Compare commits
1109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5addb64f0 | ||
|
|
ae1b9f6623 | ||
|
|
ca097c96f9 | ||
|
|
def4778d62 | ||
|
|
435e1dac89 | ||
|
|
4b23574cf8 | ||
|
|
652f051fea | ||
|
|
3fee27838e | ||
|
|
bc00d2bd9f | ||
|
|
767cf6baa6 | ||
|
|
b55d463570 | ||
|
|
15e9759e65 | ||
|
|
364697efca | ||
|
|
89102021fc | ||
|
|
4fb9528c2f | ||
|
|
336204f012 | ||
|
|
6c7af96773 | ||
|
|
9e8ab40369 | ||
|
|
ce7a6e91fb | ||
|
|
4189b7f051 | ||
|
|
e70d0b08c9 | ||
|
|
b395e6626b | ||
|
|
10b7295922 | ||
|
|
c7c13f18a5 | ||
|
|
26d48e0634 | ||
|
|
89599a9541 | ||
|
|
8ecb86f0d7 | ||
|
|
0cb7e5a2e7 | ||
|
|
15e21e9ea3 | ||
|
|
80960fa319 | ||
|
|
a33c87852b | ||
|
|
ce7e14da27 | ||
|
|
47f4a96ffa | ||
|
|
189fc4bcbe | ||
|
|
7b19cd5f4b | ||
|
|
b47d94c904 | ||
|
|
2ea2286db4 | ||
|
|
1805ee0d22 | ||
|
|
41597adffa | ||
|
|
6212e8fa3b | ||
|
|
83a85189c7 | ||
|
|
6622553979 | ||
|
|
12be5c44ca | ||
|
|
e9cabc8e1d | ||
|
|
eeb5e3c2a3 | ||
|
|
5dda2aa306 | ||
|
|
5440381553 | ||
|
|
ba2e51215e | ||
|
|
d293374b66 | ||
|
|
489fef48ba | ||
|
|
9fd6f0ca66 | ||
|
|
8e36f2bc68 | ||
|
|
3f76571d34 | ||
|
|
6f9b50990d | ||
|
|
1bf1fc0ea8 | ||
|
|
95a9527ed6 | ||
|
|
3849e1518f | ||
|
|
49d74a2e7f | ||
|
|
2e01aa0075 | ||
|
|
f06171fd5a | ||
|
|
d4961b9f8e | ||
|
|
0aa20e449e | ||
|
|
d46fa57a6a | ||
|
|
609df7ecc0 | ||
|
|
1d6b663433 | ||
|
|
1bf1ba5124 | ||
|
|
7c0cda153d | ||
|
|
beb501fc28 | ||
|
|
359f77d4f6 | ||
|
|
b351a44fb6 | ||
|
|
db9072f998 | ||
|
|
92e9dfb399 | ||
|
|
e186ecc9f8 | ||
|
|
37593c1952 | ||
|
|
88a81c5d31 | ||
|
|
fa6dac8383 | ||
|
|
a7092af2fd | ||
|
|
be56b74735 | ||
|
|
2f5ae50726 | ||
|
|
4b85e6c389 | ||
|
|
7354ed70ce | ||
|
|
5bb2ea0f4e | ||
|
|
45bb65bba1 | ||
|
|
392dbe45f0 | ||
|
|
7df47ce4d9 | ||
|
|
0006ed0547 | ||
|
|
f3eb3c90fd | ||
|
|
7e10342c2a | ||
|
|
4941b40cbb | ||
|
|
6045186f7d | ||
|
|
6d852d319a | ||
|
|
df5345140e | ||
|
|
fc84f7f6eb | ||
|
|
e745060c75 | ||
|
|
4de13707ee | ||
|
|
87713d2172 | ||
|
|
77cb36f181 | ||
|
|
326b9431c7 | ||
|
|
3faa4a8f2e | ||
|
|
c51af4ba52 | ||
|
|
cabd1c095e | ||
|
|
6f461522a5 | ||
|
|
37a2901af3 | ||
|
|
371b6e946c | ||
|
|
4f6edf36e9 | ||
|
|
c7cd6aa5bd | ||
|
|
15f925336c | ||
|
|
d76607b112 | ||
|
|
73e688875a | ||
|
|
419d3a9d80 | ||
|
|
8cd952c88f | ||
|
|
ab720d3258 | ||
|
|
99cba6ac64 | ||
|
|
ca51b4532a | ||
|
|
c6907c2203 | ||
|
|
ebc1393c5c | ||
|
|
4ddff16bbe | ||
|
|
f1ed746308 | ||
|
|
ea3071642d | ||
|
|
b871b4b8b2 | ||
|
|
dd5304d3eb | ||
|
|
1a660147ed | ||
|
|
1d526a0180 | ||
|
|
08eff926a6 | ||
|
|
b4b27ff677 | ||
|
|
a11fc3849b | ||
|
|
3b9060ee11 | ||
|
|
2318fd822c | ||
|
|
2c51edd0c0 | ||
|
|
1e11096473 | ||
|
|
90538a3b46 | ||
|
|
f8981f3d12 | ||
|
|
b471f01d66 | ||
|
|
5b5f6d8e17 | ||
|
|
724eced022 | ||
|
|
9ef08c7949 | ||
|
|
266761d8d9 | ||
|
|
fe5954c98f | ||
|
|
0265d95faa | ||
|
|
d4b70fe895 | ||
|
|
fd60b1815c | ||
|
|
90d71e63e0 | ||
|
|
cc206cf2da | ||
|
|
87f39f12c9 | ||
|
|
c51e0466be | ||
|
|
497b315fc7 | ||
|
|
89cbd3c942 | ||
|
|
f653b2f0cf | ||
|
|
fbe35add82 | ||
|
|
c19728ca39 | ||
|
|
b07d4e8ce4 | ||
|
|
280a89a4d1 | ||
|
|
1b7f39eb44 | ||
|
|
05937f4130 | ||
|
|
aea487059b | ||
|
|
2cb3252228 | ||
|
|
1d73150c1f | ||
|
|
5f2d62096a | ||
|
|
ad06741d1e | ||
|
|
9751f76186 | ||
|
|
31a7bb381a | ||
|
|
e63b6594f2 | ||
|
|
3ba5fe0d7a | ||
|
|
05b8e32844 | ||
|
|
8dc2fb3e33 | ||
|
|
e63cec5492 | ||
|
|
47fe956f74 | ||
|
|
c684e9f3aa | ||
|
|
5d32e4c1bf | ||
|
|
7c9db49f0c | ||
|
|
59df8190a4 | ||
|
|
e4241c6155 | ||
|
|
88e8431437 | ||
|
|
c3585a5ccf | ||
|
|
a54ecccd5b | ||
|
|
adbcd0e0e7 | ||
|
|
e874351f04 | ||
|
|
7ecd828237 | ||
|
|
ec4aa5e4ce | ||
|
|
1703da8706 | ||
|
|
b95ef3e489 | ||
|
|
3a7f6d1a5d | ||
|
|
053bc57c37 | ||
|
|
0f61aa58d6 | ||
|
|
c20bacbf76 | ||
|
|
304433ebaa | ||
|
|
534b47ebf2 | ||
|
|
b40c04dfa6 | ||
|
|
76c9cb65f2 | ||
|
|
e99e2948e6 | ||
|
|
9415af643f | ||
|
|
55b8669acb | ||
|
|
6a1841b924 | ||
|
|
18d7721c38 | ||
|
|
f6866ce388 | ||
|
|
f115ce4e09 | ||
|
|
2c49a151d2 | ||
|
|
5b156dca7f | ||
|
|
353fb358eb | ||
|
|
9d022c0a88 | ||
|
|
8a6ef6ed14 | ||
|
|
354a6fc8dc | ||
|
|
2e2949c8ea | ||
|
|
6d183a87e1 | ||
|
|
920333ea98 | ||
|
|
301b8fb03a | ||
|
|
b4f66c2cd7 | ||
|
|
1b8187ca67 | ||
|
|
5eb00b77a3 | ||
|
|
2073ea0046 | ||
|
|
dedd37b9ce | ||
|
|
733595037a | ||
|
|
a682f6f1c7 | ||
|
|
f9abbbbb48 | ||
|
|
abb994c0c2 | ||
|
|
fcf1bc73db | ||
|
|
ee432c0d30 | ||
|
|
df5dbc0558 | ||
|
|
746eaef3b4 | ||
|
|
919da41dc3 | ||
|
|
028b9aa201 | ||
|
|
f98268a334 | ||
|
|
1250a6fd68 | ||
|
|
859038a9e6 | ||
|
|
32e25497a3 | ||
|
|
472597fb6b | ||
|
|
26dc39213a | ||
|
|
cca62060cb | ||
|
|
21e8a7d90c | ||
|
|
9ae170a936 | ||
|
|
15d09a3bbc | ||
|
|
7d7c4f15b8 | ||
|
|
7455c00327 | ||
|
|
c1cc6b2462 | ||
|
|
4b5a92e88e | ||
|
|
579a3f2fb8 | ||
|
|
daec2bdcdb | ||
|
|
ab8177c53a | ||
|
|
46a5d38bd7 | ||
|
|
70f6ff8c3b | ||
|
|
35a00672dd | ||
|
|
f1157dbc41 | ||
|
|
85c5898d8e | ||
|
|
e486fbceea | ||
|
|
0fc9009b90 | ||
|
|
c5e9c82c90 | ||
|
|
ead8010a57 | ||
|
|
978e5bbe9f | ||
|
|
6779fae5ee | ||
|
|
99a080ea78 | ||
|
|
988809a9d6 | ||
|
|
a934c36a85 | ||
|
|
f0fd91925b | ||
|
|
18e0ae45ca | ||
|
|
ef06f7d076 | ||
|
|
78d381fc7d | ||
|
|
7488b15226 | ||
|
|
2f0c291435 | ||
|
|
5747764104 | ||
|
|
562b4b40aa | ||
|
|
bd00151b25 | ||
|
|
2880c0861e | ||
|
|
64f4523253 | ||
|
|
e071ca13a6 | ||
|
|
59914c7690 | ||
|
|
7947b56076 | ||
|
|
a6af45edac | ||
|
|
57daabf673 | ||
|
|
7c53d99da8 | ||
|
|
08a557e3e2 | ||
|
|
bddd774ce0 | ||
|
|
e27d1b8333 | ||
|
|
e4438a3a71 | ||
|
|
6a98e188c4 | ||
|
|
c6084c0f63 | ||
|
|
8c9110740d | ||
|
|
2ab735849a | ||
|
|
4cbf13ece2 | ||
|
|
10a3b68a71 | ||
|
|
e5bc1ea533 | ||
|
|
b82fbe2c13 | ||
|
|
b97c0594a5 | ||
|
|
af56476a8c | ||
|
|
a8dd079be7 | ||
|
|
563a1031f5 | ||
|
|
71a1589928 | ||
|
|
7985f685ca | ||
|
|
40a0da093b | ||
|
|
933551c519 | ||
|
|
884a69a902 | ||
|
|
5098f47444 | ||
|
|
d809a24892 | ||
|
|
192e8d828f | ||
|
|
57aa5b08a4 | ||
|
|
2d37321842 | ||
|
|
0eb97fc988 | ||
|
|
049afe5b25 | ||
|
|
1ff67ea47c | ||
|
|
8327e13454 | ||
|
|
69e13cbc39 | ||
|
|
1fc6a52546 | ||
|
|
1b4e7fbb48 | ||
|
|
16e2830624 | ||
|
|
deb904dd15 | ||
|
|
75a13a15aa | ||
|
|
1ba3e2ad4c | ||
|
|
8e5e3b871b | ||
|
|
c0e9dba320 | ||
|
|
883cf8ca6f | ||
|
|
cd1d51d597 | ||
|
|
f11eff45b7 | ||
|
|
a2a69e4bf7 | ||
|
|
8752e2672c | ||
|
|
9f9deea944 | ||
|
|
1aea9539bb | ||
|
|
db00b9279f | ||
|
|
9175097128 | ||
|
|
3b1578f931 | ||
|
|
1b2e3b7cf3 | ||
|
|
3f0675c73f | ||
|
|
9e97d7d429 | ||
|
|
71ee50b277 | ||
|
|
36f16234bc | ||
|
|
0ebe9259ac | ||
|
|
770d4f2254 | ||
|
|
2ac58e007a | ||
|
|
9f70f54316 | ||
|
|
bb65d9e28e | ||
|
|
801ab9d573 | ||
|
|
b1618227cc | ||
|
|
a7711377da | ||
|
|
8088fc7ff7 | ||
|
|
cdde07f2aa | ||
|
|
8152c4facd | ||
|
|
27db35296b | ||
|
|
d5900cd40e | ||
|
|
3eee17e69e | ||
|
|
e34a977a58 | ||
|
|
9f79b5c090 | ||
|
|
d83565932c | ||
|
|
3a8f886afa | ||
|
|
965b8adec3 | ||
|
|
ccd98b1a6d | ||
|
|
a754e71f6f | ||
|
|
f13ab4d288 | ||
|
|
45b7cfaad3 | ||
|
|
2b2269d5d8 | ||
|
|
5af6123fff | ||
|
|
1526048c94 | ||
|
|
2434e650ee | ||
|
|
303c6e8a3c | ||
|
|
a3b08c8e5e | ||
|
|
1a16028654 | ||
|
|
eb92a439e8 | ||
|
|
93de1980fa | ||
|
|
9884965233 | ||
|
|
3a82176f1f | ||
|
|
3aa4410158 | ||
|
|
943a942836 | ||
|
|
67e9c14952 | ||
|
|
d041aab111 | ||
|
|
6d59c0126f | ||
|
|
8d186cfb76 | ||
|
|
4a392f2296 | ||
|
|
aad60a4f12 | ||
|
|
344e01e98a | ||
|
|
132ccd9834 | ||
|
|
28ed42e5c0 | ||
|
|
cc034e0412 | ||
|
|
924e1f7c4b | ||
|
|
dd8a1d6cda | ||
|
|
9baf3a6cd2 | ||
|
|
5b06aea1d6 | ||
|
|
89cdd903b2 | ||
|
|
1c33a2854e | ||
|
|
940d61b239 | ||
|
|
14a1704be5 | ||
|
|
9673a3555c | ||
|
|
5eba32a61f | ||
|
|
6f31bc4bea | ||
|
|
c5eb4b8b96 | ||
|
|
1a526cf56b | ||
|
|
7a53543428 | ||
|
|
f224bd5911 | ||
|
|
850b4801d6 | ||
|
|
e58b491d11 | ||
|
|
782f507b63 | ||
|
|
8bdd6731ff | ||
|
|
70e7cd766f | ||
|
|
fba190b5b9 | ||
|
|
2b26412868 | ||
|
|
e9b0c85dd4 | ||
|
|
b07fe7b074 | ||
|
|
3af5146788 | ||
|
|
c54df4eab3 | ||
|
|
782d190d44 | ||
|
|
781076cb63 | ||
|
|
550fff933f | ||
|
|
3350d7e683 | ||
|
|
67c297069f | ||
|
|
f7a024cee3 | ||
|
|
3f9da8e0f5 | ||
|
|
4747c316d8 | ||
|
|
476127697f | ||
|
|
c5b2dd167b | ||
|
|
c82885ac04 | ||
|
|
43a1c1c826 | ||
|
|
754b55691d | ||
|
|
67651f8d05 | ||
|
|
6820b1d9c5 | ||
|
|
2a08edc471 | ||
|
|
7883c98556 | ||
|
|
d07c4b4407 | ||
|
|
2072aa2361 | ||
|
|
4c307488cd | ||
|
|
a416106250 | ||
|
|
7d3a5347a9 | ||
|
|
4401d55ecf | ||
|
|
4f8068a7ad | ||
|
|
420911bc69 | ||
|
|
0088253b32 | ||
|
|
2814fd379c | ||
|
|
2146c75f4e | ||
|
|
7b6af4d123 | ||
|
|
15c51b9dd5 | ||
|
|
31944b90f1 | ||
|
|
d299e6ff59 | ||
|
|
321d4aa509 | ||
|
|
8dc9b6bd59 | ||
|
|
7f0d43daad | ||
|
|
0f1ff50a1e | ||
|
|
3eaf69a772 | ||
|
|
c6c8cb1fe2 | ||
|
|
07f786c2f8 | ||
|
|
b7dc0c3df6 | ||
|
|
99ba25c3c5 | ||
|
|
f6b76571fa | ||
|
|
eb46c2d809 | ||
|
|
406700317b | ||
|
|
9d543750b9 | ||
|
|
56fce6190a | ||
|
|
b019d6ec75 | ||
|
|
7b95ddf399 | ||
|
|
83c81aa9b8 | ||
|
|
82ba15b521 | ||
|
|
3c5884eeaa | ||
|
|
29611cf91d | ||
|
|
b16c5f2cef | ||
|
|
a6e03ca66e | ||
|
|
efc841a9e8 | ||
|
|
6f5865f860 | ||
|
|
e232226d77 | ||
|
|
716749e3fd | ||
|
|
4882e98049 | ||
|
|
b4f60694eb | ||
|
|
61188feeae | ||
|
|
c531263f42 | ||
|
|
35b98a9f60 | ||
|
|
9082ed91e1 | ||
|
|
da8f959af0 | ||
|
|
62b1666dc6 | ||
|
|
fa03b489f0 | ||
|
|
c59be190e4 | ||
|
|
623b0ddeea | ||
|
|
885314a364 | ||
|
|
6bc4a8d7ea | ||
|
|
d591ddbe52 | ||
|
|
35164b7a64 | ||
|
|
deb1a2b921 | ||
|
|
2212dda7c7 | ||
|
|
1752e4d672 | ||
|
|
e1abaf146f | ||
|
|
d41840e18d | ||
|
|
7e01677f0a | ||
|
|
f2992442fd | ||
|
|
ee9250d60b | ||
|
|
a761e17abc | ||
|
|
ff9813e84d | ||
|
|
47266d763b | ||
|
|
0b4a83257b | ||
|
|
c24bbb85a6 | ||
|
|
513a6aa067 | ||
|
|
15defd1b5e | ||
|
|
d6ecc516bf | ||
|
|
05df3d05a4 | ||
|
|
62b7988a15 | ||
|
|
0d3fcc74a5 | ||
|
|
ecbece178f | ||
|
|
0ccc3fa9be | ||
|
|
10b60d47c7 | ||
|
|
06498df528 | ||
|
|
317653585c | ||
|
|
35de3dfeb6 | ||
|
|
0d7c4caada | ||
|
|
2d9c3580e0 | ||
|
|
4986743b3d | ||
|
|
927c88d34f | ||
|
|
77193b2ab6 | ||
|
|
d5143120d1 | ||
|
|
acb5e6ac50 | ||
|
|
77246617ca | ||
|
|
20e66d2048 | ||
|
|
50790af69b | ||
|
|
541a0afe56 | ||
|
|
ee24e67180 | ||
|
|
8c64e0c65f | ||
|
|
b839478661 | ||
|
|
b169013115 | ||
|
|
ab64f7c41f | ||
|
|
1737fc6229 | ||
|
|
3d192aed45 | ||
|
|
6b93787514 | ||
|
|
bb7f7dfda8 | ||
|
|
5096663181 | ||
|
|
f312e629bf | ||
|
|
b43af721cd | ||
|
|
47d712c01c | ||
|
|
f8cb7f5f02 | ||
|
|
604d09d7d7 | ||
|
|
3231211aa3 | ||
|
|
f818a028f9 | ||
|
|
4a3e5e12c9 | ||
|
|
776dbb578b | ||
|
|
9b17671f15 | ||
|
|
69409bb8b9 | ||
|
|
7025dd1952 | ||
|
|
2e4b308d7a | ||
|
|
2129a9789a | ||
|
|
54f4194c38 | ||
|
|
589c5a0bc3 | ||
|
|
81f385c484 | ||
|
|
4fbf2751ec | ||
|
|
760af43b4f | ||
|
|
0c2cb240df | ||
|
|
0a8b44e67d | ||
|
|
c927f3e965 | ||
|
|
e67b0dd15b | ||
|
|
2abb2f214a | ||
|
|
8fe32c52de | ||
|
|
6e55ca1af9 | ||
|
|
39d8ee619e | ||
|
|
d98e9e7ae7 | ||
|
|
2d571046e1 | ||
|
|
9b8f5af759 | ||
|
|
6a99f6f2b3 | ||
|
|
966550b342 | ||
|
|
8fd5b71016 | ||
|
|
ed19995747 | ||
|
|
397aad98fd | ||
|
|
073a3284ab | ||
|
|
4870cb5adf | ||
|
|
110ce85652 | ||
|
|
535df6c998 | ||
|
|
eb7433bf8e | ||
|
|
4dec569749 | ||
|
|
c1ccdbcb81 | ||
|
|
52dd95fb5c | ||
|
|
da2a334f2d | ||
|
|
e65a33d3cf | ||
|
|
c26425aa58 | ||
|
|
c75ddc26c7 | ||
|
|
68cf1ff88a | ||
|
|
437b55c520 | ||
|
|
1a6e254f72 | ||
|
|
6cb1672459 | ||
|
|
ea5ffce14f | ||
|
|
e05a5372eb | ||
|
|
2e6930fd77 | ||
|
|
24a55d73c3 | ||
|
|
c09e61d50c | ||
|
|
06559f8266 | ||
|
|
59f65e2b98 | ||
|
|
0f280af8b1 | ||
|
|
bd4caa873c | ||
|
|
084f35648b | ||
|
|
645ae4ed9c | ||
|
|
5af6ab0038 | ||
|
|
02a692aba5 | ||
|
|
d964343fa1 | ||
|
|
2847869475 | ||
|
|
c52e7d212f | ||
|
|
9542a17831 | ||
|
|
89fb0cbc69 | ||
|
|
181639322e | ||
|
|
9c7c2ace99 | ||
|
|
3bf18637c1 | ||
|
|
25781a7625 | ||
|
|
7f9bb5f32d | ||
|
|
f4165e9e09 | ||
|
|
e3a7b6d731 | ||
|
|
3c89b91d6b | ||
|
|
86964054d6 | ||
|
|
584a40513f | ||
|
|
7ea6019c70 | ||
|
|
28cbe77676 | ||
|
|
9005bd5df6 | ||
|
|
d0835da230 | ||
|
|
27df5e49c7 | ||
|
|
c4d2e6fa28 | ||
|
|
2961f267fd | ||
|
|
0af6d9a254 | ||
|
|
16f41a2baa | ||
|
|
f8f543057a | ||
|
|
ceccb964e6 | ||
|
|
84dca25b8e | ||
|
|
589c6e0f2f | ||
|
|
1bc3b0188a | ||
|
|
2f54548dad | ||
|
|
07229b8dff | ||
|
|
ca5f524943 | ||
|
|
92ca4d0cc6 | ||
|
|
72a1f2c759 | ||
|
|
0123bca335 | ||
|
|
7fda99fcef | ||
|
|
dbbcf438cd | ||
|
|
160e3088cd | ||
|
|
c725387b2d | ||
|
|
0eed6a3734 | ||
|
|
2a2bbe58a6 | ||
|
|
a3eb0f99dc | ||
|
|
4487ac067c | ||
|
|
4b8774041d | ||
|
|
65b69fabdb | ||
|
|
c9354282ba | ||
|
|
3f51392bea | ||
|
|
32d37cfdf1 | ||
|
|
a7a76fbb12 | ||
|
|
815ef94ed9 | ||
|
|
666cbbdfe8 | ||
|
|
320bfe1d0e | ||
|
|
ac7704e78c | ||
|
|
24da8d40ff | ||
|
|
78afd08e0f | ||
|
|
8ceb34f486 | ||
|
|
befa57c6f9 | ||
|
|
d25f2bfeff | ||
|
|
257b8fab6a | ||
|
|
27e67b32e9 | ||
|
|
e53f995994 | ||
|
|
c923f1af91 | ||
|
|
a63b038267 | ||
|
|
5d11756585 | ||
|
|
f932af9172 | ||
|
|
8e4a8a1c73 | ||
|
|
f3c29416f1 | ||
|
|
8ee08afe96 | ||
|
|
d8050ed753 | ||
|
|
ed27682686 | ||
|
|
354c4cac1f | ||
|
|
fbb21fb1ae | ||
|
|
e1f7791e97 | ||
|
|
09f94edd93 | ||
|
|
ff0febbaa9 | ||
|
|
a394df59da | ||
|
|
feb404f86b | ||
|
|
54f7708e2b | ||
|
|
d0fe113945 | ||
|
|
2d6c30d061 | ||
|
|
62c6c1c8ad | ||
|
|
b58bd8e8e5 | ||
|
|
c2afd2d9bf | ||
|
|
8a5050ea41 | ||
|
|
5ee6135256 | ||
|
|
4bd08bed22 | ||
|
|
6006721c6d | ||
|
|
59074c7bc0 | ||
|
|
930f3773e2 | ||
|
|
ed16eb3a3d | ||
|
|
4d950e5780 | ||
|
|
016e4ee210 | ||
|
|
15187e7c21 | ||
|
|
a783fe5758 | ||
|
|
78f24203ce | ||
|
|
98bb548a73 | ||
|
|
42c66863d0 | ||
|
|
8fa87650b2 | ||
|
|
642aabdac0 | ||
|
|
19b863af40 | ||
|
|
def9f1c320 | ||
|
|
ec06ba244e | ||
|
|
de502a44c6 | ||
|
|
46b9282c6e | ||
|
|
e39a6d9ef4 | ||
|
|
cf5970336a | ||
|
|
e0b4528b17 | ||
|
|
33d339a262 | ||
|
|
fa7661b306 | ||
|
|
aad8209928 | ||
|
|
9200cb0695 | ||
|
|
f5c27ec7f4 | ||
|
|
28c72050e0 | ||
|
|
534400ee42 | ||
|
|
4161d7ace9 | ||
|
|
6e6ece66c6 | ||
|
|
15c1e42c20 | ||
|
|
19515e8a8b | ||
|
|
f4b407a7c4 | ||
|
|
924fa8c9dc | ||
|
|
bd8165a1b1 | ||
|
|
84ca2010e1 | ||
|
|
25507acdc9 | ||
|
|
03cd88c336 | ||
|
|
d10b7cdc51 | ||
|
|
9fa95cce6a | ||
|
|
cb620e67c7 | ||
|
|
34ba0e14b0 | ||
|
|
09c3e90e3b | ||
|
|
a226c60861 | ||
|
|
891d865044 | ||
|
|
842ccfafe6 | ||
|
|
1729e45031 | ||
|
|
655773e1c1 | ||
|
|
557ad70242 | ||
|
|
477824aeaa | ||
|
|
5b6d33e29c | ||
|
|
c277fb816a | ||
|
|
a4463d044f | ||
|
|
db4e417a13 | ||
|
|
f540bd0bcf | ||
|
|
45de714592 | ||
|
|
7edfe64da6 | ||
|
|
4cf74bc405 | ||
|
|
b9db5e149e | ||
|
|
a25d924bb9 | ||
|
|
8c7729e42c | ||
|
|
8d9dfb54fc | ||
|
|
360b63d4f4 | ||
|
|
876e722b24 | ||
|
|
0a38695063 | ||
|
|
7123b0f7ba | ||
|
|
0e73be83a8 | ||
|
|
3205536a09 | ||
|
|
a3392c6ea7 | ||
|
|
7279ed4658 | ||
|
|
1da46f3de0 | ||
|
|
78cf16ace9 | ||
|
|
d7aa6e0189 | ||
|
|
0fb28e8cab | ||
|
|
70cdd95006 | ||
|
|
0e7730bccc | ||
|
|
e5f87434a5 | ||
|
|
d76b2c2fb7 | ||
|
|
682cad39eb | ||
|
|
f67e925f72 | ||
|
|
26cd4f54e8 | ||
|
|
a9284214e2 | ||
|
|
a110072bba | ||
|
|
71d5234305 | ||
|
|
c6022dea9f | ||
|
|
9409900898 | ||
|
|
2ba9c1ed90 | ||
|
|
dba83d45a5 | ||
|
|
e1a7d5a4ae | ||
|
|
16893e414f | ||
|
|
df54890c15 | ||
|
|
9728d8960f | ||
|
|
31e587c037 | ||
|
|
926a55a84f | ||
|
|
35f09d1394 | ||
|
|
2e60e145d7 | ||
|
|
6421575f5f | ||
|
|
50b17fdd65 | ||
|
|
2d491c9e7d | ||
|
|
c089480260 | ||
|
|
696c1eff03 | ||
|
|
7e6e35160f | ||
|
|
083d2e9aa0 | ||
|
|
01287bac99 | ||
|
|
247ee0dc49 | ||
|
|
20f4911e80 | ||
|
|
bd339212de | ||
|
|
8ed7e52a37 | ||
|
|
0641606619 | ||
|
|
ad5e6eb8b0 | ||
|
|
27b0dbc22d | ||
|
|
a89d4ad625 | ||
|
|
e107e0f842 | ||
|
|
09672a99dd | ||
|
|
ff93a011a4 | ||
|
|
ccd4711a0a | ||
|
|
3f96e74961 | ||
|
|
df24a0fc96 | ||
|
|
0296c2bbb9 | ||
|
|
3230cb3133 | ||
|
|
330a82508d | ||
|
|
6a20cfc0b8 | ||
|
|
2f0e0480da | ||
|
|
8dea2c23d7 | ||
|
|
bacc2d1835 | ||
|
|
fab427972b | ||
|
|
6cf9039593 | ||
|
|
c53172d716 | ||
|
|
cfd07cc290 | ||
|
|
4d287956fd | ||
|
|
0f7d644b8d | ||
|
|
b481166481 | ||
|
|
838f417ce0 | ||
|
|
3196be937a | ||
|
|
620b0670db | ||
|
|
8c84210555 | ||
|
|
89a8100b6c | ||
|
|
8ed6904646 | ||
|
|
56d880030e | ||
|
|
1e1a56f99b | ||
|
|
093cb42500 | ||
|
|
21d7e16559 | ||
|
|
f5e1aa5517 | ||
|
|
3721a7869e | ||
|
|
7c8158a852 | ||
|
|
66a4537959 | ||
|
|
c14350336e | ||
|
|
440b5ab95f | ||
|
|
aa630d36c2 | ||
|
|
a4145ce2fb | ||
|
|
ebc9c29365 | ||
|
|
e724abdcd1 | ||
|
|
c30acf09fb | ||
|
|
bafa32aa67 | ||
|
|
2c2c6a71a9 | ||
|
|
b57482d7fe | ||
|
|
9f58afd8f9 | ||
|
|
f1b3d74abb | ||
|
|
ab9ace2749 | ||
|
|
a58be59adb | ||
|
|
3686f2d454 | ||
|
|
225f1e259a | ||
|
|
99a5c78bbb | ||
|
|
991915a935 | ||
|
|
9b6605c3d5 | ||
|
|
d2816c9c48 | ||
|
|
ba073c8a46 | ||
|
|
0a8b0c65bd | ||
|
|
51b70b3fca | ||
|
|
790a3ead5d | ||
|
|
d6b3794395 | ||
|
|
97d488a95c | ||
|
|
d568ecda53 | ||
|
|
560b119d32 | ||
|
|
4af3dd7abc | ||
|
|
afbd4c846a | ||
|
|
d34c89a819 | ||
|
|
8c182e271f | ||
|
|
3b9ebe0523 | ||
|
|
30102612b7 | ||
|
|
8710079d5d | ||
|
|
bed3d8bd38 | ||
|
|
e586e5df37 | ||
|
|
97cf9dadd2 | ||
|
|
c6872e1fea | ||
|
|
19bf2112a1 | ||
|
|
9085582211 | ||
|
|
4c01117dd2 | ||
|
|
5829ecb648 | ||
|
|
af75908b7a | ||
|
|
3046e920ea | ||
|
|
631ba97635 | ||
|
|
4ef5de4002 | ||
|
|
94323f98ac | ||
|
|
1c9b852783 | ||
|
|
d63108d613 | ||
|
|
3acd72a6ee | ||
|
|
8f400c269e | ||
|
|
fc980e7792 | ||
|
|
939f3ce7ce | ||
|
|
21f533774f | ||
|
|
5cf1acc403 | ||
|
|
23486b5438 | ||
|
|
430285f55b | ||
|
|
43ec09c3cb | ||
|
|
a82adcc933 | ||
|
|
6e40c1b26a | ||
|
|
970886629d | ||
|
|
ae45c23cc5 | ||
|
|
707e54c484 | ||
|
|
3333152b54 | ||
|
|
7256366ba9 | ||
|
|
112fb9c07d | ||
|
|
53804173bb | ||
|
|
2714f32238 | ||
|
|
19555f6103 | ||
|
|
50d337e807 | ||
|
|
efe9f61bc2 | ||
|
|
db33c071e1 | ||
|
|
3e12fd0656 | ||
|
|
b3db9ff0b6 | ||
|
|
cfe2278096 | ||
|
|
6614831739 | ||
|
|
82dc6f32f8 | ||
|
|
c2eb0bd40f | ||
|
|
b1e99a5cf9 | ||
|
|
b23420392e | ||
|
|
b128bfaf21 | ||
|
|
1f8fb28d93 | ||
|
|
5a63540e8a | ||
|
|
be82404168 | ||
|
|
7d84408cda | ||
|
|
780d1843ca | ||
|
|
956129fbf7 | ||
|
|
7f0e791f9d | ||
|
|
bdfabe1e9a | ||
|
|
b7c0d3446c | ||
|
|
b112b23152 | ||
|
|
918c55de90 | ||
|
|
c225e95b1d | ||
|
|
7b576fae79 | ||
|
|
367bff621c | ||
|
|
d515e4b1aa | ||
|
|
2c5fdc51f4 | ||
|
|
97807bf607 | ||
|
|
b932d94d99 | ||
|
|
2038919b7e | ||
|
|
eac4b9ba74 | ||
|
|
ee37a762ef | ||
|
|
387f04732b | ||
|
|
c842c8ff20 | ||
|
|
12dd157fea | ||
|
|
c5037f06e4 | ||
|
|
2b92a78c41 | ||
|
|
f17ab37b2f | ||
|
|
e1afbfa7b4 | ||
|
|
e19bd9bc4b | ||
|
|
6ac49dacdd | ||
|
|
bd57b650a8 | ||
|
|
0ed0314569 | ||
|
|
bc6163c55a | ||
|
|
910aa9094c | ||
|
|
ff44d2d1b8 | ||
|
|
79a9748ae6 | ||
|
|
f5eaec7ab3 | ||
|
|
11e7604d1a | ||
|
|
b0bf2a7513 | ||
|
|
a9f4d018e1 | ||
|
|
b8394f1e00 | ||
|
|
f5d4bb8074 | ||
|
|
c050480387 | ||
|
|
fe86c0c4b4 | ||
|
|
2c4a1dd519 | ||
|
|
56afde1f9f | ||
|
|
22663bc66e | ||
|
|
de8b95533d | ||
|
|
35b7516674 | ||
|
|
6a1ee0eb97 | ||
|
|
1f9d0154df | ||
|
|
e284b84bf9 | ||
|
|
3462999366 | ||
|
|
d5da7430a2 | ||
|
|
f9d18a8758 | ||
|
|
25b40db757 | ||
|
|
e9ebd1df98 | ||
|
|
f3b799912e | ||
|
|
0ee0005154 | ||
|
|
49e4d155ee | ||
|
|
e30ec85016 | ||
|
|
d0427bead0 | ||
|
|
56c8edaf66 | ||
|
|
9e88f2e2fb | ||
|
|
e34df7a7a1 | ||
|
|
cee1fccaca | ||
|
|
36af9d9597 | ||
|
|
bb6ab205fe | ||
|
|
9904684d35 | ||
|
|
74e5115b86 | ||
|
|
5ee512d803 | ||
|
|
6c69e0936b | ||
|
|
8d4f182500 | ||
|
|
2c2d49760d | ||
|
|
0783baea3e | ||
|
|
c337938af5 | ||
|
|
4bb69e3e3e | ||
|
|
b847ab07a3 | ||
|
|
52c2c9762f | ||
|
|
499de51f2b | ||
|
|
77da6db8e8 | ||
|
|
1d25bd58a8 | ||
|
|
869714fbf5 | ||
|
|
00a875db93 | ||
|
|
66e0f4b6f8 | ||
|
|
02d16bfb34 | ||
|
|
3124a38123 | ||
|
|
e95111ac07 | ||
|
|
eba29da632 | ||
|
|
83fc0921c1 | ||
|
|
ab41a5d5c3 | ||
|
|
f57bb2f142 | ||
|
|
bc54dd0399 | ||
|
|
f55db15a01 | ||
|
|
2394aabcb9 | ||
|
|
3a0df657b3 | ||
|
|
0aa1815153 | ||
|
|
76f3ff9dd9 | ||
|
|
5cb48d981f | ||
|
|
85cc8f8008 | ||
|
|
8d1bc9e60f | ||
|
|
ec40d04382 | ||
|
|
d15dc0b1f8 | ||
|
|
e1f5b8ba57 | ||
|
|
df9dc6d516 | ||
|
|
ddc4885543 | ||
|
|
1e23855709 | ||
|
|
c38fd68ed7 | ||
|
|
bb6e52f356 | ||
|
|
8d8ea8bbba | ||
|
|
38a9d77342 | ||
|
|
e56e120175 | ||
|
|
fc95c7e71e | ||
|
|
dad379736d | ||
|
|
f8794cb3ce | ||
|
|
1c9167e3b7 | ||
|
|
a738327678 | ||
|
|
2f54b200de | ||
|
|
81edb1b45b | ||
|
|
cb735b9b0d | ||
|
|
c08ae7796f | ||
|
|
eb7c6b0342 | ||
|
|
c033ed1b65 | ||
|
|
d223de8ff3 | ||
|
|
293bc70947 | ||
|
|
5076952202 | ||
|
|
3cbe7315e8 | ||
|
|
30229f1652 | ||
|
|
12d00b238e | ||
|
|
33cb39733f | ||
|
|
3f68601222 | ||
|
|
871b0b5cb9 | ||
|
|
ed949508a6 | ||
|
|
7d45db068b | ||
|
|
248aa580a1 | ||
|
|
fdaa01275a | ||
|
|
9df76ccfe9 | ||
|
|
095b69184a | ||
|
|
43331cfb3d | ||
|
|
8d55d78574 | ||
|
|
3218c35341 | ||
|
|
364378a814 | ||
|
|
b1393ec2f1 | ||
|
|
5fccc04da4 | ||
|
|
e045b86d7f | ||
|
|
44ad295572 | ||
|
|
296c9b459e | ||
|
|
abe0799d70 | ||
|
|
99ee84e20d | ||
|
|
831e79f50a | ||
|
|
00e150f6a5 | ||
|
|
206c5372a6 | ||
|
|
1c326d53c6 | ||
|
|
a05ba2e914 | ||
|
|
f06ca87f97 | ||
|
|
926d6cd6e4 | ||
|
|
f0e6acb6e2 | ||
|
|
7a96a2c896 | ||
|
|
a5f9983037 | ||
|
|
2dbcaf859f | ||
|
|
331be99cbf | ||
|
|
6045ee242f | ||
|
|
5aca0c0172 | ||
|
|
1a32cf036a | ||
|
|
586acddd1a | ||
|
|
95b2b24302 | ||
|
|
08069e9368 | ||
|
|
2fcf23bbfe | ||
|
|
07586f97e8 | ||
|
|
717b34139b | ||
|
|
a62947826f | ||
|
|
1ce3cc3269 | ||
|
|
e3140a0803 | ||
|
|
88d73de752 | ||
|
|
f68b3df81c | ||
|
|
9ec2cfc5dc | ||
|
|
644e8fc5b6 | ||
|
|
ad38db82f9 | ||
|
|
09db6ec935 | ||
|
|
074cd25b04 | ||
|
|
7200717e82 | ||
|
|
2984499f28 | ||
|
|
84731c8be5 | ||
|
|
5e4f54d643 | ||
|
|
49ed77a706 | ||
|
|
c1f51277d3 | ||
|
|
a80c43294b | ||
|
|
65b8593c7c | ||
|
|
38a136833f | ||
|
|
e5d0ad2a33 | ||
|
|
7361d60943 | ||
|
|
391786696a | ||
|
|
97a104abc3 | ||
|
|
57ae7ea22b | ||
|
|
31730e7095 | ||
|
|
fc3df514e8 | ||
|
|
a1179e55e1 | ||
|
|
24346e2039 | ||
|
|
b479ceb24f | ||
|
|
dd3fbcc8d7 | ||
|
|
e6da325e8b | ||
|
|
f504399781 | ||
|
|
2f2a4e4ef0 | ||
|
|
85fa89c49c | ||
|
|
9e1cc26f8a | ||
|
|
86a0eb0268 | ||
|
|
b65bce5924 | ||
|
|
9bbd0409ab | ||
|
|
a0282569d5 | ||
|
|
db3e3a0231 | ||
|
|
7f76a642a8 | ||
|
|
5df822ca11 | ||
|
|
05f5dc26de | ||
|
|
5ced56b5b5 | ||
|
|
ea137a96d9 | ||
|
|
71cbde8ba4 | ||
|
|
6752f7d6f6 |
236
.github/CONTRIBUTING.md
vendored
Normal file
@ -0,0 +1,236 @@
|
||||
# Contributing
|
||||
|
||||
Thank you for being interested in contributing to HTTPX.
|
||||
There are many ways you can contribute to the project:
|
||||
|
||||
- Try HTTPX and [report bugs/issues you find](https://github.com/encode/httpx/issues/new)
|
||||
- [Implement new features](https://github.com/encode/httpx/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)
|
||||
- [Review Pull Requests of others](https://github.com/encode/httpx/pulls)
|
||||
- Write documentation
|
||||
- Participate in discussions
|
||||
|
||||
## Reporting Bugs or Other Issues
|
||||
|
||||
Found something that HTTPX should support?
|
||||
Stumbled upon some unexpected behaviour?
|
||||
|
||||
Contributions should generally start out with [a discussion](https://github.com/encode/httpx/discussions).
|
||||
Possible bugs may be raised as a "Potential Issue" discussion, feature requests may
|
||||
be raised as an "Ideas" discussion. We can then determine if the discussion needs
|
||||
to be escalated into an "Issue" or not, or if we'd consider a pull request.
|
||||
|
||||
Try to be more descriptive as you can and in case of a bug report,
|
||||
provide as much information as possible like:
|
||||
|
||||
- OS platform
|
||||
- Python version
|
||||
- Installed dependencies and versions (`python -m pip freeze`)
|
||||
- Code snippet
|
||||
- Error traceback
|
||||
|
||||
You should always try to reduce any examples to the *simplest possible case*
|
||||
that demonstrates the issue.
|
||||
|
||||
Some possibly useful tips for narrowing down potential issues...
|
||||
|
||||
- Does the issue exist on HTTP/1.1, or HTTP/2, or both?
|
||||
- Does the issue exist with `Client`, `AsyncClient`, or both?
|
||||
- When using `AsyncClient` does the issue exist when using `asyncio` or `trio`, or both?
|
||||
|
||||
## Development
|
||||
|
||||
To start developing HTTPX create a **fork** of the
|
||||
[HTTPX repository](https://github.com/encode/httpx) on GitHub.
|
||||
|
||||
Then clone your fork with the following command replacing `YOUR-USERNAME` with
|
||||
your GitHub username:
|
||||
|
||||
```shell
|
||||
$ git clone https://github.com/YOUR-USERNAME/httpx
|
||||
```
|
||||
|
||||
You can now install the project and its dependencies using:
|
||||
|
||||
```shell
|
||||
$ cd httpx
|
||||
$ scripts/install
|
||||
```
|
||||
|
||||
## Testing and Linting
|
||||
|
||||
We use custom shell scripts to automate testing, linting,
|
||||
and documentation building workflow.
|
||||
|
||||
To run the tests, use:
|
||||
|
||||
```shell
|
||||
$ scripts/test
|
||||
```
|
||||
|
||||
!!! warning
|
||||
The test suite spawns testing servers on ports **8000** and **8001**.
|
||||
Make sure these are not in use, so the tests can run properly.
|
||||
|
||||
You can run a single test script like this:
|
||||
|
||||
```shell
|
||||
$ scripts/test -- tests/test_multipart.py
|
||||
```
|
||||
|
||||
To run the code auto-formatting:
|
||||
|
||||
```shell
|
||||
$ scripts/lint
|
||||
```
|
||||
|
||||
Lastly, to run code checks separately (they are also run as part of `scripts/test`), run:
|
||||
|
||||
```shell
|
||||
$ scripts/check
|
||||
```
|
||||
|
||||
## Documenting
|
||||
|
||||
Documentation pages are located under the `docs/` folder.
|
||||
|
||||
To run the documentation site locally (useful for previewing changes), use:
|
||||
|
||||
```shell
|
||||
$ scripts/docs
|
||||
```
|
||||
|
||||
## Resolving Build / CI Failures
|
||||
|
||||
Once you've submitted your pull request, the test suite will automatically run, and the results will show up in GitHub.
|
||||
If the test suite fails, you'll want to click through to the "Details" link, and try to identify why the test suite failed.
|
||||
|
||||
<p align="center" style="margin: 0 0 10px">
|
||||
<img src="https://raw.githubusercontent.com/encode/httpx/master/docs/img/gh-actions-fail.png" alt='Failing PR commit status'>
|
||||
</p>
|
||||
|
||||
Here are some common ways the test suite can fail:
|
||||
|
||||
### Check Job Failed
|
||||
|
||||
<p align="center" style="margin: 0 0 10px">
|
||||
<img src="https://raw.githubusercontent.com/encode/httpx/master/docs/img/gh-actions-fail-check.png" alt='Failing GitHub action lint job'>
|
||||
</p>
|
||||
|
||||
This job failing means there is either a code formatting issue or type-annotation issue.
|
||||
You can look at the job output to figure out why it's failed or within a shell run:
|
||||
|
||||
```shell
|
||||
$ scripts/check
|
||||
```
|
||||
|
||||
It may be worth it to run `$ scripts/lint` to attempt auto-formatting the code
|
||||
and if that job succeeds commit the changes.
|
||||
|
||||
### Docs Job Failed
|
||||
|
||||
This job failing means the documentation failed to build. This can happen for
|
||||
a variety of reasons like invalid markdown or missing configuration within `mkdocs.yml`.
|
||||
|
||||
### Python 3.X Job Failed
|
||||
|
||||
<p align="center" style="margin: 0 0 10px">
|
||||
<img src="https://raw.githubusercontent.com/encode/httpx/master/docs/img/gh-actions-fail-test.png" alt='Failing GitHub action test job'>
|
||||
</p>
|
||||
|
||||
This job failing means the unit tests failed or not all code paths are covered by unit tests.
|
||||
|
||||
If tests are failing you will see this message under the coverage report:
|
||||
|
||||
`=== 1 failed, 435 passed, 1 skipped, 1 xfailed in 11.09s ===`
|
||||
|
||||
If tests succeed but coverage doesn't reach our current threshold, you will see this
|
||||
message under the coverage report:
|
||||
|
||||
`FAIL Required test coverage of 100% not reached. Total coverage: 99.00%`
|
||||
|
||||
## Releasing
|
||||
|
||||
*This section is targeted at HTTPX maintainers.*
|
||||
|
||||
Before releasing a new version, create a pull request that includes:
|
||||
|
||||
- **An update to the changelog**:
|
||||
- We follow the format from [keepachangelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- [Compare](https://github.com/encode/httpx/compare/) `master` with the tag of the latest release, and list all entries that are of interest to our users:
|
||||
- Things that **must** go in the changelog: added, changed, deprecated or removed features, and bug fixes.
|
||||
- Things that **should not** go in the changelog: changes to documentation, tests or tooling.
|
||||
- Try sorting entries in descending order of impact / importance.
|
||||
- Keep it concise and to-the-point. 🎯
|
||||
- **A version bump**: see `__version__.py`.
|
||||
|
||||
For an example, see [#1006](https://github.com/encode/httpx/pull/1006).
|
||||
|
||||
Once the release PR is merged, create a
|
||||
[new release](https://github.com/encode/httpx/releases/new) including:
|
||||
|
||||
- Tag version like `0.13.3`.
|
||||
- Release title `Version 0.13.3`
|
||||
- Description copied from the changelog.
|
||||
|
||||
Once created this release will be automatically uploaded to PyPI.
|
||||
|
||||
If something goes wrong with the PyPI job the release can be published using the
|
||||
`scripts/publish` script.
|
||||
|
||||
## Development proxy setup
|
||||
|
||||
To test and debug requests via a proxy it's best to run a proxy server locally.
|
||||
Any server should do but HTTPCore's test suite uses
|
||||
[`mitmproxy`](https://mitmproxy.org/) which is written in Python, it's fully
|
||||
featured and has excellent UI and tools for introspection of requests.
|
||||
|
||||
You can install `mitmproxy` using `pip install mitmproxy` or [several
|
||||
other ways](https://docs.mitmproxy.org/stable/overview-installation/).
|
||||
|
||||
`mitmproxy` does require setting up local TLS certificates for HTTPS requests,
|
||||
as its main purpose is to allow developers to inspect requests that pass through
|
||||
it. We can set them up follows:
|
||||
|
||||
1. [`pip install trustme-cli`](https://github.com/sethmlarson/trustme-cli/).
|
||||
2. `trustme-cli -i example.org www.example.org`, assuming you want to test
|
||||
connecting to that domain, this will create three files: `server.pem`,
|
||||
`server.key` and `client.pem`.
|
||||
3. `mitmproxy` requires a PEM file that includes the private key and the
|
||||
certificate so we need to concatenate them:
|
||||
`cat server.key server.pem > server.withkey.pem`.
|
||||
4. Start the proxy server `mitmproxy --certs server.withkey.pem`, or use the
|
||||
[other mitmproxy commands](https://docs.mitmproxy.org/stable/) with different
|
||||
UI options.
|
||||
|
||||
At this point the server is ready to start serving requests, you'll need to
|
||||
configure HTTPX as described in the
|
||||
[proxy section](https://www.python-httpx.org/advanced/#http-proxying) and
|
||||
the [SSL certificates section](https://www.python-httpx.org/advanced/#ssl-certificates),
|
||||
this is where our previously generated `client.pem` comes in:
|
||||
|
||||
```
|
||||
import httpx
|
||||
|
||||
ssl_context = httpx.SSLContext()
|
||||
ssl_context.load_verify_locations("/path/to/client.pem")
|
||||
|
||||
with httpx.Client(proxy="http://127.0.0.1:8080/", ssl_context=ssl_context) as client:
|
||||
response = client.get("https://example.org")
|
||||
print(response.status_code) # should print 200
|
||||
```
|
||||
|
||||
Note, however, that HTTPS requests will only succeed to the host specified
|
||||
in the SSL/TLS certificate we generated, HTTPS requests to other hosts will
|
||||
raise an error like:
|
||||
|
||||
```
|
||||
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate
|
||||
verify failed: Hostname mismatch, certificate is not valid for
|
||||
'duckduckgo.com'. (_ssl.c:1108)
|
||||
```
|
||||
|
||||
If you want to make requests to more hosts you'll need to regenerate the
|
||||
certificates and include all the hosts you intend to connect to in the
|
||||
seconds step, i.e.
|
||||
|
||||
`trustme-cli -i example.org www.example.org duckduckgo.com www.duckduckgo.com`
|
||||
1
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
github: encode
|
||||
16
.github/ISSUE_TEMPLATE/1-issue.md
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
name: Issue
|
||||
about: Please only raise an issue if you've been advised to do so after discussion. Thanks! 🙏
|
||||
---
|
||||
|
||||
The starting point for issues should usually be a discussion...
|
||||
|
||||
https://github.com/encode/httpx/discussions
|
||||
|
||||
Possible bugs may be raised as a "Potential Issue" discussion, feature requests may be raised as an "Ideas" discussion. We can then determine if the discussion needs to be escalated into an "Issue" or not.
|
||||
|
||||
This will help us ensure that the "Issues" list properly reflects ongoing or needed work on the project.
|
||||
|
||||
---
|
||||
|
||||
- [ ] Initially raised as discussion #...
|
||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
# Ref: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discussions
|
||||
url: https://github.com/encode/httpx/discussions
|
||||
about: >
|
||||
The "Discussions" forum is where you want to start. 💖
|
||||
- name: Chat
|
||||
url: https://gitter.im/encode/community
|
||||
about: >
|
||||
Our community chat forum.
|
||||
12
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
<!-- Thanks for contributing to HTTPX! 💚
|
||||
Given this is a project maintained by volunteers, please read this template to not waste your time, or ours! 😁 -->
|
||||
|
||||
# Summary
|
||||
|
||||
<!-- Write a small summary about what is happening here. -->
|
||||
|
||||
# Checklist
|
||||
|
||||
- [ ] I understand that this PR may be closed in case there was no previous discussion. (This doesn't apply to typos!)
|
||||
- [ ] I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
|
||||
- [ ] I've updated the documentation accordingly.
|
||||
14
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
groups:
|
||||
python-packages:
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: monthly
|
||||
29
.github/workflows/publish.yml
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: "Publish release"
|
||||
runs-on: "ubuntu-latest"
|
||||
|
||||
environment:
|
||||
name: deploy
|
||||
|
||||
steps:
|
||||
- uses: "actions/checkout@v4"
|
||||
- uses: "actions/setup-python@v6"
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: "Install dependencies"
|
||||
run: "scripts/install"
|
||||
- name: "Build package & docs"
|
||||
run: "scripts/build"
|
||||
- name: "Publish to PyPI & deploy docs"
|
||||
run: "scripts/publish"
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
||||
34
.github/workflows/test-suite.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
---
|
||||
name: Test Suite
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"]
|
||||
pull_request:
|
||||
branches: ["master", "version-*"]
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: "Python ${{ matrix.python-version }}"
|
||||
runs-on: "ubuntu-latest"
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
- uses: "actions/checkout@v4"
|
||||
- uses: "actions/setup-python@v6"
|
||||
with:
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
allow-prereleases: true
|
||||
- name: "Install dependencies"
|
||||
run: "scripts/install"
|
||||
- name: "Run linting checks"
|
||||
run: "scripts/check"
|
||||
- name: "Build package & docs"
|
||||
run: "scripts/build"
|
||||
- name: "Run tests"
|
||||
run: "scripts/test"
|
||||
- name: "Enforce coverage"
|
||||
run: "scripts/coverage"
|
||||
3
.gitignore
vendored
@ -7,5 +7,6 @@ htmlcov/
|
||||
site/
|
||||
*.egg-info/
|
||||
venv*/
|
||||
.nox
|
||||
.python-version
|
||||
build/
|
||||
dist/
|
||||
|
||||
35
.travis.yml
@ -1,35 +0,0 @@
|
||||
dist: xenial
|
||||
language: python
|
||||
|
||||
cache: pip
|
||||
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
|
||||
matrix:
|
||||
include:
|
||||
- python: 3.7
|
||||
env: NOX_SESSION=check
|
||||
- python: 3.7
|
||||
env: NOX_SESSION=docs
|
||||
|
||||
- python: 3.6
|
||||
env: NOX_SESSION=test-3.6
|
||||
- python: 3.7
|
||||
env: NOX_SESSION=test-3.7
|
||||
- python: 3.8-dev
|
||||
env: NOX_SESSION=test-3.8
|
||||
dist: bionic # Required to get OpenSSL 1.1.1+
|
||||
|
||||
install:
|
||||
- pip install --upgrade nox
|
||||
|
||||
script:
|
||||
- nox -s ${NOX_SESSION}
|
||||
|
||||
after_script:
|
||||
- if [ -f .coverage ]; then
|
||||
python -m pip install codecov;
|
||||
codecov --required;
|
||||
fi
|
||||
1024
CHANGELOG.md
25
LICENSE.md
@ -1,27 +1,12 @@
|
||||
Copyright © 2019, [Encode OSS Ltd](https://www.encode.io/).
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
* Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
include README.md
|
||||
include CHANGELOG.md
|
||||
include LICENSE.md
|
||||
102
README.md
@ -1,51 +1,70 @@
|
||||
<p align="center">
|
||||
<a href="https://www.encode.io/httpx/"><img width="350" height="208" src="https://raw.githubusercontent.com/encode/httpx/master/docs/img/logo.jpg" alt='HTTPX'></a>
|
||||
<a href="https://www.python-httpx.org/"><img width="350" height="208" src="https://raw.githubusercontent.com/encode/httpx/master/docs/img/butterfly.png" alt='HTTPX'></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><strong>HTTPX</strong> <em>- A next-generation HTTP client for Python.</em></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://travis-ci.org/encode/httpx">
|
||||
<img src="https://travis-ci.org/encode/httpx.svg?branch=master" alt="Build Status">
|
||||
</a>
|
||||
<a href="https://codecov.io/gh/encode/httpx">
|
||||
<img src="https://codecov.io/gh/encode/httpx/branch/master/graph/badge.svg" alt="Coverage">
|
||||
<a href="https://github.com/encode/httpx/actions">
|
||||
<img src="https://github.com/encode/httpx/workflows/Test%20Suite/badge.svg" alt="Test Suite">
|
||||
</a>
|
||||
<a href="https://pypi.org/project/httpx/">
|
||||
<img src="https://badge.fury.io/py/httpx.svg" alt="Package version">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**Note**: *This project should be considered as an "alpha" release. It is substantially API complete, but there are still some areas that need more work.*
|
||||
HTTPX is a fully featured HTTP client library for Python 3. It includes **an integrated command line client**, has support for both **HTTP/1.1 and HTTP/2**, and provides both **sync and async APIs**.
|
||||
|
||||
---
|
||||
|
||||
Let's get started...
|
||||
Install HTTPX using pip:
|
||||
|
||||
```python
|
||||
```shell
|
||||
$ pip install httpx
|
||||
```
|
||||
|
||||
Now, let's get started:
|
||||
|
||||
```pycon
|
||||
>>> import httpx
|
||||
>>> r = httpx.get('https://www.example.org/')
|
||||
>>> r
|
||||
<Response [200 OK]>
|
||||
>>> r.status_code
|
||||
200
|
||||
>>> r.http_version
|
||||
'HTTP/1.1'
|
||||
>>> r.headers['content-type']
|
||||
'text/html; charset=UTF-8'
|
||||
>>> r.text
|
||||
'<!doctype html>\n<html>\n<head>\n<title>Example Domain</title>...'
|
||||
```
|
||||
|
||||
Or, using the command-line client.
|
||||
|
||||
```shell
|
||||
$ pip install 'httpx[cli]' # The command line client is an optional dependency.
|
||||
```
|
||||
|
||||
Which now allows us to use HTTPX directly from the command-line...
|
||||
|
||||
<p align="center">
|
||||
<img width="700" src="docs/img/httpx-help.png" alt='httpx --help'>
|
||||
</p>
|
||||
|
||||
Sending a request...
|
||||
|
||||
<p align="center">
|
||||
<img width="700" src="docs/img/httpx-request.png" alt='httpx http://httpbin.org/json'>
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
HTTPX builds on the well-established usability of `requests`, and gives you:
|
||||
|
||||
* A requests-compatible API.
|
||||
* HTTP/2 and HTTP/1.1 support.
|
||||
* Support for [issuing HTTP requests in parallel](https://www.encode.io/httpx/parallel/). *(Coming soon)*
|
||||
* Standard synchronous interface, but [with `async`/`await` support if you need it](https://www.encode.io/httpx/async/).
|
||||
* Ability to [make requests directly to WSGI or ASGI applications](https://www.encode.io/httpx/advanced/#calling-into-python-web-apps).
|
||||
* A broadly [requests-compatible API](https://www.python-httpx.org/compatibility/).
|
||||
* An integrated command-line client.
|
||||
* HTTP/1.1 [and HTTP/2 support](https://www.python-httpx.org/http2/).
|
||||
* Standard synchronous interface, but with [async support if you need it](https://www.python-httpx.org/async/).
|
||||
* Ability to make requests directly to [WSGI applications](https://www.python-httpx.org/advanced/transports/#wsgi-transport) or [ASGI applications](https://www.python-httpx.org/advanced/transports/#asgi-transport).
|
||||
* Strict timeouts everywhere.
|
||||
* Fully type annotated.
|
||||
* 100% test coverage.
|
||||
@ -62,7 +81,7 @@ Plus all the standard features of `requests`...
|
||||
* Automatic Content Decoding
|
||||
* Unicode Response Bodies
|
||||
* Multipart File Uploads
|
||||
* HTTP(S) Proxy Support *(TODO)*
|
||||
* HTTP(S) Proxy Support
|
||||
* Connection Timeouts
|
||||
* Streaming Downloads
|
||||
* .netrc Support
|
||||
@ -76,40 +95,53 @@ Install with pip:
|
||||
$ pip install httpx
|
||||
```
|
||||
|
||||
httpx requires Python 3.6+
|
||||
Or, to include the optional HTTP/2 support, use:
|
||||
|
||||
```shell
|
||||
$ pip install httpx[http2]
|
||||
```
|
||||
|
||||
HTTPX requires Python 3.9+.
|
||||
|
||||
## Documentation
|
||||
|
||||
Project documentation is available at [www.encode.io/httpx/](https://www.encode.io/httpx/).
|
||||
Project documentation is available at [https://www.python-httpx.org/](https://www.python-httpx.org/).
|
||||
|
||||
For a run-through of all the basics, head over to the [QuickStart](https://www.encode.io/httpx/quickstart/).
|
||||
For a run-through of all the basics, head over to the [QuickStart](https://www.python-httpx.org/quickstart/).
|
||||
|
||||
For more advanced topics, see the [Advanced Usage](https://www.encode.io/httpx/advanced/) section, or
|
||||
the specific topics on making [Parallel Requests](https://www.encode.io/httpx/parallel/) or using the
|
||||
[Async Client](https://www.encode.io/httpx/async/).
|
||||
For more advanced topics, see the [Advanced Usage](https://www.python-httpx.org/advanced/) section, the [async support](https://www.python-httpx.org/async/) section, or the [HTTP/2](https://www.python-httpx.org/http2/) section.
|
||||
|
||||
The [Developer Interface](https://www.encode.io/httpx/api/) provides a comprehensive API reference.
|
||||
The [Developer Interface](https://www.python-httpx.org/api/) provides a comprehensive API reference.
|
||||
|
||||
To find out about tools that integrate with HTTPX, see [Third Party Packages](https://www.python-httpx.org/third_party_packages/).
|
||||
|
||||
## Contribute
|
||||
|
||||
If you want to contribute with HTTPX check out the [Contributing Guide](https://www.encode.io/httpx/contributing/) to learn how to start.
|
||||
If you want to contribute with HTTPX check out the [Contributing Guide](https://www.python-httpx.org/contributing/) to learn how to start.
|
||||
|
||||
## Dependencies
|
||||
|
||||
The httpx project relies on these excellent libraries:
|
||||
The HTTPX project relies on these excellent libraries:
|
||||
|
||||
* `h2` - HTTP/2 support.
|
||||
* `h11` - HTTP/1.1 support.
|
||||
* `httpcore` - The underlying transport implementation for `httpx`.
|
||||
* `h11` - HTTP/1.1 support.
|
||||
* `certifi` - SSL certificates.
|
||||
* `chardet` - Fallback auto-detection for response encoding.
|
||||
* `hstspreload` - determines whether IDNA-encoded host should be only accessed via HTTPS.
|
||||
* `idna` - Internationalized domain name support.
|
||||
* `rfc3986` - URL parsing & normalization.
|
||||
* `brotlipy` - Decoding for "brotli" compressed responses. *(Optional)*
|
||||
* `sniffio` - Async library autodetection.
|
||||
|
||||
As well as these optional installs:
|
||||
|
||||
* `h2` - HTTP/2 support. *(Optional, with `httpx[http2]`)*
|
||||
* `socksio` - SOCKS proxy support. *(Optional, with `httpx[socks]`)*
|
||||
* `rich` - Rich terminal support. *(Optional, with `httpx[cli]`)*
|
||||
* `click` - Command line client support. *(Optional, with `httpx[cli]`)*
|
||||
* `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx[brotli]`)*
|
||||
* `zstandard` - Decoding for "zstd" compressed responses. *(Optional, with `httpx[zstd]`)*
|
||||
|
||||
A huge amount of credit is due to `requests` for the API layout that
|
||||
much of this work follows, as well as to `urllib3` for plenty of design
|
||||
inspiration around the lower level networking details.
|
||||
inspiration around the lower-level networking details.
|
||||
|
||||
<p align="center">— ⭐️ —</p>
|
||||
<p align="center"><i>HTTPX is <a href="https://github.com/encode/httpx/blob/master/LICENSE.md">BSD licensed</a> code. Designed & built in Brighton, England.</i></p>
|
||||
---
|
||||
|
||||
<p align="center"><i>HTTPX is <a href="https://github.com/encode/httpx/blob/master/LICENSE.md">BSD licensed</a> code.<br/>Designed & crafted with care.</i><br/>— 🦋 —</p>
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
coverage:
|
||||
status:
|
||||
patch:
|
||||
default:
|
||||
target: '100'
|
||||
project:
|
||||
default:
|
||||
target: '100'
|
||||
1
docs/CNAME
Normal file
@ -0,0 +1 @@
|
||||
www.python-httpx.org
|
||||
162
docs/advanced.md
@ -1,162 +0,0 @@
|
||||
# Advanced Usage
|
||||
|
||||
## Client Instances
|
||||
|
||||
Using a Client instance to make requests will give you HTTP connection pooling,
|
||||
will provide cookie persistence, and allows you to apply configuration across
|
||||
all outgoing requests.
|
||||
|
||||
```python
|
||||
>>> client = httpx.Client()
|
||||
>>> r = client.get('https://example.org/')
|
||||
>>> r
|
||||
<Response [200 OK]>
|
||||
```
|
||||
|
||||
## Calling into Python Web Apps
|
||||
|
||||
You can configure an `httpx` client to call directly into a Python web
|
||||
application, using either the WSGI or ASGI protocol.
|
||||
|
||||
This is particularly useful for two main use-cases:
|
||||
|
||||
* Using `httpx` as a client, inside test cases.
|
||||
* Mocking out external services, during tests or in dev/staging environments.
|
||||
|
||||
Here's an example of integrating against a Flask application:
|
||||
|
||||
```python
|
||||
from flask import Flask
|
||||
import httpx
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/")
|
||||
def hello():
|
||||
return "Hello World!"
|
||||
|
||||
client = httpx.Client(app=app)
|
||||
r = client.get('http://example/')
|
||||
assert r.status_code == 200
|
||||
assert r.text == "Hello World!"
|
||||
```
|
||||
|
||||
For some more complex cases you might need to customize the WSGI or ASGI
|
||||
dispatch. This allows you to:
|
||||
|
||||
* Inspect 500 error responses, rather than raise exceptions, by setting `raise_app_exceptions=False`.
|
||||
* Mount the WSGI or ASGI application at a subpath, by setting `script_name` (WSGI) or `root_path` (ASGI).
|
||||
* Use a given the client address for requests, by setting `remote_addr` (WSGI) or `client` (ASGI).
|
||||
|
||||
For example:
|
||||
|
||||
```python
|
||||
# Instantiate a client that makes WSGI requests with a client IP of "1.2.3.4".
|
||||
dispatch = httpx.WSGIDispatch(app=app, remote_addr="1.2.3.4")
|
||||
client = httpx.Client(dispatch=dispatch)
|
||||
```
|
||||
|
||||
## Build Request
|
||||
|
||||
You can use `Client.build_request()` to build a request and
|
||||
make modifications before sending the request.
|
||||
|
||||
```python
|
||||
>>> client = httpx.Client()
|
||||
>>> req = client.build_request("OPTIONS", "https://example.com")
|
||||
>>> req.url.full_path = "*" # Build an 'OPTIONS *' request for CORS
|
||||
>>> client.send(r)
|
||||
<Response [200 OK]>
|
||||
```
|
||||
|
||||
## Specify the version of HTTP protocol
|
||||
|
||||
One can set the version of HTTP protocol for the client in case you want to make the requests using specific version.
|
||||
|
||||
For example:
|
||||
|
||||
```python
|
||||
h11_client = httpx.Client(http_versions=["HTTP/1.1"])
|
||||
h11_response = h11_client.get("https://myserver.com")
|
||||
|
||||
h2_client = httpx.Client(http_versions=["HTTP/2"])
|
||||
h2_response = h2_client.get("https://myserver.com")
|
||||
```
|
||||
|
||||
## .netrc Support
|
||||
|
||||
HTTPX supports .netrc file. In `trust_env=True` cases, if auth parameter is
|
||||
not defined, HTTPX tries to add auth into request's header from .netrc file.
|
||||
|
||||
As default `trust_env` is true. To set false:
|
||||
```python
|
||||
>>> httpx.get('https://example.org/', trust_env=False)
|
||||
```
|
||||
|
||||
If `NETRC` environment is empty, HTTPX tries to use default files.
|
||||
(`~/.netrc`, `~/_netrc`)
|
||||
|
||||
To change `NETRC` environment:
|
||||
```python
|
||||
>>> import os
|
||||
>>> os.environ["NETRC"] = "my_default_folder/.my_netrc"
|
||||
```
|
||||
|
||||
.netrc file content example:
|
||||
```
|
||||
machine netrcexample.org
|
||||
login example-username
|
||||
password example-password
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
## HTTP Proxying
|
||||
|
||||
HTTPX supports setting up proxies the same way that Requests does via the `proxies` parameter.
|
||||
For example to forward all HTTP traffic to `http://127.0.0.1:3080` and all HTTPS traffic
|
||||
to `http://127.0.0.1:3081` your `proxies` config would look like this:
|
||||
|
||||
```python
|
||||
>>> client = httpx.Client(proxies={
|
||||
"http": "http://127.0.0.1:3080",
|
||||
"https": "http://127.0.0.1:3081"
|
||||
})
|
||||
```
|
||||
|
||||
Proxies can be configured for a specific scheme and host, all schemes of a host,
|
||||
all hosts for a scheme, or for all requests. When determining which proxy configuration
|
||||
to use for a given request this same order is used.
|
||||
|
||||
```python
|
||||
>>> client = httpx.Client(proxies={
|
||||
"http://example.com": "...", # Host+Scheme
|
||||
"all://example.com": "...", # Host
|
||||
"http": "...", # Scheme
|
||||
"all": "...", # All
|
||||
})
|
||||
>>> client = httpx.Client(proxies="...") # Shortcut for 'all'
|
||||
```
|
||||
|
||||
!!! warning
|
||||
To make sure that proxies cannot read your traffic,
|
||||
and even if the proxy_url uses HTTPS, it is recommended to
|
||||
use HTTPS and tunnel requests if possible.
|
||||
|
||||
By default `HTTPProxy` will operate as a forwarding proxy for `http://...` requests
|
||||
and will establish a `CONNECT` TCP tunnel for `https://` requests. This doesn't change
|
||||
regardless of the `proxy_url` being `http` or `https`.
|
||||
|
||||
Proxies can be configured to have different behavior such as forwarding or tunneling all requests:
|
||||
|
||||
```python
|
||||
proxy = httpx.HTTPProxy(
|
||||
proxy_url="https://127.0.0.1",
|
||||
proxy_mode=httpx.HTTPProxyMode.TUNNEL_ONLY
|
||||
)
|
||||
client = httpx.Client(proxies=proxy)
|
||||
|
||||
# This request will be tunnelled instead of forwarded.
|
||||
client.get("http://example.com")
|
||||
```
|
||||
232
docs/advanced/authentication.md
Normal file
@ -0,0 +1,232 @@
|
||||
Authentication can either be included on a per-request basis...
|
||||
|
||||
```pycon
|
||||
>>> auth = httpx.BasicAuth(username="username", password="secret")
|
||||
>>> client = httpx.Client()
|
||||
>>> response = client.get("https://www.example.com/", auth=auth)
|
||||
```
|
||||
|
||||
Or configured on the client instance, ensuring that all outgoing requests will include authentication credentials...
|
||||
|
||||
```pycon
|
||||
>>> auth = httpx.BasicAuth(username="username", password="secret")
|
||||
>>> client = httpx.Client(auth=auth)
|
||||
>>> response = client.get("https://www.example.com/")
|
||||
```
|
||||
|
||||
## Basic authentication
|
||||
|
||||
HTTP basic authentication is an unencrypted authentication scheme that uses a simple encoding of the username and password in the request `Authorization` header. Since it is unencrypted it should typically only be used over `https`, although this is not strictly enforced.
|
||||
|
||||
```pycon
|
||||
>>> auth = httpx.BasicAuth(username="finley", password="secret")
|
||||
>>> client = httpx.Client(auth=auth)
|
||||
>>> response = client.get("https://httpbin.org/basic-auth/finley/secret")
|
||||
>>> response
|
||||
<Response [200 OK]>
|
||||
```
|
||||
|
||||
## Digest authentication
|
||||
|
||||
HTTP digest authentication is a challenge-response authentication scheme. Unlike basic authentication it provides encryption, and can be used over unencrypted `http` connections. It requires an additional round-trip in order to negotiate the authentication.
|
||||
|
||||
```pycon
|
||||
>>> auth = httpx.DigestAuth(username="olivia", password="secret")
|
||||
>>> client = httpx.Client(auth=auth)
|
||||
>>> response = client.get("https://httpbin.org/digest-auth/auth/olivia/secret")
|
||||
>>> response
|
||||
<Response [200 OK]>
|
||||
>>> response.history
|
||||
[<Response [401 UNAUTHORIZED]>]
|
||||
```
|
||||
|
||||
## NetRC authentication
|
||||
|
||||
HTTPX can be configured to use [a `.netrc` config file](https://everything.curl.dev/usingcurl/netrc) for authentication.
|
||||
|
||||
The `.netrc` config file allows authentication credentials to be associated with specified hosts. When a request is made to a host that is found in the netrc file, the username and password will be included using HTTP basic authentication.
|
||||
|
||||
Example `.netrc` file:
|
||||
|
||||
```
|
||||
machine example.org
|
||||
login example-username
|
||||
password example-password
|
||||
|
||||
machine python-httpx.org
|
||||
login other-username
|
||||
password other-password
|
||||
```
|
||||
|
||||
Some examples of configuring `.netrc` authentication with `httpx`.
|
||||
|
||||
Use the default `.netrc` file in the users home directory:
|
||||
|
||||
```pycon
|
||||
>>> auth = httpx.NetRCAuth()
|
||||
>>> client = httpx.Client(auth=auth)
|
||||
```
|
||||
|
||||
Use an explicit path to a `.netrc` file:
|
||||
|
||||
```pycon
|
||||
>>> auth = httpx.NetRCAuth(file="/path/to/.netrc")
|
||||
>>> client = httpx.Client(auth=auth)
|
||||
```
|
||||
|
||||
Use the `NETRC` environment variable to configure a path to the `.netrc` file,
|
||||
or fallback to the default.
|
||||
|
||||
```pycon
|
||||
>>> auth = httpx.NetRCAuth(file=os.environ.get("NETRC"))
|
||||
>>> client = httpx.Client(auth=auth)
|
||||
```
|
||||
|
||||
The `NetRCAuth()` class uses [the `netrc.netrc()` function from the Python standard library](https://docs.python.org/3/library/netrc.html). See the documentation there for more details on exceptions that may be raised if the `.netrc` file is not found, or cannot be parsed.
|
||||
|
||||
## Custom authentication schemes
|
||||
|
||||
When issuing requests or instantiating a client, the `auth` argument can be used to pass an authentication scheme to use. The `auth` argument may be one of the following...
|
||||
|
||||
* A two-tuple of `username`/`password`, to be used with basic authentication.
|
||||
* An instance of `httpx.BasicAuth()`, `httpx.DigestAuth()`, or `httpx.NetRCAuth()`.
|
||||
* A callable, accepting a request and returning an authenticated request instance.
|
||||
* An instance of subclasses of `httpx.Auth`.
|
||||
|
||||
The most involved of these is the last, which allows you to create authentication flows involving one or more requests. A subclass of `httpx.Auth` should implement `def auth_flow(request)`, and yield any requests that need to be made...
|
||||
|
||||
```python
|
||||
class MyCustomAuth(httpx.Auth):
|
||||
def __init__(self, token):
|
||||
self.token = token
|
||||
|
||||
def auth_flow(self, request):
|
||||
# Send the request, with a custom `X-Authentication` header.
|
||||
request.headers['X-Authentication'] = self.token
|
||||
yield request
|
||||
```
|
||||
|
||||
If the auth flow requires more than one request, you can issue multiple yields, and obtain the response in each case...
|
||||
|
||||
```python
|
||||
class MyCustomAuth(httpx.Auth):
|
||||
def __init__(self, token):
|
||||
self.token = token
|
||||
|
||||
def auth_flow(self, request):
|
||||
response = yield request
|
||||
if response.status_code == 401:
|
||||
# If the server issues a 401 response then resend the request,
|
||||
# with a custom `X-Authentication` header.
|
||||
request.headers['X-Authentication'] = self.token
|
||||
yield request
|
||||
```
|
||||
|
||||
Custom authentication classes are designed to not perform any I/O, so that they may be used with both sync and async client instances. If you are implementing an authentication scheme that requires the request body, then you need to indicate this on the class using a `requires_request_body` property.
|
||||
|
||||
You will then be able to access `request.content` inside the `.auth_flow()` method.
|
||||
|
||||
```python
|
||||
class MyCustomAuth(httpx.Auth):
|
||||
requires_request_body = True
|
||||
|
||||
def __init__(self, token):
|
||||
self.token = token
|
||||
|
||||
def auth_flow(self, request):
|
||||
response = yield request
|
||||
if response.status_code == 401:
|
||||
# If the server issues a 401 response then resend the request,
|
||||
# with a custom `X-Authentication` header.
|
||||
request.headers['X-Authentication'] = self.sign_request(...)
|
||||
yield request
|
||||
|
||||
def sign_request(self, request):
|
||||
# Create a request signature, based on `request.method`, `request.url`,
|
||||
# `request.headers`, and `request.content`.
|
||||
...
|
||||
```
|
||||
|
||||
Similarly, if you are implementing a scheme that requires access to the response body, then use the `requires_response_body` property. You will then be able to access response body properties and methods such as `response.content`, `response.text`, `response.json()`, etc.
|
||||
|
||||
```python
|
||||
class MyCustomAuth(httpx.Auth):
|
||||
requires_response_body = True
|
||||
|
||||
def __init__(self, access_token, refresh_token, refresh_url):
|
||||
self.access_token = access_token
|
||||
self.refresh_token = refresh_token
|
||||
self.refresh_url = refresh_url
|
||||
|
||||
def auth_flow(self, request):
|
||||
request.headers["X-Authentication"] = self.access_token
|
||||
response = yield request
|
||||
|
||||
if response.status_code == 401:
|
||||
# If the server issues a 401 response, then issue a request to
|
||||
# refresh tokens, and resend the request.
|
||||
refresh_response = yield self.build_refresh_request()
|
||||
self.update_tokens(refresh_response)
|
||||
|
||||
request.headers["X-Authentication"] = self.access_token
|
||||
yield request
|
||||
|
||||
def build_refresh_request(self):
|
||||
# Return an `httpx.Request` for refreshing tokens.
|
||||
...
|
||||
|
||||
def update_tokens(self, response):
|
||||
# Update the `.access_token` and `.refresh_token` tokens
|
||||
# based on a refresh response.
|
||||
data = response.json()
|
||||
...
|
||||
```
|
||||
|
||||
If you _do_ need to perform I/O other than HTTP requests, such as accessing a disk-based cache, or you need to use concurrency primitives, such as locks, then you should override `.sync_auth_flow()` and `.async_auth_flow()` (instead of `.auth_flow()`). The former will be used by `httpx.Client`, while the latter will be used by `httpx.AsyncClient`.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import threading
|
||||
import httpx
|
||||
|
||||
|
||||
class MyCustomAuth(httpx.Auth):
|
||||
def __init__(self):
|
||||
self._sync_lock = threading.RLock()
|
||||
self._async_lock = asyncio.Lock()
|
||||
|
||||
def sync_get_token(self):
|
||||
with self._sync_lock:
|
||||
...
|
||||
|
||||
def sync_auth_flow(self, request):
|
||||
token = self.sync_get_token()
|
||||
request.headers["Authorization"] = f"Token {token}"
|
||||
yield request
|
||||
|
||||
async def async_get_token(self):
|
||||
async with self._async_lock:
|
||||
...
|
||||
|
||||
async def async_auth_flow(self, request):
|
||||
token = await self.async_get_token()
|
||||
request.headers["Authorization"] = f"Token {token}"
|
||||
yield request
|
||||
```
|
||||
|
||||
If you only want to support one of the two methods, then you should still override it, but raise an explicit `RuntimeError`.
|
||||
|
||||
```python
|
||||
import httpx
|
||||
import sync_only_library
|
||||
|
||||
|
||||
class MyCustomAuth(httpx.Auth):
|
||||
def sync_auth_flow(self, request):
|
||||
token = sync_only_library.get_token(...)
|
||||
request.headers["Authorization"] = f"Token {token}"
|
||||
yield request
|
||||
|
||||
async def async_auth_flow(self, request):
|
||||
raise RuntimeError("Cannot use a sync authentication class with httpx.AsyncClient")
|
||||
```
|
||||
328
docs/advanced/clients.md
Normal file
@ -0,0 +1,328 @@
|
||||
!!! hint
|
||||
If you are coming from Requests, `httpx.Client()` is what you can use instead of `requests.Session()`.
|
||||
|
||||
## Why use a Client?
|
||||
|
||||
!!! note "TL;DR"
|
||||
If you do anything more than experimentation, one-off scripts, or prototypes, then you should use a `Client` instance.
|
||||
|
||||
**More efficient usage of network resources**
|
||||
|
||||
When you make requests using the top-level API as documented in the [Quickstart](../quickstart.md) guide, HTTPX has to establish a new connection _for every single request_ (connections are not reused). As the number of requests to a host increases, this quickly becomes inefficient.
|
||||
|
||||
On the other hand, a `Client` instance uses [HTTP connection pooling](https://en.wikipedia.org/wiki/HTTP_persistent_connection). This means that when you make several requests to the same host, the `Client` will reuse the underlying TCP connection, instead of recreating one for every single request.
|
||||
|
||||
This can bring **significant performance improvements** compared to using the top-level API, including:
|
||||
|
||||
- Reduced latency across requests (no handshaking).
|
||||
- Reduced CPU usage and round-trips.
|
||||
- Reduced network congestion.
|
||||
|
||||
**Extra features**
|
||||
|
||||
`Client` instances also support features that aren't available at the top-level API, such as:
|
||||
|
||||
- Cookie persistence across requests.
|
||||
- Applying configuration across all outgoing requests.
|
||||
- Sending requests through HTTP proxies.
|
||||
- Using [HTTP/2](../http2.md).
|
||||
|
||||
The other sections on this page go into further detail about what you can do with a `Client` instance.
|
||||
|
||||
## Usage
|
||||
|
||||
The recommended way to use a `Client` is as a context manager. This will ensure that connections are properly cleaned up when leaving the `with` block:
|
||||
|
||||
```python
|
||||
with httpx.Client() as client:
|
||||
...
|
||||
```
|
||||
|
||||
Alternatively, you can explicitly close the connection pool without block-usage using `.close()`:
|
||||
|
||||
```python
|
||||
client = httpx.Client()
|
||||
try:
|
||||
...
|
||||
finally:
|
||||
client.close()
|
||||
```
|
||||
|
||||
## Making requests
|
||||
|
||||
Once you have a `Client`, you can send requests using `.get()`, `.post()`, etc. For example:
|
||||
|
||||
```pycon
|
||||
>>> with httpx.Client() as client:
|
||||
... r = client.get('https://example.com')
|
||||
...
|
||||
>>> r
|
||||
<Response [200 OK]>
|
||||
```
|
||||
|
||||
These methods accept the same arguments as `httpx.get()`, `httpx.post()`, etc. This means that all features documented in the [Quickstart](../quickstart.md) guide are also available at the client level.
|
||||
|
||||
For example, to send a request with custom headers:
|
||||
|
||||
```pycon
|
||||
>>> with httpx.Client() as client:
|
||||
... headers = {'X-Custom': 'value'}
|
||||
... r = client.get('https://example.com', headers=headers)
|
||||
...
|
||||
>>> r.request.headers['X-Custom']
|
||||
'value'
|
||||
```
|
||||
|
||||
## Sharing configuration across requests
|
||||
|
||||
Clients allow you to apply configuration to all outgoing requests by passing parameters to the `Client` constructor.
|
||||
|
||||
For example, to apply a set of custom headers _on every request_:
|
||||
|
||||
```pycon
|
||||
>>> url = 'http://httpbin.org/headers'
|
||||
>>> headers = {'user-agent': 'my-app/0.0.1'}
|
||||
>>> with httpx.Client(headers=headers) as client:
|
||||
... r = client.get(url)
|
||||
...
|
||||
>>> r.json()['headers']['User-Agent']
|
||||
'my-app/0.0.1'
|
||||
```
|
||||
|
||||
## Merging of configuration
|
||||
|
||||
When a configuration option is provided at both the client-level and request-level, one of two things can happen:
|
||||
|
||||
- For headers, query parameters and cookies, the values are combined together. For example:
|
||||
|
||||
```pycon
|
||||
>>> headers = {'X-Auth': 'from-client'}
|
||||
>>> params = {'client_id': 'client1'}
|
||||
>>> with httpx.Client(headers=headers, params=params) as client:
|
||||
... headers = {'X-Custom': 'from-request'}
|
||||
... params = {'request_id': 'request1'}
|
||||
... r = client.get('https://example.com', headers=headers, params=params)
|
||||
...
|
||||
>>> r.request.url
|
||||
URL('https://example.com?client_id=client1&request_id=request1')
|
||||
>>> r.request.headers['X-Auth']
|
||||
'from-client'
|
||||
>>> r.request.headers['X-Custom']
|
||||
'from-request'
|
||||
```
|
||||
|
||||
- For all other parameters, the request-level value takes priority. For example:
|
||||
|
||||
```pycon
|
||||
>>> with httpx.Client(auth=('tom', 'mot123')) as client:
|
||||
... r = client.get('https://example.com', auth=('alice', 'ecila123'))
|
||||
...
|
||||
>>> _, _, auth = r.request.headers['Authorization'].partition(' ')
|
||||
>>> import base64
|
||||
>>> base64.b64decode(auth)
|
||||
b'alice:ecila123'
|
||||
```
|
||||
|
||||
If you need finer-grained control on the merging of client-level and request-level parameters, see [Request instances](#request-instances).
|
||||
|
||||
## Other Client-only configuration options
|
||||
|
||||
Additionally, `Client` accepts some configuration options that aren't available at the request level.
|
||||
|
||||
For example, `base_url` allows you to prepend an URL to all outgoing requests:
|
||||
|
||||
```pycon
|
||||
>>> with httpx.Client(base_url='http://httpbin.org') as client:
|
||||
... r = client.get('/headers')
|
||||
...
|
||||
>>> r.request.url
|
||||
URL('http://httpbin.org/headers')
|
||||
```
|
||||
|
||||
For a list of all available client parameters, see the [`Client`](../api.md#client) API reference.
|
||||
|
||||
---
|
||||
|
||||
## Request instances
|
||||
|
||||
For maximum control on what gets sent over the wire, HTTPX supports building explicit [`Request`](../api.md#request) instances:
|
||||
|
||||
```python
|
||||
request = httpx.Request("GET", "https://example.com")
|
||||
```
|
||||
|
||||
To dispatch a `Request` instance across to the network, create a [`Client` instance](#client-instances) and use `.send()`:
|
||||
|
||||
```python
|
||||
with httpx.Client() as client:
|
||||
response = client.send(request)
|
||||
...
|
||||
```
|
||||
|
||||
If you need to mix client-level and request-level options in a way that is not supported by the default [Merging of parameters](#merging-of-parameters), you can use `.build_request()` and then make arbitrary modifications to the `Request` instance. For example:
|
||||
|
||||
```python
|
||||
headers = {"X-Api-Key": "...", "X-Client-ID": "ABC123"}
|
||||
|
||||
with httpx.Client(headers=headers) as client:
|
||||
request = client.build_request("GET", "https://api.example.com")
|
||||
|
||||
print(request.headers["X-Client-ID"]) # "ABC123"
|
||||
|
||||
# Don't send the API key for this particular request.
|
||||
del request.headers["X-Api-Key"]
|
||||
|
||||
response = client.send(request)
|
||||
...
|
||||
```
|
||||
|
||||
## Monitoring download progress
|
||||
|
||||
If you need to monitor download progress of large responses, you can use response streaming and inspect the `response.num_bytes_downloaded` property.
|
||||
|
||||
This interface is required for properly determining download progress, because the total number of bytes returned by `response.content` or `response.iter_content()` will not always correspond with the raw content length of the response if HTTP response compression is being used.
|
||||
|
||||
For example, showing a progress bar using the [`tqdm`](https://github.com/tqdm/tqdm) library while a response is being downloaded could be done like this…
|
||||
|
||||
```python
|
||||
import tempfile
|
||||
|
||||
import httpx
|
||||
from tqdm import tqdm
|
||||
|
||||
with tempfile.NamedTemporaryFile() as download_file:
|
||||
url = "https://speed.hetzner.de/100MB.bin"
|
||||
with httpx.stream("GET", url) as response:
|
||||
total = int(response.headers["Content-Length"])
|
||||
|
||||
with tqdm(total=total, unit_scale=True, unit_divisor=1024, unit="B") as progress:
|
||||
num_bytes_downloaded = response.num_bytes_downloaded
|
||||
for chunk in response.iter_bytes():
|
||||
download_file.write(chunk)
|
||||
progress.update(response.num_bytes_downloaded - num_bytes_downloaded)
|
||||
num_bytes_downloaded = response.num_bytes_downloaded
|
||||
```
|
||||
|
||||

|
||||
|
||||
Or an alternate example, this time using the [`rich`](https://github.com/willmcgugan/rich) library…
|
||||
|
||||
```python
|
||||
import tempfile
|
||||
import httpx
|
||||
import rich.progress
|
||||
|
||||
with tempfile.NamedTemporaryFile() as download_file:
|
||||
url = "https://speed.hetzner.de/100MB.bin"
|
||||
with httpx.stream("GET", url) as response:
|
||||
total = int(response.headers["Content-Length"])
|
||||
|
||||
with rich.progress.Progress(
|
||||
"[progress.percentage]{task.percentage:>3.0f}%",
|
||||
rich.progress.BarColumn(bar_width=None),
|
||||
rich.progress.DownloadColumn(),
|
||||
rich.progress.TransferSpeedColumn(),
|
||||
) as progress:
|
||||
download_task = progress.add_task("Download", total=total)
|
||||
for chunk in response.iter_bytes():
|
||||
download_file.write(chunk)
|
||||
progress.update(download_task, completed=response.num_bytes_downloaded)
|
||||
```
|
||||
|
||||

|
||||
|
||||
## Monitoring upload progress
|
||||
|
||||
If you need to monitor upload progress of large responses, you can use request content generator streaming.
|
||||
|
||||
For example, showing a progress bar using the [`tqdm`](https://github.com/tqdm/tqdm) library.
|
||||
|
||||
```python
|
||||
import io
|
||||
import random
|
||||
|
||||
import httpx
|
||||
from tqdm import tqdm
|
||||
|
||||
|
||||
def gen():
|
||||
"""
|
||||
this is a complete example with generated random bytes.
|
||||
you can replace `io.BytesIO` with real file object.
|
||||
"""
|
||||
total = 32 * 1024 * 1024 # 32m
|
||||
with tqdm(ascii=True, unit_scale=True, unit='B', unit_divisor=1024, total=total) as bar:
|
||||
with io.BytesIO(random.randbytes(total)) as f:
|
||||
while data := f.read(1024):
|
||||
yield data
|
||||
bar.update(len(data))
|
||||
|
||||
|
||||
httpx.post("https://httpbin.org/post", content=gen())
|
||||
```
|
||||
|
||||

|
||||
|
||||
## Multipart file encoding
|
||||
|
||||
As mentioned in the [quickstart](../quickstart.md#sending-multipart-file-uploads)
|
||||
multipart file encoding is available by passing a dictionary with the
|
||||
name of the payloads as keys and either tuple of elements or a file-like object or a string as values.
|
||||
|
||||
```pycon
|
||||
>>> with open('report.xls', 'rb') as report_file:
|
||||
... files = {'upload-file': ('report.xls', report_file, 'application/vnd.ms-excel')}
|
||||
... r = httpx.post("https://httpbin.org/post", files=files)
|
||||
>>> print(r.text)
|
||||
{
|
||||
...
|
||||
"files": {
|
||||
"upload-file": "<... binary content ...>"
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
More specifically, if a tuple is used as a value, it must have between 2 and 3 elements:
|
||||
|
||||
- The first element is an optional file name which can be set to `None`.
|
||||
- The second element may be a file-like object or a string which will be automatically
|
||||
encoded in UTF-8.
|
||||
- An optional third element can be used to specify the
|
||||
[MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_Types)
|
||||
of the file being uploaded. If not specified HTTPX will attempt to guess the MIME type based
|
||||
on the file name, with unknown file extensions defaulting to "application/octet-stream".
|
||||
If the file name is explicitly set to `None` then HTTPX will not include a content-type
|
||||
MIME header field.
|
||||
|
||||
```pycon
|
||||
>>> files = {'upload-file': (None, 'text content', 'text/plain')}
|
||||
>>> r = httpx.post("https://httpbin.org/post", files=files)
|
||||
>>> print(r.text)
|
||||
{
|
||||
...
|
||||
"files": {},
|
||||
"form": {
|
||||
"upload-file": "text-content"
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
!!! tip
|
||||
It is safe to upload large files this way. File uploads are streaming by default, meaning that only one chunk will be loaded into memory at a time.
|
||||
|
||||
Non-file data fields can be included in the multipart form using by passing them to `data=...`.
|
||||
|
||||
You can also send multiple files in one go with a multiple file field form.
|
||||
To do that, pass a list of `(field, <file>)` items instead of a dictionary, allowing you to pass multiple items with the same `field`.
|
||||
For instance this request sends 2 files, `foo.png` and `bar.png` in one request on the `images` form field:
|
||||
|
||||
```pycon
|
||||
>>> with open('foo.png', 'rb') as foo_file, open('bar.png', 'rb') as bar_file:
|
||||
... files = [
|
||||
... ('images', ('foo.png', foo_file, 'image/png')),
|
||||
... ('images', ('bar.png', bar_file, 'image/png')),
|
||||
... ]
|
||||
... r = httpx.post("https://httpbin.org/post", files=files)
|
||||
```
|
||||
65
docs/advanced/event-hooks.md
Normal file
@ -0,0 +1,65 @@
|
||||
HTTPX allows you to register "event hooks" with the client, that are called
|
||||
every time a particular type of event takes place.
|
||||
|
||||
There are currently two event hooks:
|
||||
|
||||
* `request` - Called after a request is fully prepared, but before it is sent to the network. Passed the `request` instance.
|
||||
* `response` - Called after the response has been fetched from the network, but before it is returned to the caller. Passed the `response` instance.
|
||||
|
||||
These allow you to install client-wide functionality such as logging, monitoring or tracing.
|
||||
|
||||
```python
|
||||
def log_request(request):
|
||||
print(f"Request event hook: {request.method} {request.url} - Waiting for response")
|
||||
|
||||
def log_response(response):
|
||||
request = response.request
|
||||
print(f"Response event hook: {request.method} {request.url} - Status {response.status_code}")
|
||||
|
||||
client = httpx.Client(event_hooks={'request': [log_request], 'response': [log_response]})
|
||||
```
|
||||
|
||||
You can also use these hooks to install response processing code, such as this
|
||||
example, which creates a client instance that always raises `httpx.HTTPStatusError`
|
||||
on 4xx and 5xx responses.
|
||||
|
||||
```python
|
||||
def raise_on_4xx_5xx(response):
|
||||
response.raise_for_status()
|
||||
|
||||
client = httpx.Client(event_hooks={'response': [raise_on_4xx_5xx]})
|
||||
```
|
||||
|
||||
!!! note
|
||||
Response event hooks are called before determining if the response body
|
||||
should be read or not.
|
||||
|
||||
If you need access to the response body inside an event hook, you'll
|
||||
need to call `response.read()`, or for AsyncClients, `response.aread()`.
|
||||
|
||||
The hooks are also allowed to modify `request` and `response` objects.
|
||||
|
||||
```python
|
||||
def add_timestamp(request):
|
||||
request.headers['x-request-timestamp'] = datetime.now(tz=datetime.utc).isoformat()
|
||||
|
||||
client = httpx.Client(event_hooks={'request': [add_timestamp]})
|
||||
```
|
||||
|
||||
Event hooks must always be set as a **list of callables**, and you may register
|
||||
multiple event hooks for each type of event.
|
||||
|
||||
As well as being able to set event hooks on instantiating the client, there
|
||||
is also an `.event_hooks` property, that allows you to inspect and modify
|
||||
the installed hooks.
|
||||
|
||||
```python
|
||||
client = httpx.Client()
|
||||
client.event_hooks['request'] = [log_request]
|
||||
client.event_hooks['response'] = [log_response, raise_on_4xx_5xx]
|
||||
```
|
||||
|
||||
!!! note
|
||||
If you are using HTTPX's async support, then you need to be aware that
|
||||
hooks registered with `httpx.AsyncClient` MUST be async functions,
|
||||
rather than plain functions.
|
||||
242
docs/advanced/extensions.md
Normal file
@ -0,0 +1,242 @@
|
||||
# Extensions
|
||||
|
||||
Request and response extensions provide a untyped space where additional information may be added.
|
||||
|
||||
Extensions should be used for features that may not be available on all transports, and that do not fit neatly into [the simplified request/response model](https://www.encode.io/httpcore/extensions/) that the underlying `httpcore` package uses as its API.
|
||||
|
||||
Several extensions are supported on the request:
|
||||
|
||||
```python
|
||||
# Request timeouts actually implemented as an extension on
|
||||
# the request, ensuring that they are passed throughout the
|
||||
# entire call stack.
|
||||
client = httpx.Client()
|
||||
response = client.get(
|
||||
"https://www.example.com",
|
||||
extensions={"timeout": {"connect": 5.0}}
|
||||
)
|
||||
response.request.extensions["timeout"]
|
||||
{"connect": 5.0}
|
||||
```
|
||||
|
||||
And on the response:
|
||||
|
||||
```python
|
||||
client = httpx.Client()
|
||||
response = client.get("https://www.example.com")
|
||||
print(response.extensions["http_version"]) # b"HTTP/1.1"
|
||||
# Other server responses could have been
|
||||
# b"HTTP/0.9", b"HTTP/1.0", or b"HTTP/1.1"
|
||||
```
|
||||
|
||||
## Request Extensions
|
||||
|
||||
### `"trace"`
|
||||
|
||||
The trace extension allows a callback handler to be installed to monitor the internal
|
||||
flow of events within the underlying `httpcore` transport.
|
||||
|
||||
The simplest way to explain this is with an example:
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
def log(event_name, info):
|
||||
print(event_name, info)
|
||||
|
||||
client = httpx.Client()
|
||||
response = client.get("https://www.example.com/", extensions={"trace": log})
|
||||
# connection.connect_tcp.started {'host': 'www.example.com', 'port': 443, 'local_address': None, 'timeout': None}
|
||||
# connection.connect_tcp.complete {'return_value': <httpcore.backends.sync.SyncStream object at 0x1093f94d0>}
|
||||
# connection.start_tls.started {'ssl_context': <ssl.SSLContext object at 0x1093ee750>, 'server_hostname': b'www.example.com', 'timeout': None}
|
||||
# connection.start_tls.complete {'return_value': <httpcore.backends.sync.SyncStream object at 0x1093f9450>}
|
||||
# http11.send_request_headers.started {'request': <Request [b'GET']>}
|
||||
# http11.send_request_headers.complete {'return_value': None}
|
||||
# http11.send_request_body.started {'request': <Request [b'GET']>}
|
||||
# http11.send_request_body.complete {'return_value': None}
|
||||
# http11.receive_response_headers.started {'request': <Request [b'GET']>}
|
||||
# http11.receive_response_headers.complete {'return_value': (b'HTTP/1.1', 200, b'OK', [(b'Age', b'553715'), (b'Cache-Control', b'max-age=604800'), (b'Content-Type', b'text/html; charset=UTF-8'), (b'Date', b'Thu, 21 Oct 2021 17:08:42 GMT'), (b'Etag', b'"3147526947+ident"'), (b'Expires', b'Thu, 28 Oct 2021 17:08:42 GMT'), (b'Last-Modified', b'Thu, 17 Oct 2019 07:18:26 GMT'), (b'Server', b'ECS (nyb/1DCD)'), (b'Vary', b'Accept-Encoding'), (b'X-Cache', b'HIT'), (b'Content-Length', b'1256')])}
|
||||
# http11.receive_response_body.started {'request': <Request [b'GET']>}
|
||||
# http11.receive_response_body.complete {'return_value': None}
|
||||
# http11.response_closed.started {}
|
||||
# http11.response_closed.complete {'return_value': None}
|
||||
```
|
||||
|
||||
The `event_name` and `info` arguments here will be one of the following:
|
||||
|
||||
* `{event_type}.{event_name}.started`, `<dictionary of keyword arguments>`
|
||||
* `{event_type}.{event_name}.complete`, `{"return_value": <...>}`
|
||||
* `{event_type}.{event_name}.failed`, `{"exception": <...>}`
|
||||
|
||||
Note that when using async code the handler function passed to `"trace"` must be an `async def ...` function.
|
||||
|
||||
The following event types are currently exposed...
|
||||
|
||||
**Establishing the connection**
|
||||
|
||||
* `"connection.connect_tcp"`
|
||||
* `"connection.connect_unix_socket"`
|
||||
* `"connection.start_tls"`
|
||||
|
||||
**HTTP/1.1 events**
|
||||
|
||||
* `"http11.send_request_headers"`
|
||||
* `"http11.send_request_body"`
|
||||
* `"http11.receive_response"`
|
||||
* `"http11.receive_response_body"`
|
||||
* `"http11.response_closed"`
|
||||
|
||||
**HTTP/2 events**
|
||||
|
||||
* `"http2.send_connection_init"`
|
||||
* `"http2.send_request_headers"`
|
||||
* `"http2.send_request_body"`
|
||||
* `"http2.receive_response_headers"`
|
||||
* `"http2.receive_response_body"`
|
||||
* `"http2.response_closed"`
|
||||
|
||||
The exact set of trace events may be subject to change across different versions of `httpcore`. If you need to rely on a particular set of events it is recommended that you pin installation of the package to a fixed version.
|
||||
|
||||
### `"sni_hostname"`
|
||||
|
||||
The server's hostname, which is used to confirm the hostname supplied by the SSL certificate.
|
||||
|
||||
If you want to connect to an explicit IP address rather than using the standard DNS hostname lookup, then you'll need to use this request extension.
|
||||
|
||||
For example:
|
||||
|
||||
``` python
|
||||
# Connect to '185.199.108.153' but use 'www.encode.io' in the Host header,
|
||||
# and use 'www.encode.io' when SSL verifying the server hostname.
|
||||
client = httpx.Client()
|
||||
headers = {"Host": "www.encode.io"}
|
||||
extensions = {"sni_hostname": "www.encode.io"}
|
||||
response = client.get(
|
||||
"https://185.199.108.153/path",
|
||||
headers=headers,
|
||||
extensions=extensions
|
||||
)
|
||||
```
|
||||
|
||||
### `"timeout"`
|
||||
|
||||
A dictionary of `str: Optional[float]` timeout values.
|
||||
|
||||
May include values for `'connect'`, `'read'`, `'write'`, or `'pool'`.
|
||||
|
||||
For example:
|
||||
|
||||
```python
|
||||
# Timeout if a connection takes more than 5 seconds to established, or if
|
||||
# we are blocked waiting on the connection pool for more than 10 seconds.
|
||||
client = httpx.Client()
|
||||
response = client.get(
|
||||
"https://www.example.com",
|
||||
extensions={"timeout": {"connect": 5.0, "pool": 10.0}}
|
||||
)
|
||||
```
|
||||
|
||||
This extension is how the `httpx` timeouts are implemented, ensuring that the timeout values are associated with the request instance and passed throughout the stack. You shouldn't typically be working with this extension directly, but use the higher level `timeout` API instead.
|
||||
|
||||
### `"target"`
|
||||
|
||||
The target that is used as [the HTTP target instead of the URL path](https://datatracker.ietf.org/doc/html/rfc2616#section-5.1.2).
|
||||
|
||||
This enables support constructing requests that would otherwise be unsupported.
|
||||
|
||||
* URL paths with non-standard escaping applied.
|
||||
* Forward proxy requests using an absolute URI.
|
||||
* Tunneling proxy requests using `CONNECT` with hostname as the target.
|
||||
* Server-wide `OPTIONS *` requests.
|
||||
|
||||
Some examples:
|
||||
|
||||
Using the 'target' extension to send requests without the standard path escaping rules...
|
||||
|
||||
```python
|
||||
# Typically a request to "https://www.example.com/test^path" would
|
||||
# connect to "www.example.com" and send an HTTP/1.1 request like...
|
||||
#
|
||||
# GET /test%5Epath HTTP/1.1
|
||||
#
|
||||
# Using the target extension we can include the literal '^'...
|
||||
#
|
||||
# GET /test^path HTTP/1.1
|
||||
#
|
||||
# Note that requests must still be valid HTTP requests.
|
||||
# For example including whitespace in the target will raise a `LocalProtocolError`.
|
||||
extensions = {"target": b"/test^path"}
|
||||
response = httpx.get("https://www.example.com", extensions=extensions)
|
||||
```
|
||||
|
||||
The `target` extension also allows server-wide `OPTIONS *` requests to be constructed...
|
||||
|
||||
```python
|
||||
# This will send the following request...
|
||||
#
|
||||
# CONNECT * HTTP/1.1
|
||||
extensions = {"target": b"*"}
|
||||
response = httpx.request("CONNECT", "https://www.example.com", extensions=extensions)
|
||||
```
|
||||
|
||||
## Response Extensions
|
||||
|
||||
### `"http_version"`
|
||||
|
||||
The HTTP version, as bytes. Eg. `b"HTTP/1.1"`.
|
||||
|
||||
When using HTTP/1.1 the response line includes an explicit version, and the value of this key could feasibly be one of `b"HTTP/0.9"`, `b"HTTP/1.0"`, or `b"HTTP/1.1"`.
|
||||
|
||||
When using HTTP/2 there is no further response versioning included in the protocol, and the value of this key will always be `b"HTTP/2"`.
|
||||
|
||||
### `"reason_phrase"`
|
||||
|
||||
The reason-phrase of the HTTP response, as bytes. For example `b"OK"`. Some servers may include a custom reason phrase, although this is not recommended.
|
||||
|
||||
HTTP/2 onwards does not include a reason phrase on the wire.
|
||||
|
||||
When no key is included, a default based on the status code may be used.
|
||||
|
||||
### `"stream_id"`
|
||||
|
||||
When HTTP/2 is being used the `"stream_id"` response extension can be accessed to determine the ID of the data stream that the response was sent on.
|
||||
|
||||
### `"network_stream"`
|
||||
|
||||
The `"network_stream"` extension allows developers to handle HTTP `CONNECT` and `Upgrade` requests, by providing an API that steps outside the standard request/response model, and can directly read or write to the network.
|
||||
|
||||
The interface provided by the network stream:
|
||||
|
||||
* `read(max_bytes, timeout = None) -> bytes`
|
||||
* `write(buffer, timeout = None)`
|
||||
* `close()`
|
||||
* `start_tls(ssl_context, server_hostname = None, timeout = None) -> NetworkStream`
|
||||
* `get_extra_info(info) -> Any`
|
||||
|
||||
This API can be used as the foundation for working with HTTP proxies, WebSocket upgrades, and other advanced use-cases.
|
||||
|
||||
See the [network backends documentation](https://www.encode.io/httpcore/network-backends/) for more information on working directly with network streams.
|
||||
|
||||
**Extra network information**
|
||||
|
||||
The network stream abstraction also allows access to various low-level information that may be exposed by the underlying socket:
|
||||
|
||||
```python
|
||||
response = httpx.get("https://www.example.com")
|
||||
network_stream = response.extensions["network_stream"]
|
||||
|
||||
client_addr = network_stream.get_extra_info("client_addr")
|
||||
server_addr = network_stream.get_extra_info("server_addr")
|
||||
print("Client address", client_addr)
|
||||
print("Server address", server_addr)
|
||||
```
|
||||
|
||||
The socket SSL information is also available through this interface, although you need to ensure that the underlying connection is still open, in order to access it...
|
||||
|
||||
```python
|
||||
with httpx.stream("GET", "https://www.example.com") as response:
|
||||
network_stream = response.extensions["network_stream"]
|
||||
|
||||
ssl_object = network_stream.get_extra_info("ssl_object")
|
||||
print("TLS version", ssl_object.version())
|
||||
```
|
||||
83
docs/advanced/proxies.md
Normal file
@ -0,0 +1,83 @@
|
||||
HTTPX supports setting up [HTTP proxies](https://en.wikipedia.org/wiki/Proxy_server#Web_proxy_servers) via the `proxy` parameter to be passed on client initialization or top-level API functions like `httpx.get(..., proxy=...)`.
|
||||
|
||||
<div align="center">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/2/27/Open_proxy_h2g2bob.svg/480px-Open_proxy_h2g2bob.svg.png"/>
|
||||
<figcaption><em>Diagram of how a proxy works (source: Wikipedia). The left hand side "Internet" blob may be your HTTPX client requesting <code>example.com</code> through a proxy.</em></figcaption>
|
||||
</div>
|
||||
|
||||
## HTTP Proxies
|
||||
|
||||
To route all traffic (HTTP and HTTPS) to a proxy located at `http://localhost:8030`, pass the proxy URL to the client...
|
||||
|
||||
```python
|
||||
with httpx.Client(proxy="http://localhost:8030") as client:
|
||||
...
|
||||
```
|
||||
|
||||
For more advanced use cases, pass a mounts `dict`. For example, to route HTTP and HTTPS requests to 2 different proxies, respectively located at `http://localhost:8030`, and `http://localhost:8031`, pass a `dict` of proxy URLs:
|
||||
|
||||
```python
|
||||
proxy_mounts = {
|
||||
"http://": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||
"https://": httpx.HTTPTransport(proxy="http://localhost:8031"),
|
||||
}
|
||||
|
||||
with httpx.Client(mounts=proxy_mounts) as client:
|
||||
...
|
||||
```
|
||||
|
||||
For detailed information about proxy routing, see the [Routing](#routing) section.
|
||||
|
||||
!!! tip "Gotcha"
|
||||
In most cases, the proxy URL for the `https://` key _should_ use the `http://` scheme (that's not a typo!).
|
||||
|
||||
This is because HTTP proxying requires initiating a connection with the proxy server. While it's possible that your proxy supports doing it via HTTPS, most proxies only support doing it via HTTP.
|
||||
|
||||
For more information, see [FORWARD vs TUNNEL](#forward-vs-tunnel).
|
||||
|
||||
## Authentication
|
||||
|
||||
Proxy credentials can be passed as the `userinfo` section of the proxy URL. For example:
|
||||
|
||||
```python
|
||||
with httpx.Client(proxy="http://username:password@localhost:8030") as client:
|
||||
...
|
||||
```
|
||||
|
||||
## Proxy mechanisms
|
||||
|
||||
!!! note
|
||||
This section describes **advanced** proxy concepts and functionality.
|
||||
|
||||
### FORWARD vs TUNNEL
|
||||
|
||||
In general, the flow for making an HTTP request through a proxy is as follows:
|
||||
|
||||
1. The client connects to the proxy (initial connection request).
|
||||
2. The proxy transfers data to the server on your behalf.
|
||||
|
||||
How exactly step 2/ is performed depends on which of two proxying mechanisms is used:
|
||||
|
||||
* **Forwarding**: the proxy makes the request for you, and sends back the response it obtained from the server.
|
||||
* **Tunnelling**: the proxy establishes a TCP connection to the server on your behalf, and the client reuses this connection to send the request and receive the response. This is known as an [HTTP Tunnel](https://en.wikipedia.org/wiki/HTTP_tunnel). This mechanism is how you can access websites that use HTTPS from an HTTP proxy (the client "upgrades" the connection to HTTPS by performing the TLS handshake with the server over the TCP connection provided by the proxy).
|
||||
|
||||
### Troubleshooting proxies
|
||||
|
||||
If you encounter issues when setting up proxies, please refer to our [Troubleshooting guide](../troubleshooting.md#proxies).
|
||||
|
||||
## SOCKS
|
||||
|
||||
In addition to HTTP proxies, `httpcore` also supports proxies using the SOCKS protocol.
|
||||
This is an optional feature that requires an additional third-party library be installed before use.
|
||||
|
||||
You can install SOCKS support using `pip`:
|
||||
|
||||
```shell
|
||||
$ pip install httpx[socks]
|
||||
```
|
||||
|
||||
You can now configure a client to make requests via a proxy using the SOCKS protocol:
|
||||
|
||||
```python
|
||||
httpx.Client(proxy='socks5://user:pass@host:port')
|
||||
```
|
||||
13
docs/advanced/resource-limits.md
Normal file
@ -0,0 +1,13 @@
|
||||
You can control the connection pool size using the `limits` keyword
|
||||
argument on the client. It takes instances of `httpx.Limits` which define:
|
||||
|
||||
- `max_keepalive_connections`, number of allowable keep-alive connections, or `None` to always
|
||||
allow. (Defaults 20)
|
||||
- `max_connections`, maximum number of allowable connections, or `None` for no limits.
|
||||
(Default 100)
|
||||
- `keepalive_expiry`, time limit on idle keep-alive connections in seconds, or `None` for no limits. (Default 5)
|
||||
|
||||
```python
|
||||
limits = httpx.Limits(max_keepalive_connections=5, max_connections=10)
|
||||
client = httpx.Client(limits=limits)
|
||||
```
|
||||
89
docs/advanced/ssl.md
Normal file
@ -0,0 +1,89 @@
|
||||
When making a request over HTTPS, HTTPX needs to verify the identity of the requested host. To do this, it uses a bundle of SSL certificates (a.k.a. CA bundle) delivered by a trusted certificate authority (CA).
|
||||
|
||||
### Enabling and disabling verification
|
||||
|
||||
By default httpx will verify HTTPS connections, and raise an error for invalid SSL cases...
|
||||
|
||||
```pycon
|
||||
>>> httpx.get("https://expired.badssl.com/")
|
||||
httpx.ConnectError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:997)
|
||||
```
|
||||
|
||||
You can disable SSL verification completely and allow insecure requests...
|
||||
|
||||
```pycon
|
||||
>>> httpx.get("https://expired.badssl.com/", verify=False)
|
||||
<Response [200 OK]>
|
||||
```
|
||||
|
||||
### Configuring client instances
|
||||
|
||||
If you're using a `Client()` instance you should pass any `verify=<...>` configuration when instantiating the client.
|
||||
|
||||
By default the [certifi CA bundle](https://certifiio.readthedocs.io/en/latest/) is used for SSL verification.
|
||||
|
||||
For more complex configurations you can pass an [SSL Context](https://docs.python.org/3/library/ssl.html) instance...
|
||||
|
||||
```python
|
||||
import certifi
|
||||
import httpx
|
||||
import ssl
|
||||
|
||||
# This SSL context is equivalent to the default `verify=True`.
|
||||
ctx = ssl.create_default_context(cafile=certifi.where())
|
||||
client = httpx.Client(verify=ctx)
|
||||
```
|
||||
|
||||
Using [the `truststore` package](https://truststore.readthedocs.io/) to support system certificate stores...
|
||||
|
||||
```python
|
||||
import ssl
|
||||
import truststore
|
||||
import httpx
|
||||
|
||||
# Use system certificate stores.
|
||||
ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
client = httpx.Client(verify=ctx)
|
||||
```
|
||||
|
||||
Loding an alternative certificate verification store using [the standard SSL context API](https://docs.python.org/3/library/ssl.html)...
|
||||
|
||||
```python
|
||||
import httpx
|
||||
import ssl
|
||||
|
||||
# Use an explicitly configured certificate store.
|
||||
ctx = ssl.create_default_context(cafile="path/to/certs.pem") # Either cafile or capath.
|
||||
client = httpx.Client(verify=ctx)
|
||||
```
|
||||
|
||||
### Client side certificates
|
||||
|
||||
Client side certificates allow a remote server to verify the client. They tend to be used within private organizations to authenticate requests to remote servers.
|
||||
|
||||
You can specify client-side certificates, using the [`.load_cert_chain()`](https://docs.python.org/3/library/ssl.html#ssl.SSLContext.load_cert_chain) API...
|
||||
|
||||
```python
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.load_cert_chain(certfile="path/to/client.pem") # Optionally also keyfile or password.
|
||||
client = httpx.Client(verify=ctx)
|
||||
```
|
||||
|
||||
### Working with `SSL_CERT_FILE` and `SSL_CERT_DIR`
|
||||
|
||||
`httpx` does respect the `SSL_CERT_FILE` and `SSL_CERT_DIR` environment variables by default. For details, refer to [the section on the environment variables page](../environment_variables.md#ssl_cert_file).
|
||||
|
||||
### Making HTTPS requests to a local server
|
||||
|
||||
When making requests to local servers, such as a development server running on `localhost`, you will typically be using unencrypted HTTP connections.
|
||||
|
||||
If you do need to make HTTPS connections to a local server, for example to test an HTTPS-only service, you will need to create and use your own certificates. Here's one way to do it...
|
||||
|
||||
1. Use [trustme](https://github.com/python-trio/trustme) to generate a pair of server key/cert files, and a client cert file.
|
||||
2. Pass the server key/cert files when starting your local server. (This depends on the particular web server you're using. For example, [Uvicorn](https://www.uvicorn.org) provides the `--ssl-keyfile` and `--ssl-certfile` options.)
|
||||
3. Configure `httpx` to use the certificates stored in `client.pem`.
|
||||
|
||||
```python
|
||||
ctx = ssl.create_default_context(cafile="client.pem")
|
||||
client = httpx.Client(verify=ctx)
|
||||
```
|
||||
75
docs/advanced/text-encodings.md
Normal file
@ -0,0 +1,75 @@
|
||||
When accessing `response.text`, we need to decode the response bytes into a unicode text representation.
|
||||
|
||||
By default `httpx` will use `"charset"` information included in the response `Content-Type` header to determine how the response bytes should be decoded into text.
|
||||
|
||||
In cases where no charset information is included on the response, the default behaviour is to assume "utf-8" encoding, which is by far the most widely used text encoding on the internet.
|
||||
|
||||
## Using the default encoding
|
||||
|
||||
To understand this better let's start by looking at the default behaviour for text decoding...
|
||||
|
||||
```python
|
||||
import httpx
|
||||
# Instantiate a client with the default configuration.
|
||||
client = httpx.Client()
|
||||
# Using the client...
|
||||
response = client.get(...)
|
||||
print(response.encoding) # This will either print the charset given in
|
||||
# the Content-Type charset, or else "utf-8".
|
||||
print(response.text) # The text will either be decoded with the Content-Type
|
||||
# charset, or using "utf-8".
|
||||
```
|
||||
|
||||
This is normally absolutely fine. Most servers will respond with a properly formatted Content-Type header, including a charset encoding. And in most cases where no charset encoding is included, UTF-8 is very likely to be used, since it is so widely adopted.
|
||||
|
||||
## Using an explicit encoding
|
||||
|
||||
In some cases we might be making requests to a site where no character set information is being set explicitly by the server, but we know what the encoding is. In this case it's best to set the default encoding explicitly on the client.
|
||||
|
||||
```python
|
||||
import httpx
|
||||
# Instantiate a client with a Japanese character set as the default encoding.
|
||||
client = httpx.Client(default_encoding="shift-jis")
|
||||
# Using the client...
|
||||
response = client.get(...)
|
||||
print(response.encoding) # This will either print the charset given in
|
||||
# the Content-Type charset, or else "shift-jis".
|
||||
print(response.text) # The text will either be decoded with the Content-Type
|
||||
# charset, or using "shift-jis".
|
||||
```
|
||||
|
||||
## Using auto-detection
|
||||
|
||||
In cases where the server is not reliably including character set information, and where we don't know what encoding is being used, we can enable auto-detection to make a best-guess attempt when decoding from bytes to text.
|
||||
|
||||
To use auto-detection you need to set the `default_encoding` argument to a callable instead of a string. This callable should be a function which takes the input bytes as an argument and returns the character set to use for decoding those bytes to text.
|
||||
|
||||
There are two widely used Python packages which both handle this functionality:
|
||||
|
||||
* [`chardet`](https://chardet.readthedocs.io/) - This is a well established package, and is a port of [the auto-detection code in Mozilla](https://www-archive.mozilla.org/projects/intl/chardet.html).
|
||||
* [`charset-normalizer`](https://charset-normalizer.readthedocs.io/) - A newer package, motivated by `chardet`, with a different approach.
|
||||
|
||||
Let's take a look at installing autodetection using one of these packages...
|
||||
|
||||
```shell
|
||||
$ pip install httpx
|
||||
$ pip install chardet
|
||||
```
|
||||
|
||||
Once `chardet` is installed, we can configure a client to use character-set autodetection.
|
||||
|
||||
```python
|
||||
import httpx
|
||||
import chardet
|
||||
|
||||
def autodetect(content):
|
||||
return chardet.detect(content).get("encoding")
|
||||
|
||||
# Using a client with character-set autodetection enabled.
|
||||
client = httpx.Client(default_encoding=autodetect)
|
||||
response = client.get(...)
|
||||
print(response.encoding) # This will either print the charset given in
|
||||
# the Content-Type charset, or else the auto-detected
|
||||
# character set.
|
||||
print(response.text)
|
||||
```
|
||||
71
docs/advanced/timeouts.md
Normal file
@ -0,0 +1,71 @@
|
||||
HTTPX is careful to enforce timeouts everywhere by default.
|
||||
|
||||
The default behavior is to raise a `TimeoutException` after 5 seconds of
|
||||
network inactivity.
|
||||
|
||||
## Setting and disabling timeouts
|
||||
|
||||
You can set timeouts for an individual request:
|
||||
|
||||
```python
|
||||
# Using the top-level API:
|
||||
httpx.get('http://example.com/api/v1/example', timeout=10.0)
|
||||
|
||||
# Using a client instance:
|
||||
with httpx.Client() as client:
|
||||
client.get("http://example.com/api/v1/example", timeout=10.0)
|
||||
```
|
||||
|
||||
Or disable timeouts for an individual request:
|
||||
|
||||
```python
|
||||
# Using the top-level API:
|
||||
httpx.get('http://example.com/api/v1/example', timeout=None)
|
||||
|
||||
# Using a client instance:
|
||||
with httpx.Client() as client:
|
||||
client.get("http://example.com/api/v1/example", timeout=None)
|
||||
```
|
||||
|
||||
## Setting a default timeout on a client
|
||||
|
||||
You can set a timeout on a client instance, which results in the given
|
||||
`timeout` being used as the default for requests made with this client:
|
||||
|
||||
```python
|
||||
client = httpx.Client() # Use a default 5s timeout everywhere.
|
||||
client = httpx.Client(timeout=10.0) # Use a default 10s timeout everywhere.
|
||||
client = httpx.Client(timeout=None) # Disable all timeouts by default.
|
||||
```
|
||||
|
||||
## Fine tuning the configuration
|
||||
|
||||
HTTPX also allows you to specify the timeout behavior in more fine grained detail.
|
||||
|
||||
There are four different types of timeouts that may occur. These are **connect**,
|
||||
**read**, **write**, and **pool** timeouts.
|
||||
|
||||
* The **connect** timeout specifies the maximum amount of time to wait until
|
||||
a socket connection to the requested host is established. If HTTPX is unable to connect
|
||||
within this time frame, a `ConnectTimeout` exception is raised.
|
||||
* The **read** timeout specifies the maximum duration to wait for a chunk of
|
||||
data to be received (for example, a chunk of the response body). If HTTPX is
|
||||
unable to receive data within this time frame, a `ReadTimeout` exception is raised.
|
||||
* The **write** timeout specifies the maximum duration to wait for a chunk of
|
||||
data to be sent (for example, a chunk of the request body). If HTTPX is unable
|
||||
to send data within this time frame, a `WriteTimeout` exception is raised.
|
||||
* The **pool** timeout specifies the maximum duration to wait for acquiring
|
||||
a connection from the connection pool. If HTTPX is unable to acquire a connection
|
||||
within this time frame, a `PoolTimeout` exception is raised. A related
|
||||
configuration here is the maximum number of allowable connections in the
|
||||
connection pool, which is configured by the `limits` argument.
|
||||
|
||||
You can configure the timeout behavior for any of these values...
|
||||
|
||||
```python
|
||||
# A client with a 60s timeout for connecting, and a 10s timeout elsewhere.
|
||||
timeout = httpx.Timeout(10.0, connect=60.0)
|
||||
client = httpx.Client(timeout=timeout)
|
||||
|
||||
response = client.get('http://example.com/')
|
||||
```
|
||||
454
docs/advanced/transports.md
Normal file
@ -0,0 +1,454 @@
|
||||
HTTPX's `Client` also accepts a `transport` argument. This argument allows you
|
||||
to provide a custom Transport object that will be used to perform the actual
|
||||
sending of the requests.
|
||||
|
||||
## HTTP Transport
|
||||
|
||||
For some advanced configuration you might need to instantiate a transport
|
||||
class directly, and pass it to the client instance. One example is the
|
||||
`local_address` configuration which is only available via this low-level API.
|
||||
|
||||
```pycon
|
||||
>>> import httpx
|
||||
>>> transport = httpx.HTTPTransport(local_address="0.0.0.0")
|
||||
>>> client = httpx.Client(transport=transport)
|
||||
```
|
||||
|
||||
Connection retries are also available via this interface. Requests will be retried the given number of times in case an `httpx.ConnectError` or an `httpx.ConnectTimeout` occurs, allowing smoother operation under flaky networks. If you need other forms of retry behaviors, such as handling read/write errors or reacting to `503 Service Unavailable`, consider general-purpose tools such as [tenacity](https://github.com/jd/tenacity).
|
||||
|
||||
```pycon
|
||||
>>> import httpx
|
||||
>>> transport = httpx.HTTPTransport(retries=1)
|
||||
>>> client = httpx.Client(transport=transport)
|
||||
```
|
||||
|
||||
Similarly, instantiating a transport directly provides a `uds` option for
|
||||
connecting via a Unix Domain Socket that is only available via this low-level API:
|
||||
|
||||
```pycon
|
||||
>>> import httpx
|
||||
>>> # Connect to the Docker API via a Unix Socket.
|
||||
>>> transport = httpx.HTTPTransport(uds="/var/run/docker.sock")
|
||||
>>> client = httpx.Client(transport=transport)
|
||||
>>> response = client.get("http://docker/info")
|
||||
>>> response.json()
|
||||
{"ID": "...", "Containers": 4, "Images": 74, ...}
|
||||
```
|
||||
|
||||
## WSGI Transport
|
||||
|
||||
You can configure an `httpx` client to call directly into a Python web application using the WSGI protocol.
|
||||
|
||||
This is particularly useful for two main use-cases:
|
||||
|
||||
* Using `httpx` as a client inside test cases.
|
||||
* Mocking out external services during tests or in dev or staging environments.
|
||||
|
||||
### Example
|
||||
|
||||
Here's an example of integrating against a Flask application:
|
||||
|
||||
```python
|
||||
from flask import Flask
|
||||
import httpx
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/")
|
||||
def hello():
|
||||
return "Hello World!"
|
||||
|
||||
transport = httpx.WSGITransport(app=app)
|
||||
with httpx.Client(transport=transport, base_url="http://testserver") as client:
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert r.text == "Hello World!"
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
For some more complex cases you might need to customize the WSGI transport. This allows you to:
|
||||
|
||||
* Inspect 500 error responses rather than raise exceptions by setting `raise_app_exceptions=False`.
|
||||
* Mount the WSGI application at a subpath by setting `script_name` (WSGI).
|
||||
* Use a given client address for requests by setting `remote_addr` (WSGI).
|
||||
|
||||
For example:
|
||||
|
||||
```python
|
||||
# Instantiate a client that makes WSGI requests with a client IP of "1.2.3.4".
|
||||
transport = httpx.WSGITransport(app=app, remote_addr="1.2.3.4")
|
||||
with httpx.Client(transport=transport, base_url="http://testserver") as client:
|
||||
...
|
||||
```
|
||||
|
||||
## ASGI Transport
|
||||
|
||||
You can configure an `httpx` client to call directly into an async Python web application using the ASGI protocol.
|
||||
|
||||
This is particularly useful for two main use-cases:
|
||||
|
||||
* Using `httpx` as a client inside test cases.
|
||||
* Mocking out external services during tests or in dev or staging environments.
|
||||
|
||||
### Example
|
||||
|
||||
Let's take this Starlette application as an example:
|
||||
|
||||
```python
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import HTMLResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
|
||||
async def hello(request):
|
||||
return HTMLResponse("Hello World!")
|
||||
|
||||
|
||||
app = Starlette(routes=[Route("/", hello)])
|
||||
```
|
||||
|
||||
We can make requests directly against the application, like so:
|
||||
|
||||
```python
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
r = await client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert r.text == "Hello World!"
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
For some more complex cases you might need to customise the ASGI transport. This allows you to:
|
||||
|
||||
* Inspect 500 error responses rather than raise exceptions by setting `raise_app_exceptions=False`.
|
||||
* Mount the ASGI application at a subpath by setting `root_path`.
|
||||
* Use a given client address for requests by setting `client`.
|
||||
|
||||
For example:
|
||||
|
||||
```python
|
||||
# Instantiate a client that makes ASGI requests with a client IP of "1.2.3.4",
|
||||
# on port 123.
|
||||
transport = httpx.ASGITransport(app=app, client=("1.2.3.4", 123))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
...
|
||||
```
|
||||
|
||||
See [the ASGI documentation](https://asgi.readthedocs.io/en/latest/specs/www.html#connection-scope) for more details on the `client` and `root_path` keys.
|
||||
|
||||
### ASGI startup and shutdown
|
||||
|
||||
It is not in the scope of HTTPX to trigger ASGI lifespan events of your app.
|
||||
|
||||
However it is suggested to use `LifespanManager` from [asgi-lifespan](https://github.com/florimondmanca/asgi-lifespan#usage) in pair with `AsyncClient`.
|
||||
|
||||
## Custom transports
|
||||
|
||||
A transport instance must implement the low-level Transport API which deals
|
||||
with sending a single request, and returning a response. You should either
|
||||
subclass `httpx.BaseTransport` to implement a transport to use with `Client`,
|
||||
or subclass `httpx.AsyncBaseTransport` to implement a transport to
|
||||
use with `AsyncClient`.
|
||||
|
||||
At the layer of the transport API we're using the familiar `Request` and
|
||||
`Response` models.
|
||||
|
||||
See the `handle_request` and `handle_async_request` docstrings for more details
|
||||
on the specifics of the Transport API.
|
||||
|
||||
A complete example of a custom transport implementation would be:
|
||||
|
||||
```python
|
||||
import json
|
||||
import httpx
|
||||
|
||||
class HelloWorldTransport(httpx.BaseTransport):
|
||||
"""
|
||||
A mock transport that always returns a JSON "Hello, world!" response.
|
||||
"""
|
||||
|
||||
def handle_request(self, request):
|
||||
return httpx.Response(200, json={"text": "Hello, world!"})
|
||||
```
|
||||
|
||||
Or this example, which uses a custom transport and `httpx.Mounts` to always redirect `http://` requests.
|
||||
|
||||
```python
|
||||
class HTTPSRedirect(httpx.BaseTransport):
|
||||
"""
|
||||
A transport that always redirects to HTTPS.
|
||||
"""
|
||||
def handle_request(self, request):
|
||||
url = request.url.copy_with(scheme="https")
|
||||
return httpx.Response(303, headers={"Location": str(url)})
|
||||
|
||||
# A client where any `http` requests are always redirected to `https`
|
||||
transport = httpx.Mounts({
|
||||
'http://': HTTPSRedirect()
|
||||
'https://': httpx.HTTPTransport()
|
||||
})
|
||||
client = httpx.Client(transport=transport)
|
||||
```
|
||||
|
||||
A useful pattern here is custom transport classes that wrap the default HTTP implementation. For example...
|
||||
|
||||
```python
|
||||
class DebuggingTransport(httpx.BaseTransport):
|
||||
def __init__(self, **kwargs):
|
||||
self._wrapper = httpx.HTTPTransport(**kwargs)
|
||||
|
||||
def handle_request(self, request):
|
||||
print(f">>> {request}")
|
||||
response = self._wrapper.handle_request(request)
|
||||
print(f"<<< {response}")
|
||||
return response
|
||||
|
||||
def close(self):
|
||||
self._wrapper.close()
|
||||
|
||||
transport = DebuggingTransport()
|
||||
client = httpx.Client(transport=transport)
|
||||
```
|
||||
|
||||
Here's another case, where we're using a round-robin across a number of different proxies...
|
||||
|
||||
```python
|
||||
class ProxyRoundRobin(httpx.BaseTransport):
|
||||
def __init__(self, proxies, **kwargs):
|
||||
self._transports = [
|
||||
httpx.HTTPTransport(proxy=proxy, **kwargs)
|
||||
for proxy in proxies
|
||||
]
|
||||
self._idx = 0
|
||||
|
||||
def handle_request(self, request):
|
||||
transport = self._transports[self._idx]
|
||||
self._idx = (self._idx + 1) % len(self._transports)
|
||||
return transport.handle_request(request)
|
||||
|
||||
def close(self):
|
||||
for transport in self._transports:
|
||||
transport.close()
|
||||
|
||||
proxies = [
|
||||
httpx.Proxy("http://127.0.0.1:8081"),
|
||||
httpx.Proxy("http://127.0.0.1:8082"),
|
||||
httpx.Proxy("http://127.0.0.1:8083"),
|
||||
]
|
||||
transport = ProxyRoundRobin(proxies=proxies)
|
||||
client = httpx.Client(transport=transport)
|
||||
```
|
||||
|
||||
## Mock transports
|
||||
|
||||
During testing it can often be useful to be able to mock out a transport,
|
||||
and return pre-determined responses, rather than making actual network requests.
|
||||
|
||||
The `httpx.MockTransport` class accepts a handler function, which can be used
|
||||
to map requests onto pre-determined responses:
|
||||
|
||||
```python
|
||||
def handler(request):
|
||||
return httpx.Response(200, json={"text": "Hello, world!"})
|
||||
|
||||
|
||||
# Switch to a mock transport, if the TESTING environment variable is set.
|
||||
if os.environ.get('TESTING', '').upper() == "TRUE":
|
||||
transport = httpx.MockTransport(handler)
|
||||
else:
|
||||
transport = httpx.HTTPTransport()
|
||||
|
||||
client = httpx.Client(transport=transport)
|
||||
```
|
||||
|
||||
For more advanced use-cases you might want to take a look at either [the third-party
|
||||
mocking library, RESPX](https://lundberg.github.io/respx/), or the [pytest-httpx library](https://github.com/Colin-b/pytest_httpx).
|
||||
|
||||
## Mounting transports
|
||||
|
||||
You can also mount transports against given schemes or domains, to control
|
||||
which transport an outgoing request should be routed via, with [the same style
|
||||
used for specifying proxy routing](#routing).
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
class HTTPSRedirectTransport(httpx.BaseTransport):
|
||||
"""
|
||||
A transport that always redirects to HTTPS.
|
||||
"""
|
||||
|
||||
def handle_request(self, method, url, headers, stream, extensions):
|
||||
scheme, host, port, path = url
|
||||
if port is None:
|
||||
location = b"https://%s%s" % (host, path)
|
||||
else:
|
||||
location = b"https://%s:%d%s" % (host, port, path)
|
||||
stream = httpx.ByteStream(b"")
|
||||
headers = [(b"location", location)]
|
||||
extensions = {}
|
||||
return 303, headers, stream, extensions
|
||||
|
||||
|
||||
# A client where any `http` requests are always redirected to `https`
|
||||
mounts = {'http://': HTTPSRedirectTransport()}
|
||||
client = httpx.Client(mounts=mounts)
|
||||
```
|
||||
|
||||
A couple of other sketches of how you might take advantage of mounted transports...
|
||||
|
||||
Disabling HTTP/2 on a single given domain...
|
||||
|
||||
```python
|
||||
mounts = {
|
||||
"all://": httpx.HTTPTransport(http2=True),
|
||||
"all://*example.org": httpx.HTTPTransport()
|
||||
}
|
||||
client = httpx.Client(mounts=mounts)
|
||||
```
|
||||
|
||||
Mocking requests to a given domain:
|
||||
|
||||
```python
|
||||
# All requests to "example.org" should be mocked out.
|
||||
# Other requests occur as usual.
|
||||
def handler(request):
|
||||
return httpx.Response(200, json={"text": "Hello, World!"})
|
||||
|
||||
mounts = {"all://example.org": httpx.MockTransport(handler)}
|
||||
client = httpx.Client(mounts=mounts)
|
||||
```
|
||||
|
||||
Adding support for custom schemes:
|
||||
|
||||
```python
|
||||
# Support URLs like "file:///Users/sylvia_green/websites/new_client/index.html"
|
||||
mounts = {"file://": FileSystemTransport()}
|
||||
client = httpx.Client(mounts=mounts)
|
||||
```
|
||||
|
||||
### Routing
|
||||
|
||||
HTTPX provides a powerful mechanism for routing requests, allowing you to write complex rules that specify which transport should be used for each request.
|
||||
|
||||
The `mounts` dictionary maps URL patterns to HTTP transports. HTTPX matches requested URLs against URL patterns to decide which transport should be used, if any. Matching is done from most specific URL patterns (e.g. `https://<domain>:<port>`) to least specific ones (e.g. `https://`).
|
||||
|
||||
HTTPX supports routing requests based on **scheme**, **domain**, **port**, or a combination of these.
|
||||
|
||||
### Wildcard routing
|
||||
|
||||
Route everything through a transport...
|
||||
|
||||
```python
|
||||
mounts = {
|
||||
"all://": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||
}
|
||||
```
|
||||
|
||||
### Scheme routing
|
||||
|
||||
Route HTTP requests through one transport, and HTTPS requests through another...
|
||||
|
||||
```python
|
||||
mounts = {
|
||||
"http://": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||
"https://": httpx.HTTPTransport(proxy="http://localhost:8031"),
|
||||
}
|
||||
```
|
||||
|
||||
### Domain routing
|
||||
|
||||
Proxy all requests on domain "example.com", let other requests pass through...
|
||||
|
||||
```python
|
||||
mounts = {
|
||||
"all://example.com": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||
}
|
||||
```
|
||||
|
||||
Proxy HTTP requests on domain "example.com", let HTTPS and other requests pass through...
|
||||
|
||||
```python
|
||||
mounts = {
|
||||
"http://example.com": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||
}
|
||||
```
|
||||
|
||||
Proxy all requests to "example.com" and its subdomains, let other requests pass through...
|
||||
|
||||
```python
|
||||
mounts = {
|
||||
"all://*example.com": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||
}
|
||||
```
|
||||
|
||||
Proxy all requests to strict subdomains of "example.com", let "example.com" and other requests pass through...
|
||||
|
||||
```python
|
||||
mounts = {
|
||||
"all://*.example.com": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||
}
|
||||
```
|
||||
|
||||
### Port routing
|
||||
|
||||
Proxy HTTPS requests on port 1234 to "example.com"...
|
||||
|
||||
```python
|
||||
mounts = {
|
||||
"https://example.com:1234": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||
}
|
||||
```
|
||||
|
||||
Proxy all requests on port 1234...
|
||||
|
||||
```python
|
||||
mounts = {
|
||||
"all://*:1234": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||
}
|
||||
```
|
||||
|
||||
### No-proxy support
|
||||
|
||||
It is also possible to define requests that _shouldn't_ be routed through the transport.
|
||||
|
||||
To do so, pass `None` as the proxy URL. For example...
|
||||
|
||||
```python
|
||||
mounts = {
|
||||
# Route requests through a proxy by default...
|
||||
"all://": httpx.HTTPTransport(proxy="http://localhost:8031"),
|
||||
# Except those for "example.com".
|
||||
"all://example.com": None,
|
||||
}
|
||||
```
|
||||
|
||||
### Complex configuration example
|
||||
|
||||
You can combine the routing features outlined above to build complex proxy routing configurations. For example...
|
||||
|
||||
```python
|
||||
mounts = {
|
||||
# Route all traffic through a proxy by default...
|
||||
"all://": httpx.HTTPTransport(proxy="http://localhost:8030"),
|
||||
# But don't use proxies for HTTPS requests to "domain.io"...
|
||||
"https://domain.io": None,
|
||||
# And use another proxy for requests to "example.com" and its subdomains...
|
||||
"all://*example.com": httpx.HTTPTransport(proxy="http://localhost:8031"),
|
||||
# And yet another proxy if HTTP is used,
|
||||
# and the "internal" subdomain on port 5550 is requested...
|
||||
"http://internal.example.com:5550": httpx.HTTPTransport(proxy="http://localhost:8032"),
|
||||
}
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
There are also environment variables that can be used to control the dictionary of the client mounts.
|
||||
They can be used to configure HTTP proxying for clients.
|
||||
|
||||
See documentation on [`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`](../environment_variables.md#http_proxy-https_proxy-all_proxy)
|
||||
and [`NO_PROXY`](../environment_variables.md#no_proxy) for more information.
|
||||
133
docs/api.md
@ -8,40 +8,45 @@
|
||||
enable HTTP/2 and connection pooling for more efficient and
|
||||
long-lived connections.
|
||||
|
||||
* `get(url, [params], [headers], [cookies], [auth], [stream], [allow_redirects], [verify], [cert], [timeout], [proxies])`
|
||||
* `options(url, [params], [headers], [cookies], [auth], [stream], [allow_redirects], [verify], [cert], [timeout], [proxies])`
|
||||
* `head(url, [params], [headers], [cookies], [auth], [stream], [allow_redirects], [verify], [cert], [timeout], [proxies])`
|
||||
* `post(url, [data], [json], [params], [headers], [cookies], [auth], [stream], [allow_redirects], [verify], [cert], [timeout], [proxies])`
|
||||
* `put(url, [data], [json], [params], [headers], [cookies], [auth], [stream], [allow_redirects], [verify], [cert], [timeout], [proxies])`
|
||||
* `patch(url, [data], [json], [params], [headers], [cookies], [auth], [stream], [allow_redirects], [verify], [cert], [timeout], [proxies])`
|
||||
* `delete(url, [data], [json], [params], [headers], [cookies], [auth], [stream], [allow_redirects], [verify], [cert], [timeout], [proxies])`
|
||||
* `request(method, url, [data], [params], [headers], [cookies], [auth], [stream], [allow_redirects], [verify], [cert], [timeout], [proxies])`
|
||||
* `build_request(method, url, [data], [files], [json], [params], [headers], [cookies])`
|
||||
::: httpx.request
|
||||
:docstring:
|
||||
|
||||
::: httpx.get
|
||||
:docstring:
|
||||
|
||||
::: httpx.options
|
||||
:docstring:
|
||||
|
||||
::: httpx.head
|
||||
:docstring:
|
||||
|
||||
::: httpx.post
|
||||
:docstring:
|
||||
|
||||
::: httpx.put
|
||||
:docstring:
|
||||
|
||||
::: httpx.patch
|
||||
:docstring:
|
||||
|
||||
::: httpx.delete
|
||||
:docstring:
|
||||
|
||||
::: httpx.stream
|
||||
:docstring:
|
||||
|
||||
## `Client`
|
||||
|
||||
*An HTTP client, with connection pooling, HTTP/2, redirects, cookie persistence, etc.*
|
||||
::: httpx.Client
|
||||
:docstring:
|
||||
:members: headers cookies params auth request get head options post put patch delete stream build_request send close
|
||||
|
||||
```python
|
||||
>>> client = httpx.Client()
|
||||
>>> response = client.get('https://example.org')
|
||||
```
|
||||
## `AsyncClient`
|
||||
|
||||
::: httpx.AsyncClient
|
||||
:docstring:
|
||||
:members: headers cookies params auth request get head options post put patch delete stream build_request send aclose
|
||||
|
||||
* `def __init__([auth], [params], [headers], [cookies], [verify], [cert], [timeout], [pool_limits], [max_redirects], [app], [dispatch])`
|
||||
* `.params` - **QueryParams**
|
||||
* `.headers` - **Headers**
|
||||
* `.cookies` - **Cookies**
|
||||
* `def .get(url, [params], [headers], [cookies], [auth], [stream], [allow_redirects], [verify], [cert], [timeout], [proxies])`
|
||||
* `def .options(url, [params], [headers], [cookies], [auth], [stream], [allow_redirects], [verify], [cert], [timeout], [proxies])`
|
||||
* `def .head(url, [params], [headers], [cookies], [auth], [stream], [allow_redirects], [verify], [cert], [timeout], [proxies])`
|
||||
* `def .post(url, [data], [json], [params], [headers], [cookies], [auth], [stream], [allow_redirects], [verify], [cert], [timeout], [proxies])`
|
||||
* `def .put(url, [data], [json], [params], [headers], [cookies], [auth], [stream], [allow_redirects], [verify], [cert], [timeout], [proxies])`
|
||||
* `def .patch(url, [data], [json], [params], [headers], [cookies], [auth], [stream], [allow_redirects], [verify], [cert], [timeout], [proxies])`
|
||||
* `def .delete(url, [data], [json], [params], [headers], [cookies], [auth], [stream], [allow_redirects], [verify], [cert], [timeout], [proxies])`
|
||||
* `def .request(method, url, [data], [params], [headers], [cookies], [auth], [stream], [allow_redirects], [verify], [cert], [timeout], [proxies])`
|
||||
* `def .build_request(method, url, [data], [files], [json], [params], [headers], [cookies])`
|
||||
* `def .send(request, [stream], [allow_redirects], [verify], [cert], [timeout], [proxies])`
|
||||
* `def .close()`
|
||||
|
||||
## `Response`
|
||||
|
||||
@ -58,35 +63,44 @@
|
||||
* `.encoding` - **str**
|
||||
* `.is_redirect` - **bool**
|
||||
* `.request` - **Request**
|
||||
* `.next_request` - **Optional[Request]**
|
||||
* `.cookies` - **Cookies**
|
||||
* `.history` - **List[Response]**
|
||||
* `.elapsed` - **[timedelta](https://docs.python.org/3/library/datetime.html)**
|
||||
* The amount of time elapsed between sending the first byte and parsing the headers (not including time spent reading
|
||||
the response). Use
|
||||
* The amount of time elapsed between sending the request and calling `close()` on the corresponding response received for that request.
|
||||
[total_seconds()](https://docs.python.org/3/library/datetime.html#datetime.timedelta.total_seconds) to correctly get
|
||||
the total elapsed seconds.
|
||||
* `def .raise_for_status()` - **None**
|
||||
* `def .raise_for_status()` - **Response**
|
||||
* `def .json()` - **Any**
|
||||
* `def .read()` - **bytes**
|
||||
* `def .stream()` - **bytes iterator**
|
||||
* `def .raw()` - **bytes iterator**
|
||||
* `def .iter_raw([chunk_size])` - **bytes iterator**
|
||||
* `def .iter_bytes([chunk_size])` - **bytes iterator**
|
||||
* `def .iter_text([chunk_size])` - **text iterator**
|
||||
* `def .iter_lines()` - **text iterator**
|
||||
* `def .close()` - **None**
|
||||
* `def .next()` - **Response**
|
||||
* `def .aread()` - **bytes**
|
||||
* `def .aiter_raw([chunk_size])` - **async bytes iterator**
|
||||
* `def .aiter_bytes([chunk_size])` - **async bytes iterator**
|
||||
* `def .aiter_text([chunk_size])` - **async text iterator**
|
||||
* `def .aiter_lines()` - **async text iterator**
|
||||
* `def .aclose()` - **None**
|
||||
* `def .anext()` - **Response**
|
||||
|
||||
## `Request`
|
||||
|
||||
*An HTTP request. Can be constructed explicitly for more control over exactly
|
||||
what gets sent over the wire.*
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> request = httpx.Request("GET", "https://example.org", headers={'host': 'example.org'})
|
||||
>>> response = client.send(request)
|
||||
```
|
||||
|
||||
* `def __init__(method, url, [params], [data], [json], [headers], [cookies])`
|
||||
* `def __init__(method, url, [params], [headers], [cookies], [content], [data], [files], [json], [stream])`
|
||||
* `.method` - **str**
|
||||
* `.url` - **URL**
|
||||
* `.content` - **byte** or **byte async iterator**
|
||||
* `.content` - **byte**, **byte iterator**, or **byte async iterator**
|
||||
* `.headers` - **Headers**
|
||||
* `.cookies` - **Cookies**
|
||||
|
||||
@ -94,60 +108,44 @@ what gets sent over the wire.*
|
||||
|
||||
*A normalized, IDNA supporting URL.*
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> url = URL("https://example.org/")
|
||||
>>> url.host
|
||||
'example.org'
|
||||
```
|
||||
|
||||
* `def __init__(url, allow_relative=False, params=None)`
|
||||
* `def __init__(url, **kwargs)`
|
||||
* `.scheme` - **str**
|
||||
* `.authority` - **str**
|
||||
* `.host` - **str**
|
||||
* `.port` - **int**
|
||||
* `.path` - **str**
|
||||
* `.query` - **str**
|
||||
* `.full_path` - **str**
|
||||
* `.raw_path` - **str**
|
||||
* `.fragment` - **str**
|
||||
* `.is_ssl` - **bool**
|
||||
* `.origin` - **Origin**
|
||||
* `.is_absolute_url` - **bool**
|
||||
* `.is_relative_url` - **bool**
|
||||
* `def .copy_with([scheme], [authority], [path], [query], [fragment])` - **URL**
|
||||
* `def .resolve_with(url)` - **URL**
|
||||
|
||||
## `Origin`
|
||||
|
||||
*A normalized, IDNA supporting set of scheme/host/port info.*
|
||||
|
||||
```python
|
||||
>>> Origin('https://example.org') == Origin('HTTPS://EXAMPLE.ORG:443')
|
||||
True
|
||||
```
|
||||
|
||||
* `def __init__(url)`
|
||||
* `.scheme` - **str**
|
||||
* `.is_ssl` - **bool**
|
||||
* `.host` - **str**
|
||||
* `.port` - **int**
|
||||
|
||||
## `Headers`
|
||||
|
||||
*A case-insensitive multi-dict.*
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> headers = Headers({'Content-Type': 'application/json'})
|
||||
>>> headers['content-type']
|
||||
'application/json'
|
||||
```
|
||||
|
||||
* `def __init__(self, headers)`
|
||||
* `def __init__(self, headers, encoding=None)`
|
||||
* `def copy()` - **Headers**
|
||||
|
||||
## `Cookies`
|
||||
|
||||
*A dict-like cookie store.*
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> cookies = Cookies()
|
||||
>>> cookies.set("name", "value", domain="example.org")
|
||||
```
|
||||
@ -161,3 +159,18 @@ True
|
||||
* `def delete(name, [domain], [path])`
|
||||
* `def clear([domain], [path])`
|
||||
* *Standard mutable mapping interface*
|
||||
|
||||
## `Proxy`
|
||||
|
||||
*A configuration of the proxy server.*
|
||||
|
||||
```pycon
|
||||
>>> proxy = Proxy("http://proxy.example.com:8030")
|
||||
>>> client = Client(proxy=proxy)
|
||||
```
|
||||
|
||||
* `def __init__(url, [ssl_context], [auth], [headers])`
|
||||
* `.url` - **URL**
|
||||
* `.auth` - **tuple[str, str]**
|
||||
* `.headers` - **Headers**
|
||||
* `.ssl_context` - **SSLContext**
|
||||
|
||||
177
docs/async.md
@ -1,4 +1,4 @@
|
||||
# Async Client
|
||||
# Async Support
|
||||
|
||||
HTTPX offers a standard synchronous API by default, but also gives you
|
||||
the option of an async client if you need it.
|
||||
@ -14,67 +14,131 @@ async client for sending outgoing HTTP requests.
|
||||
|
||||
To make asynchronous requests, you'll need an `AsyncClient`.
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> async with httpx.AsyncClient() as client:
|
||||
>>> r = await client.get('https://www.example.com/')
|
||||
... r = await client.get('https://www.example.com/')
|
||||
...
|
||||
>>> r
|
||||
<Response [200 OK]>
|
||||
```
|
||||
|
||||
!!! tip
|
||||
Use [IPython](https://ipython.readthedocs.io/en/stable/) to try this code interactively, as it supports executing `async`/`await` expressions in the console.
|
||||
Use [IPython](https://ipython.readthedocs.io/en/stable/) or Python 3.9+ with `python -m asyncio` to try this code interactively, as they support executing `async`/`await` expressions in the console.
|
||||
|
||||
!!! note
|
||||
The `async with` syntax ensures that all active connections are closed on exit.
|
||||
## API Differences
|
||||
|
||||
It is safe to access response content (e.g. `r.text`) both inside and outside the `async with` block, unless you are using response streaming. In that case, you should `.read()`, `.stream()`, or `.close()` the response *inside* the `async with` block.
|
||||
If you're using an async client then there are a few bits of API that
|
||||
use async methods.
|
||||
|
||||
## API Differences
|
||||
### Making requests
|
||||
|
||||
If you're using streaming responses then there are a few bits of API that
|
||||
use async methods:
|
||||
The request methods are all async, so you should use `response = await client.get(...)` style for all of the following:
|
||||
|
||||
* `AsyncClient.get(url, ...)`
|
||||
* `AsyncClient.options(url, ...)`
|
||||
* `AsyncClient.head(url, ...)`
|
||||
* `AsyncClient.post(url, ...)`
|
||||
* `AsyncClient.put(url, ...)`
|
||||
* `AsyncClient.patch(url, ...)`
|
||||
* `AsyncClient.delete(url, ...)`
|
||||
* `AsyncClient.request(method, url, ...)`
|
||||
* `AsyncClient.send(request, ...)`
|
||||
|
||||
### Opening and closing clients
|
||||
|
||||
Use `async with httpx.AsyncClient()` if you want a context-managed client...
|
||||
|
||||
```python
|
||||
>>> async with httpx.AsyncClient() as client:
|
||||
>>> r = await client.get('https://www.example.com/', stream=True)
|
||||
>>> async for chunk in r.stream():
|
||||
>>> ...
|
||||
async with httpx.AsyncClient() as client:
|
||||
...
|
||||
```
|
||||
|
||||
The async response methods are:
|
||||
!!! warning
|
||||
In order to get the most benefit from connection pooling, make sure you're not instantiating multiple client instances - for example by using `async with` inside a "hot loop". This can be achieved either by having a single scoped client that's passed throughout wherever it's needed, or by having a single global client instance.
|
||||
|
||||
* `.read()`
|
||||
* `.stream()`
|
||||
* `.raw()`
|
||||
* `.close()`
|
||||
|
||||
If you're making [parallel requests](/parallel/), then you'll also need to use an async API:
|
||||
Alternatively, use `await client.aclose()` if you want to close a client explicitly:
|
||||
|
||||
```python
|
||||
>>> async with httpx.AsyncClient() as client:
|
||||
>>> async with client.parallel() as parallel:
|
||||
>>> pending_one = parallel.get('https://example.com/1')
|
||||
>>> pending_two = parallel.get('https://example.com/2')
|
||||
>>> response_one = await pending_one.get_response()
|
||||
>>> response_two = await pending_two.get_response()
|
||||
client = httpx.AsyncClient()
|
||||
...
|
||||
await client.aclose()
|
||||
```
|
||||
|
||||
The async parallel methods are:
|
||||
### Streaming responses
|
||||
|
||||
* `.parallel()` *Used as an "async with" context manager.*
|
||||
* `.get_response()`
|
||||
* `.next_response()`
|
||||
The `AsyncClient.stream(method, url, ...)` method is an async context block.
|
||||
|
||||
## Supported async libraries
|
||||
```pycon
|
||||
>>> client = httpx.AsyncClient()
|
||||
>>> async with client.stream('GET', 'https://www.example.com/') as response:
|
||||
... async for chunk in response.aiter_bytes():
|
||||
... ...
|
||||
```
|
||||
|
||||
You can use `AsyncClient` with any of the following async libraries.
|
||||
The async response streaming methods are:
|
||||
|
||||
!!! tip
|
||||
You will typically be using `AsyncClient` in async programs that run on `asyncio`. If that's the case, or if you're not sure what this is all about, you can safely ignore this section.
|
||||
* `Response.aread()` - For conditionally reading a response inside a stream block.
|
||||
* `Response.aiter_bytes()` - For streaming the response content as bytes.
|
||||
* `Response.aiter_text()` - For streaming the response content as text.
|
||||
* `Response.aiter_lines()` - For streaming the response content as lines of text.
|
||||
* `Response.aiter_raw()` - For streaming the raw response bytes, without applying content decoding.
|
||||
* `Response.aclose()` - For closing the response. You don't usually need this, since `.stream` block closes the response automatically on exit.
|
||||
|
||||
### [asyncio](https://docs.python.org/3/library/asyncio.html) (Default)
|
||||
For situations when context block usage is not practical, it is possible to enter "manual mode" by sending a [`Request` instance](advanced/clients.md#request-instances) using `client.send(..., stream=True)`.
|
||||
|
||||
By default, `AsyncClient` uses `asyncio` to perform asynchronous operations and I/O calls.
|
||||
Example in the context of forwarding the response to a streaming web endpoint with [Starlette](https://www.starlette.io):
|
||||
|
||||
```python
|
||||
import httpx
|
||||
from starlette.background import BackgroundTask
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
client = httpx.AsyncClient()
|
||||
|
||||
async def home(request):
|
||||
req = client.build_request("GET", "https://www.example.com/")
|
||||
r = await client.send(req, stream=True)
|
||||
return StreamingResponse(r.aiter_text(), background=BackgroundTask(r.aclose))
|
||||
```
|
||||
|
||||
!!! warning
|
||||
When using this "manual streaming mode", it is your duty as a developer to make sure that `Response.aclose()` is called eventually. Failing to do so would leave connections open, most likely resulting in resource leaks down the line.
|
||||
|
||||
### Streaming requests
|
||||
|
||||
When sending a streaming request body with an `AsyncClient` instance, you should use an async bytes generator instead of a bytes generator:
|
||||
|
||||
```python
|
||||
async def upload_bytes():
|
||||
... # yield byte content
|
||||
|
||||
await client.post(url, content=upload_bytes())
|
||||
```
|
||||
|
||||
### Explicit transport instances
|
||||
|
||||
When instantiating a transport instance directly, you need to use `httpx.AsyncHTTPTransport`.
|
||||
|
||||
For instance:
|
||||
|
||||
```pycon
|
||||
>>> import httpx
|
||||
>>> transport = httpx.AsyncHTTPTransport(retries=1)
|
||||
>>> async with httpx.AsyncClient(transport=transport) as client:
|
||||
>>> ...
|
||||
```
|
||||
|
||||
## Supported async environments
|
||||
|
||||
HTTPX supports either `asyncio` or `trio` as an async environment.
|
||||
|
||||
It will auto-detect which of those two to use as the backend
|
||||
for socket operations and concurrency primitives.
|
||||
|
||||
### [AsyncIO](https://docs.python.org/3/library/asyncio.html)
|
||||
|
||||
AsyncIO is Python's [built-in library](https://docs.python.org/3/library/asyncio.html)
|
||||
for writing concurrent code with the async/await syntax.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
@ -82,26 +146,49 @@ import httpx
|
||||
|
||||
async def main():
|
||||
async with httpx.AsyncClient() as client:
|
||||
...
|
||||
response = await client.get('https://www.example.com/')
|
||||
print(response)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### [trio](https://github.com/python-trio/trio)
|
||||
### [Trio](https://github.com/python-trio/trio)
|
||||
|
||||
To make asynchronous requests in `trio` programs, pass a `TrioBackend` to the `AsyncClient`:
|
||||
Trio is [an alternative async library](https://trio.readthedocs.io/en/stable/),
|
||||
designed around the [the principles of structured concurrency](https://en.wikipedia.org/wiki/Structured_concurrency).
|
||||
|
||||
```python
|
||||
import trio
|
||||
import httpx
|
||||
from httpx.concurrency.trio import TrioBackend
|
||||
import trio
|
||||
|
||||
async def main():
|
||||
async with httpx.AsyncClient(backend=TrioBackend()) as client:
|
||||
...
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get('https://www.example.com/')
|
||||
print(response)
|
||||
|
||||
trio.run(main)
|
||||
```
|
||||
|
||||
!!! important
|
||||
`trio` must be installed to import and use the `TrioBackend`.
|
||||
The `trio` package must be installed to use the Trio backend.
|
||||
|
||||
|
||||
### [AnyIO](https://github.com/agronholm/anyio)
|
||||
|
||||
AnyIO is an [asynchronous networking and concurrency library](https://anyio.readthedocs.io/) that works on top of either `asyncio` or `trio`. It blends in with native libraries of your chosen backend (defaults to `asyncio`).
|
||||
|
||||
```python
|
||||
import httpx
|
||||
import anyio
|
||||
|
||||
async def main():
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get('https://www.example.com/')
|
||||
print(response)
|
||||
|
||||
anyio.run(main, backend='trio')
|
||||
```
|
||||
|
||||
## Calling into Python Web Apps
|
||||
|
||||
For details on calling directly into ASGI applications, see [the `ASGITransport` docs](../advanced/transports#asgitransport).
|
||||
56
docs/code_of_conduct.md
Normal file
@ -0,0 +1,56 @@
|
||||
# Code of Conduct
|
||||
|
||||
We expect contributors to our projects and online spaces to follow [the Python Software Foundation’s Code of Conduct](https://www.python.org/psf/conduct/).
|
||||
|
||||
The Python community is made up of members from around the globe with a diverse set of skills, personalities, and experiences. It is through these differences that our community experiences great successes and continued growth. When you're working with members of the community, this Code of Conduct will help steer your interactions and keep Python a positive, successful, and growing community.
|
||||
|
||||
## Our Community
|
||||
|
||||
Members of the Python community are **open, considerate, and respectful**. Behaviours that reinforce these values contribute to a positive environment, and include:
|
||||
|
||||
* **Being open.** Members of the community are open to collaboration, whether it's on PEPs, patches, problems, or otherwise.
|
||||
* **Focusing on what is best for the community.** We're respectful of the processes set forth in the community, and we work within them.
|
||||
* **Acknowledging time and effort.** We're respectful of the volunteer efforts that permeate the Python community. We're thoughtful when addressing the efforts of others, keeping in mind that often times the labor was completed simply for the good of the community.
|
||||
* **Being respectful of differing viewpoints and experiences.** We're receptive to constructive comments and criticism, as the experiences and skill sets of other members contribute to the whole of our efforts.
|
||||
* **Showing empathy towards other community members.** We're attentive in our communications, whether in person or online, and we're tactful when approaching differing views.
|
||||
* **Being considerate.** Members of the community are considerate of their peers -- other Python users.
|
||||
* **Being respectful.** We're respectful of others, their positions, their skills, their commitments, and their efforts.
|
||||
* **Gracefully accepting constructive criticism.** When we disagree, we are courteous in raising our issues.
|
||||
* **Using welcoming and inclusive language.** We're accepting of all who wish to take part in our activities, fostering an environment where anyone can participate and everyone can make a difference.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Every member of our community has the right to have their identity respected. The Python community is dedicated to providing a positive experience for everyone, regardless of age, gender identity and expression, sexual orientation, disability, physical appearance, body size, ethnicity, nationality, race, or religion (or lack thereof), education, or socio-economic status.
|
||||
|
||||
## Inappropriate Behavior
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* Harassment of any participants in any form
|
||||
* Deliberate intimidation, stalking, or following
|
||||
* Logging or taking screenshots of online activity for harassment purposes
|
||||
* Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
* Violent threats or language directed against another person
|
||||
* Incitement of violence or harassment towards any individual, including encouraging a person to commit suicide or to engage in self-harm
|
||||
* Creating additional online accounts in order to harass another person or circumvent a ban
|
||||
* Sexual language and imagery in online communities or in any conference venue, including talks
|
||||
* Insults, put downs, or jokes that are based upon stereotypes, that are exclusionary, or that hold others up for ridicule
|
||||
* Excessive swearing
|
||||
* Unwelcome sexual attention or advances
|
||||
* Unwelcome physical contact, including simulated physical contact (eg, textual descriptions like "hug" or "backrub") without consent or after a request to stop
|
||||
* Pattern of inappropriate social contact, such as requesting/assuming inappropriate levels of intimacy with others
|
||||
* Sustained disruption of online community discussions, in-person presentations, or other in-person events
|
||||
* Continued one-on-one communication after requests to cease
|
||||
* Other conduct that is inappropriate for a professional audience including people of many different backgrounds
|
||||
|
||||
Community members asked to stop any inappropriate behavior are expected to comply immediately.
|
||||
|
||||
## Enforcement
|
||||
|
||||
We take Code of Conduct violations seriously, and will act to ensure our spaces are welcoming, inclusive, and professional environments to communicate in.
|
||||
|
||||
If you need to raise a Code of Conduct report, you may do so privately by email to tom@tomchristie.com.
|
||||
|
||||
Reports will be treated confidentially.
|
||||
|
||||
Alternately you may [make a report to the Python Software Foundation](https://www.python.org/psf/conduct/reporting/).
|
||||
@ -1,20 +1,232 @@
|
||||
# Requests Compatibility Guide
|
||||
|
||||
HTTPX aims to be compatible with the `requests` API wherever possible.
|
||||
HTTPX aims to be broadly compatible with the `requests` API, although there are a
|
||||
few design differences in places.
|
||||
|
||||
This documentation outlines places where the API differs...
|
||||
|
||||
## QuickStart
|
||||
## Redirects
|
||||
|
||||
Pretty much all the API mentioned in the `requests` QuickStart should be identical
|
||||
to the API in our own documentation. The following exceptions apply:
|
||||
Unlike `requests`, HTTPX does **not follow redirects by default**.
|
||||
|
||||
* `Response.url` - Returns a `URL` instance, rather than a string. Use `str(response.url)` if you need a string instance.
|
||||
* `httpx.codes` - In our documentation we prefer the uppercased versions, such as `codes.NOT_FOUND`,
|
||||
but also provide lower-cased versions for API compatibility with `requests`.
|
||||
* `stream=True`. - Streaming responses provide the `.stream()` and `.raw()` byte iterator interfaces, rather than the `.iter_content()` method and the `.raw` socket interface.
|
||||
We differ in behaviour here [because auto-redirects can easily mask unnecessary network
|
||||
calls being made](https://github.com/encode/httpx/discussions/1785).
|
||||
|
||||
## Advanced Usage
|
||||
You can still enable behaviour to automatically follow redirects, but you need to
|
||||
do so explicitly...
|
||||
|
||||
!!! warning
|
||||
TODO
|
||||
```python
|
||||
response = client.get(url, follow_redirects=True)
|
||||
```
|
||||
|
||||
Or else instantiate a client, with redirect following enabled by default...
|
||||
|
||||
```python
|
||||
client = httpx.Client(follow_redirects=True)
|
||||
```
|
||||
|
||||
## Client instances
|
||||
|
||||
The HTTPX equivalent of `requests.Session` is `httpx.Client`.
|
||||
|
||||
```python
|
||||
session = requests.Session(**kwargs)
|
||||
```
|
||||
|
||||
is generally equivalent to
|
||||
|
||||
```python
|
||||
client = httpx.Client(**kwargs)
|
||||
```
|
||||
|
||||
## Request URLs
|
||||
|
||||
Accessing `response.url` will return a `URL` instance, rather than a string.
|
||||
|
||||
Use `str(response.url)` if you need a string instance.
|
||||
|
||||
## Determining the next redirect request
|
||||
|
||||
The `requests` library exposes an attribute `response.next`, which can be used to obtain the next redirect request.
|
||||
|
||||
```python
|
||||
session = requests.Session()
|
||||
request = requests.Request("GET", ...).prepare()
|
||||
while request is not None:
|
||||
response = session.send(request, allow_redirects=False)
|
||||
request = response.next
|
||||
```
|
||||
|
||||
In HTTPX, this attribute is instead named `response.next_request`. For example:
|
||||
|
||||
```python
|
||||
client = httpx.Client()
|
||||
request = client.build_request("GET", ...)
|
||||
while request is not None:
|
||||
response = client.send(request)
|
||||
request = response.next_request
|
||||
```
|
||||
|
||||
## Request Content
|
||||
|
||||
For uploading raw text or binary content we prefer to use a `content` parameter,
|
||||
in order to better separate this usage from the case of uploading form data.
|
||||
|
||||
For example, using `content=...` to upload raw content:
|
||||
|
||||
```python
|
||||
# Uploading text, bytes, or a bytes iterator.
|
||||
httpx.post(..., content=b"Hello, world")
|
||||
```
|
||||
|
||||
And using `data=...` to send form data:
|
||||
|
||||
```python
|
||||
# Uploading form data.
|
||||
httpx.post(..., data={"message": "Hello, world"})
|
||||
```
|
||||
|
||||
Using the `data=<text/byte content>` will raise a deprecation warning,
|
||||
and is expected to be fully removed with the HTTPX 1.0 release.
|
||||
|
||||
## Upload files
|
||||
|
||||
HTTPX strictly enforces that upload files must be opened in binary mode, in order
|
||||
to avoid character encoding issues that can result from attempting to upload files
|
||||
opened in text mode.
|
||||
|
||||
## Content encoding
|
||||
|
||||
HTTPX uses `utf-8` for encoding `str` request bodies. For example, when using `content=<str>` the request body will be encoded to `utf-8` before being sent over the wire. This differs from Requests which uses `latin1`. If you need an explicit encoding, pass encoded bytes explicitly, e.g. `content=<str>.encode("latin1")`.
|
||||
For response bodies, assuming the server didn't send an explicit encoding then HTTPX will do its best to figure out an appropriate encoding. HTTPX makes a guess at the encoding to use for decoding the response using `charset_normalizer`. Fallback to that or any content with less than 32 octets will be decoded using `utf-8` with the `error="replace"` decoder strategy.
|
||||
|
||||
## Cookies
|
||||
|
||||
If using a client instance, then cookies should always be set on the client rather than on a per-request basis.
|
||||
|
||||
This usage is supported:
|
||||
|
||||
```python
|
||||
client = httpx.Client(cookies=...)
|
||||
client.post(...)
|
||||
```
|
||||
|
||||
This usage is **not** supported:
|
||||
|
||||
```python
|
||||
client = httpx.Client()
|
||||
client.post(..., cookies=...)
|
||||
```
|
||||
|
||||
We prefer enforcing a stricter API here because it provides clearer expectations around cookie persistence, particularly when redirects occur.
|
||||
|
||||
## Status Codes
|
||||
|
||||
In our documentation we prefer the uppercased versions, such as `codes.NOT_FOUND`, but also provide lower-cased versions for API compatibility with `requests`.
|
||||
|
||||
Requests includes various synonyms for status codes that HTTPX does not support.
|
||||
|
||||
## Streaming responses
|
||||
|
||||
HTTPX provides a `.stream()` interface rather than using `stream=True`. This ensures that streaming responses are always properly closed outside of the stream block, and makes it visually clearer at which points streaming I/O APIs may be used with a response.
|
||||
|
||||
For example:
|
||||
|
||||
```python
|
||||
with httpx.stream("GET", "https://www.example.com") as response:
|
||||
...
|
||||
```
|
||||
|
||||
Within a `stream()` block request data is made available with:
|
||||
|
||||
* `.iter_bytes()` - Instead of `response.iter_content()`
|
||||
* `.iter_text()` - Instead of `response.iter_content(decode_unicode=True)`
|
||||
* `.iter_lines()` - Corresponding to `response.iter_lines()`
|
||||
* `.iter_raw()` - Use this instead of `response.raw`
|
||||
* `.read()` - Read the entire response body, making `response.text` and `response.content` available.
|
||||
|
||||
## Timeouts
|
||||
|
||||
HTTPX defaults to including reasonable [timeouts](quickstart.md#timeouts) for all network operations, while Requests has no timeouts by default.
|
||||
|
||||
To get the same behavior as Requests, set the `timeout` parameter to `None`:
|
||||
|
||||
```python
|
||||
httpx.get('https://www.example.com', timeout=None)
|
||||
```
|
||||
|
||||
## Proxy keys
|
||||
|
||||
HTTPX uses the mounts argument for HTTP proxying and transport routing.
|
||||
It can do much more than proxies and allows you to configure more than just the proxy route.
|
||||
For more detailed documentation, see [Mounting Transports](advanced/transports.md#mounting-transports).
|
||||
|
||||
When using `httpx.Client(mounts={...})` to map to a selection of different transports, we use full URL schemes, such as `mounts={"http://": ..., "https://": ...}`.
|
||||
|
||||
This is different to the `requests` usage of `proxies={"http": ..., "https": ...}`.
|
||||
|
||||
This change is for better consistency with more complex mappings, that might also include domain names, such as `mounts={"all://": ..., httpx.HTTPTransport(proxy="all://www.example.com": None})` which maps all requests onto a proxy, except for requests to "www.example.com" which have an explicit exclusion.
|
||||
|
||||
Also note that `requests.Session.request(...)` allows a `proxies=...` parameter, whereas `httpx.Client.request(...)` does not allow `mounts=...`.
|
||||
|
||||
## SSL configuration
|
||||
|
||||
When using a `Client` instance, the ssl configurations should always be passed on client instantiation, rather than passed to the request method.
|
||||
|
||||
If you need more than one different SSL configuration, you should use different client instances for each SSL configuration.
|
||||
|
||||
## Request body on HTTP methods
|
||||
|
||||
The HTTP `GET`, `DELETE`, `HEAD`, and `OPTIONS` methods are specified as not supporting a request body. To stay in line with this, the `.get`, `.delete`, `.head` and `.options` functions do not support `content`, `files`, `data`, or `json` arguments.
|
||||
|
||||
If you really do need to send request data using these http methods you should use the generic `.request` function instead.
|
||||
|
||||
```python
|
||||
httpx.request(
|
||||
method="DELETE",
|
||||
url="https://www.example.com/",
|
||||
content=b'A request body on a DELETE request.'
|
||||
)
|
||||
```
|
||||
|
||||
## Checking for success and failure responses
|
||||
|
||||
We don't support `response.is_ok` since the naming is ambiguous there, and might incorrectly imply an equivalence to `response.status_code == codes.OK`. Instead we provide the `response.is_success` property, which can be used to check for a 2xx response.
|
||||
|
||||
## Request instantiation
|
||||
|
||||
There is no notion of [prepared requests](https://requests.readthedocs.io/en/stable/user/advanced/#prepared-requests) in HTTPX. If you need to customize request instantiation, see [Request instances](advanced/clients.md#request-instances).
|
||||
|
||||
Besides, `httpx.Request()` does not support the `auth`, `timeout`, `follow_redirects`, `mounts`, `verify` and `cert` parameters. However these are available in `httpx.request`, `httpx.get`, `httpx.post` etc., as well as on [`Client` instances](advanced/clients.md#client-instances).
|
||||
|
||||
## Mocking
|
||||
|
||||
If you need to mock HTTPX the same way that test utilities like `responses` and `requests-mock` does for `requests`, see [RESPX](https://github.com/lundberg/respx).
|
||||
|
||||
## Caching
|
||||
|
||||
If you use `cachecontrol` or `requests-cache` to add HTTP Caching support to the `requests` library, you can use [Hishel](https://hishel.com) for HTTPX.
|
||||
|
||||
## Networking layer
|
||||
|
||||
`requests` defers most of its HTTP networking code to the excellent [`urllib3` library](https://urllib3.readthedocs.io/en/latest/).
|
||||
|
||||
On the other hand, HTTPX uses [HTTPCore](https://github.com/encode/httpcore) as its core HTTP networking layer, which is a different project than `urllib3`.
|
||||
|
||||
## Query Parameters
|
||||
|
||||
`requests` omits `params` whose values are `None` (e.g. `requests.get(..., params={"foo": None})`). This is not supported by HTTPX.
|
||||
|
||||
For both query params (`params=`) and form data (`data=`), `requests` supports sending a list of tuples (e.g. `requests.get(..., params=[('key1', 'value1'), ('key1', 'value2')])`). This is not supported by HTTPX. Instead, use a dictionary with lists as values. E.g.: `httpx.get(..., params={'key1': ['value1', 'value2']})` or with form data: `httpx.post(..., data={'key1': ['value1', 'value2']})`.
|
||||
|
||||
## Event Hooks
|
||||
|
||||
`requests` allows event hooks to mutate `Request` and `Response` objects. See [examples](https://requests.readthedocs.io/en/master/user/advanced/#event-hooks) given in the documentation for `requests`.
|
||||
|
||||
In HTTPX, event hooks may access properties of requests and responses, but event hook callbacks cannot mutate the original request/response.
|
||||
|
||||
If you are looking for more control, consider checking out [Custom Transports](advanced/transports.md#custom-transports).
|
||||
|
||||
## Exceptions and Errors
|
||||
|
||||
`requests` exception hierarchy is slightly different to the `httpx` exception hierarchy. `requests` exposes a top level `RequestException`, where as `httpx` exposes a top level `HTTPError`. see the exceptions exposes in requests [here](https://requests.readthedocs.io/en/latest/_modules/requests/exceptions/). See the `httpx` error hierarchy [here](https://www.python-httpx.org/exceptions/).
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# Contributing
|
||||
|
||||
Thank you for being interested in contributing with HTTPX.
|
||||
There are many ways you can contribute with the project:
|
||||
Thank you for being interested in contributing to HTTPX.
|
||||
There are many ways you can contribute to the project:
|
||||
|
||||
- Try HTTPX and [report bugs/issues you find](https://github.com/encode/httpx/issues/new)
|
||||
- [Implement new features](https://github.com/encode/httpx/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)
|
||||
@ -12,10 +12,13 @@ There are many ways you can contribute with the project:
|
||||
## Reporting Bugs or Other Issues
|
||||
|
||||
Found something that HTTPX should support?
|
||||
Stumbled upon some unexpected behavior?
|
||||
Stumbled upon some unexpected behaviour?
|
||||
|
||||
Contributions should generally start out with [a discussion](https://github.com/encode/httpx/discussions).
|
||||
Possible bugs may be raised as a "Potential Issue" discussion, feature requests may
|
||||
be raised as an "Ideas" discussion. We can then determine if the discussion needs
|
||||
to be escalated into an "Issue" or not, or if we'd consider a pull request.
|
||||
|
||||
Feel free to open an issue at the
|
||||
[issue tracker](https://github.com/encode/httpx/issues).
|
||||
Try to be more descriptive as you can and in case of a bug report,
|
||||
provide as much information as possible like:
|
||||
|
||||
@ -25,6 +28,15 @@ provide as much information as possible like:
|
||||
- Code snippet
|
||||
- Error traceback
|
||||
|
||||
You should always try to reduce any examples to the *simplest possible case*
|
||||
that demonstrates the issue.
|
||||
|
||||
Some possibly useful tips for narrowing down potential issues...
|
||||
|
||||
- Does the issue exist on HTTP/1.1, or HTTP/2, or both?
|
||||
- Does the issue exist with `Client`, `AsyncClient`, or both?
|
||||
- When using `AsyncClient` does the issue exist when using `asyncio` or `trio`, or both?
|
||||
|
||||
## Development
|
||||
|
||||
To start developing HTTPX create a **fork** of the
|
||||
@ -37,84 +49,107 @@ your GitHub username:
|
||||
$ git clone https://github.com/YOUR-USERNAME/httpx
|
||||
```
|
||||
|
||||
With the repository cloned you can access its folder, set up the
|
||||
virtual environment, install the project requirements,
|
||||
and then install HTTPX on edit mode:
|
||||
You can now install the project and its dependencies using:
|
||||
|
||||
```shell
|
||||
$ cd httpx
|
||||
$ python3 -m venv venv
|
||||
$ source venv/bin/activate
|
||||
$ pip install -r test-requirements.txt
|
||||
$ pip install -e .
|
||||
$ scripts/install
|
||||
```
|
||||
|
||||
!!! note
|
||||
Feel free to replace this step with your development environment setup
|
||||
(pyenv, pipenv, virtualenvwrapper, docker, etc).
|
||||
|
||||
## Testing and Linting
|
||||
|
||||
We use [nox](https://nox.thea.codes/en/stable/) to automate testing, linting,
|
||||
and documentation building workflow. Make sure you have it installed
|
||||
at your system before starting.
|
||||
We use custom shell scripts to automate testing, linting,
|
||||
and documentation building workflow.
|
||||
|
||||
Install nox with:
|
||||
To run the tests, use:
|
||||
|
||||
```shell
|
||||
$ python3 -m pip install --user nox
|
||||
```
|
||||
|
||||
Alternatively, use [pipx](https://github.com/pipxproject/pipx) if you prefer
|
||||
to keep it into an isolated environment:
|
||||
|
||||
```shell
|
||||
$ pipx install nox
|
||||
```
|
||||
|
||||
Now, with nox installed run the complete pipeline with:
|
||||
|
||||
```shell
|
||||
$ nox
|
||||
$ scripts/test
|
||||
```
|
||||
|
||||
!!! warning
|
||||
The test suite spawns a testing server at the port **8000**.
|
||||
Make sure this isn't being used, so the tests can run properly.
|
||||
The test suite spawns testing servers on ports **8000** and **8001**.
|
||||
Make sure these are not in use, so the tests can run properly.
|
||||
|
||||
To run the code auto-formatting separately:
|
||||
Any additional arguments will be passed to `pytest`. See the [pytest documentation](https://docs.pytest.org/en/latest/how-to/usage.html) for more information.
|
||||
|
||||
For example, to run a single test script:
|
||||
|
||||
```shell
|
||||
$ nox -s lint
|
||||
$ scripts/test tests/test_multipart.py
|
||||
```
|
||||
|
||||
Also, if you need to run the tests only:
|
||||
To run the code auto-formatting:
|
||||
|
||||
```shell
|
||||
$ nox -s test
|
||||
$ scripts/lint
|
||||
```
|
||||
|
||||
You can also run a single test script like this:
|
||||
Lastly, to run code checks separately (they are also run as part of `scripts/test`), run:
|
||||
|
||||
```shell
|
||||
$ nox -s test -- tests/test_multipart.py
|
||||
$ scripts/check
|
||||
```
|
||||
|
||||
## Documenting
|
||||
|
||||
To work with the documentation, make sure you have `mkdocs` and
|
||||
`mkdocs-material` installed on your environment:
|
||||
Documentation pages are located under the `docs/` folder.
|
||||
|
||||
To run the documentation site locally (useful for previewing changes), use:
|
||||
|
||||
```shell
|
||||
$ pip install mkdocs mkdocs-material
|
||||
$ scripts/docs
|
||||
```
|
||||
|
||||
To spawn the docs server run:
|
||||
## Resolving Build / CI Failures
|
||||
|
||||
Once you've submitted your pull request, the test suite will automatically run, and the results will show up in GitHub.
|
||||
If the test suite fails, you'll want to click through to the "Details" link, and try to identify why the test suite failed.
|
||||
|
||||
<p align="center" style="margin: 0 0 10px">
|
||||
<img src="https://raw.githubusercontent.com/encode/httpx/master/docs/img/gh-actions-fail.png" alt='Failing PR commit status'>
|
||||
</p>
|
||||
|
||||
Here are some common ways the test suite can fail:
|
||||
|
||||
### Check Job Failed
|
||||
|
||||
<p align="center" style="margin: 0 0 10px">
|
||||
<img src="https://raw.githubusercontent.com/encode/httpx/master/docs/img/gh-actions-fail-check.png" alt='Failing GitHub action lint job'>
|
||||
</p>
|
||||
|
||||
This job failing means there is either a code formatting issue or type-annotation issue.
|
||||
You can look at the job output to figure out why it's failed or within a shell run:
|
||||
|
||||
```shell
|
||||
$ mkdocs serve
|
||||
$ scripts/check
|
||||
```
|
||||
|
||||
It may be worth it to run `$ scripts/lint` to attempt auto-formatting the code
|
||||
and if that job succeeds commit the changes.
|
||||
|
||||
### Docs Job Failed
|
||||
|
||||
This job failing means the documentation failed to build. This can happen for
|
||||
a variety of reasons like invalid markdown or missing configuration within `mkdocs.yml`.
|
||||
|
||||
### Python 3.X Job Failed
|
||||
|
||||
<p align="center" style="margin: 0 0 10px">
|
||||
<img src="https://raw.githubusercontent.com/encode/httpx/master/docs/img/gh-actions-fail-test.png" alt='Failing GitHub action test job'>
|
||||
</p>
|
||||
|
||||
This job failing means the unit tests failed or not all code paths are covered by unit tests.
|
||||
|
||||
If tests are failing you will see this message under the coverage report:
|
||||
|
||||
`=== 1 failed, 435 passed, 1 skipped, 1 xfailed in 11.09s ===`
|
||||
|
||||
If tests succeed but coverage doesn't reach our current threshold, you will see this
|
||||
message under the coverage report:
|
||||
|
||||
`FAIL Required test coverage of 100% not reached. Total coverage: 99.00%`
|
||||
|
||||
## Releasing
|
||||
|
||||
*This section is targeted at HTTPX maintainers.*
|
||||
@ -130,6 +165,68 @@ Before releasing a new version, create a pull request that includes:
|
||||
- Keep it concise and to-the-point. 🎯
|
||||
- **A version bump**: see `__version__.py`.
|
||||
|
||||
For an example, see [#362](https://github.com/encode/httpx/pull/362).
|
||||
For an example, see [#1006](https://github.com/encode/httpx/pull/1006).
|
||||
|
||||
Once the release PR is merged, run `$ scripts/publish` to publish the new release to PyPI.
|
||||
Once the release PR is merged, create a
|
||||
[new release](https://github.com/encode/httpx/releases/new) including:
|
||||
|
||||
- Tag version like `0.13.3`.
|
||||
- Release title `Version 0.13.3`
|
||||
- Description copied from the changelog.
|
||||
|
||||
Once created this release will be automatically uploaded to PyPI.
|
||||
|
||||
If something goes wrong with the PyPI job the release can be published using the
|
||||
`scripts/publish` script.
|
||||
|
||||
## Development proxy setup
|
||||
|
||||
To test and debug requests via a proxy it's best to run a proxy server locally.
|
||||
Any server should do but HTTPCore's test suite uses
|
||||
[`mitmproxy`](https://mitmproxy.org/) which is written in Python, it's fully
|
||||
featured and has excellent UI and tools for introspection of requests.
|
||||
|
||||
You can install `mitmproxy` using `pip install mitmproxy` or [several
|
||||
other ways](https://docs.mitmproxy.org/stable/overview-installation/).
|
||||
|
||||
`mitmproxy` does require setting up local TLS certificates for HTTPS requests,
|
||||
as its main purpose is to allow developers to inspect requests that pass through
|
||||
it. We can set them up follows:
|
||||
|
||||
1. [`pip install trustme-cli`](https://github.com/sethmlarson/trustme-cli/).
|
||||
2. `trustme-cli -i example.org www.example.org`, assuming you want to test
|
||||
connecting to that domain, this will create three files: `server.pem`,
|
||||
`server.key` and `client.pem`.
|
||||
3. `mitmproxy` requires a PEM file that includes the private key and the
|
||||
certificate so we need to concatenate them:
|
||||
`cat server.key server.pem > server.withkey.pem`.
|
||||
4. Start the proxy server `mitmproxy --certs server.withkey.pem`, or use the
|
||||
[other mitmproxy commands](https://docs.mitmproxy.org/stable/) with different
|
||||
UI options.
|
||||
|
||||
At this point the server is ready to start serving requests, you'll need to
|
||||
configure HTTPX as described in the
|
||||
[proxy section](https://www.python-httpx.org/advanced/proxies/#http-proxies) and
|
||||
the [SSL certificates section](https://www.python-httpx.org/advanced/ssl/),
|
||||
this is where our previously generated `client.pem` comes in:
|
||||
|
||||
```python
|
||||
ctx = ssl.create_default_context(cafile="/path/to/client.pem")
|
||||
client = httpx.Client(proxy="http://127.0.0.1:8080/", verify=ctx)
|
||||
```
|
||||
|
||||
Note, however, that HTTPS requests will only succeed to the host specified
|
||||
in the SSL/TLS certificate we generated, HTTPS requests to other hosts will
|
||||
raise an error like:
|
||||
|
||||
```
|
||||
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate
|
||||
verify failed: Hostname mismatch, certificate is not valid for
|
||||
'duckduckgo.com'. (_ssl.c:1108)
|
||||
```
|
||||
|
||||
If you want to make requests to more hosts you'll need to regenerate the
|
||||
certificates and include all the hosts you intend to connect to in the
|
||||
seconds step, i.e.
|
||||
|
||||
`trustme-cli -i example.org www.example.org duckduckgo.com www.duckduckgo.com`
|
||||
|
||||
10
docs/css/custom.css
Normal file
@ -0,0 +1,10 @@
|
||||
div.autodoc-docstring {
|
||||
padding-left: 20px;
|
||||
margin-bottom: 30px;
|
||||
border-left: 5px solid rgba(230, 230, 230);
|
||||
}
|
||||
|
||||
div.autodoc-members {
|
||||
padding-left: 20px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
@ -1,97 +1,62 @@
|
||||
Environment Variables
|
||||
=====================
|
||||
# Environment Variables
|
||||
|
||||
The HTTPX library can be configured via environment variables.
|
||||
Environment variables are used by default. To ignore environment variables, `trust_env` has to be set `False`.
|
||||
There are two ways to set `trust_env` to disable environment variables:
|
||||
Environment variables are used by default. To ignore environment variables, `trust_env` has to be set `False`. There are two ways to set `trust_env` to disable environment variables:
|
||||
|
||||
* On the client via `httpx.Client(trust_env=False)`
|
||||
* Per request via `client.get("<url>", trust_env=False)`
|
||||
* On the client via `httpx.Client(trust_env=False)`.
|
||||
* Using the top-level API, such as `httpx.get("<url>", trust_env=False)`.
|
||||
|
||||
Here is a list of environment variables that HTTPX recognizes
|
||||
and what function they serve:
|
||||
Here is a list of environment variables that HTTPX recognizes and what function they serve:
|
||||
|
||||
`HTTPX_DEBUG`
|
||||
-----------
|
||||
## Proxies
|
||||
|
||||
Valid values: `1`, `true`
|
||||
The environment variables documented below are used as a convention by various HTTP tooling, including:
|
||||
|
||||
If this environment variable is set to a valid value then low-level
|
||||
details about the execution of HTTP requests will be logged to `stderr`.
|
||||
* [cURL](https://github.com/curl/curl/blob/master/docs/MANUAL.md#environment-variables)
|
||||
* [requests](https://github.com/psf/requests/blob/master/docs/user/advanced.rst#proxies)
|
||||
|
||||
This can help you debug issues and see what's exactly being sent
|
||||
over the wire and to which location.
|
||||
For more information on using proxies in HTTPX, see [HTTP Proxying](advanced/proxies.md#http-proxying).
|
||||
|
||||
Example:
|
||||
### `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`
|
||||
|
||||
```python
|
||||
# test_script.py
|
||||
Valid values: A URL to a proxy
|
||||
|
||||
`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` set the proxy to be used for `http`, `https`, or all requests respectively.
|
||||
|
||||
```bash
|
||||
export HTTP_PROXY=http://my-external-proxy.com:1234
|
||||
|
||||
# This request will be sent through the proxy
|
||||
python -c "import httpx; httpx.get('http://example.com')"
|
||||
|
||||
# This request will be sent directly, as we set `trust_env=False`
|
||||
python -c "import httpx; httpx.get('http://example.com', trust_env=False)"
|
||||
|
||||
import httpx
|
||||
client = httpx.Client()
|
||||
client.get("https://google.com")
|
||||
```
|
||||
|
||||
```console
|
||||
user@host:~$ HTTPX_DEBUG=1 python test_script.py
|
||||
20:54:17.585 - httpx.dispatch.connection_pool - acquire_connection origin=Origin(scheme='https' host='www.google.com' port=443)
|
||||
20:54:17.585 - httpx.dispatch.connection_pool - new_connection connection=HTTPConnection(origin=Origin(scheme='https' host='www.google.com' port=443))
|
||||
20:54:17.590 - httpx.dispatch.connection - start_connect host='www.google.com' port=443 timeout=TimeoutConfig(timeout=5.0)
|
||||
20:54:17.651 - httpx.dispatch.connection - connected http_version='HTTP/2'
|
||||
20:54:17.651 - httpx.dispatch.http2 - send_headers stream_id=1 headers=[(b':method', b'GET'), (b':authority', b'www.google.com'), ...]
|
||||
20:54:17.652 - httpx.dispatch.http2 - end_stream stream_id=1
|
||||
20:54:17.681 - httpx.dispatch.http2 - receive_event stream_id=0 event=<RemoteSettingsChanged changed_settings:{...}>
|
||||
20:54:17.681 - httpx.dispatch.http2 - receive_event stream_id=0 event=<WindowUpdated stream_id:0, delta:983041>
|
||||
20:54:17.682 - httpx.dispatch.http2 - receive_event stream_id=0 event=<SettingsAcknowledged changed_settings:{}>
|
||||
20:54:17.739 - httpx.dispatch.http2 - receive_event stream_id=1 event=<ResponseReceived stream_id:1, headers:[(b':status', b'200'), ...]>
|
||||
20:54:17.741 - httpx.dispatch.http2 - receive_event stream_id=1 event=<DataReceived stream_id:1, flow_controlled_length:5224 data:>
|
||||
20:54:17.742 - httpx.dispatch.http2 - receive_event stream_id=1 event=<DataReceived stream_id:1, flow_controlled_length:59, data:>
|
||||
20:54:17.742 - httpx.dispatch.http2 - receive_event stream_id=1 event=<StreamEnded stream_id:1>
|
||||
20:54:17.742 - httpx.dispatch.http2 - receive_event stream_id=0 event=<PingReceived ping_data:0000000000000000>
|
||||
20:54:17.743 - httpx.dispatch.connection_pool - release_connection connection=HTTPConnection(origin=Origin(scheme='https' host='www.google.com' port=443))
|
||||
### `NO_PROXY`
|
||||
|
||||
Valid values: a comma-separated list of hostnames/urls
|
||||
|
||||
`NO_PROXY` disables the proxy for specific urls
|
||||
|
||||
```bash
|
||||
export HTTP_PROXY=http://my-external-proxy.com:1234
|
||||
export NO_PROXY=http://127.0.0.1,python-httpx.org
|
||||
|
||||
# As in the previous example, this request will be sent through the proxy
|
||||
python -c "import httpx; httpx.get('http://example.com')"
|
||||
|
||||
# These requests will be sent directly, bypassing the proxy
|
||||
python -c "import httpx; httpx.get('http://127.0.0.1:5000/my-api')"
|
||||
python -c "import httpx; httpx.get('https://www.python-httpx.org')"
|
||||
```
|
||||
|
||||
`SSLKEYLOGFILE`
|
||||
-----------
|
||||
## `SSL_CERT_FILE`
|
||||
|
||||
Valid values: a filename
|
||||
|
||||
If this environment variable is set, TLS keys will be appended to the specified file, creating it if it doesn't exist, whenever key material is generated or received. The keylog file is designed for debugging purposes only.
|
||||
|
||||
Support for `SSLKEYLOGFILE` requires Python 3.8 and OpenSSL 1.1.1 or newer.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
# test_script.py
|
||||
|
||||
import httpx
|
||||
client = httpx.Client()
|
||||
client.get("https://google.com")
|
||||
```
|
||||
|
||||
```console
|
||||
SSLKEYLOGFILE=test.log python test_script.py
|
||||
cat test.log
|
||||
# TLS secrets log file, generated by OpenSSL / Python
|
||||
SERVER_HANDSHAKE_TRAFFIC_SECRET XXXX
|
||||
EXPORTER_SECRET XXXX
|
||||
SERVER_TRAFFIC_SECRET_0 XXXX
|
||||
CLIENT_HANDSHAKE_TRAFFIC_SECRET XXXX
|
||||
CLIENT_TRAFFIC_SECRET_0 XXXX
|
||||
SERVER_HANDSHAKE_TRAFFIC_SECRET XXXX
|
||||
EXPORTER_SECRET XXXX
|
||||
SERVER_TRAFFIC_SECRET_0 XXXX
|
||||
CLIENT_HANDSHAKE_TRAFFIC_SECRET XXXX
|
||||
CLIENT_TRAFFIC_SECRET_0 XXXX
|
||||
```
|
||||
|
||||
`SSL_CERT_FILE`
|
||||
-----------
|
||||
|
||||
Valid values: a filename
|
||||
|
||||
if this environment variable is set then HTTPX will load
|
||||
If this environment variable is set then HTTPX will load
|
||||
CA certificate from the specified file instead of the default
|
||||
location.
|
||||
|
||||
@ -101,31 +66,14 @@ Example:
|
||||
SSL_CERT_FILE=/path/to/ca-certs/ca-bundle.crt python -c "import httpx; httpx.get('https://example.com')"
|
||||
```
|
||||
|
||||
`SSL_CERT_DIR`
|
||||
-----------
|
||||
## `SSL_CERT_DIR`
|
||||
|
||||
Valid values: a directory
|
||||
Valid values: a directory following an [OpenSSL specific layout](https://www.openssl.org/docs/manmaster/man3/SSL_CTX_load_verify_locations.html).
|
||||
|
||||
if this environment variable is set then HTTPX will load
|
||||
CA certificates from the specified location instead of the default
|
||||
location.
|
||||
If this environment variable is set and the directory follows an [OpenSSL specific layout](https://www.openssl.org/docs/manmaster/man3/SSL_CTX_load_verify_locations.html) (ie. you ran `c_rehash`) then HTTPX will load CA certificates from this directory instead of the default location.
|
||||
|
||||
Example:
|
||||
|
||||
```console
|
||||
SSL_CERT_DIR=/path/to/ca-certs/ python -c "import httpx; httpx.get('https://example.com')"
|
||||
```
|
||||
|
||||
`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`
|
||||
----------------------------------------
|
||||
|
||||
Valid values: A URL to a proxy
|
||||
|
||||
Sets the proxy to be used for `http`, `https`, or all requests respectively.
|
||||
|
||||
```bash
|
||||
export HTTP_PROXY=http://127.0.0.1:3080
|
||||
|
||||
# This request will be sent through the proxy
|
||||
python -c "import httpx; httpx.get('http://example.com')"
|
||||
```
|
||||
|
||||
124
docs/exceptions.md
Normal file
@ -0,0 +1,124 @@
|
||||
# Exceptions
|
||||
|
||||
This page lists exceptions that may be raised when using HTTPX.
|
||||
|
||||
For an overview of how to work with HTTPX exceptions, see [Exceptions (Quickstart)](quickstart.md#exceptions).
|
||||
|
||||
## The exception hierarchy
|
||||
|
||||
* HTTPError
|
||||
* RequestError
|
||||
* TransportError
|
||||
* TimeoutException
|
||||
* ConnectTimeout
|
||||
* ReadTimeout
|
||||
* WriteTimeout
|
||||
* PoolTimeout
|
||||
* NetworkError
|
||||
* ConnectError
|
||||
* ReadError
|
||||
* WriteError
|
||||
* CloseError
|
||||
* ProtocolError
|
||||
* LocalProtocolError
|
||||
* RemoteProtocolError
|
||||
* ProxyError
|
||||
* UnsupportedProtocol
|
||||
* DecodingError
|
||||
* TooManyRedirects
|
||||
* HTTPStatusError
|
||||
* InvalidURL
|
||||
* CookieConflict
|
||||
* StreamError
|
||||
* StreamConsumed
|
||||
* ResponseNotRead
|
||||
* RequestNotRead
|
||||
* StreamClosed
|
||||
|
||||
---
|
||||
|
||||
## Exception classes
|
||||
|
||||
::: httpx.HTTPError
|
||||
:docstring:
|
||||
|
||||
::: httpx.RequestError
|
||||
:docstring:
|
||||
|
||||
::: httpx.TransportError
|
||||
:docstring:
|
||||
|
||||
::: httpx.TimeoutException
|
||||
:docstring:
|
||||
|
||||
::: httpx.ConnectTimeout
|
||||
:docstring:
|
||||
|
||||
::: httpx.ReadTimeout
|
||||
:docstring:
|
||||
|
||||
::: httpx.WriteTimeout
|
||||
:docstring:
|
||||
|
||||
::: httpx.PoolTimeout
|
||||
:docstring:
|
||||
|
||||
::: httpx.NetworkError
|
||||
:docstring:
|
||||
|
||||
::: httpx.ConnectError
|
||||
:docstring:
|
||||
|
||||
::: httpx.ReadError
|
||||
:docstring:
|
||||
|
||||
::: httpx.WriteError
|
||||
:docstring:
|
||||
|
||||
::: httpx.CloseError
|
||||
:docstring:
|
||||
|
||||
::: httpx.ProtocolError
|
||||
:docstring:
|
||||
|
||||
::: httpx.LocalProtocolError
|
||||
:docstring:
|
||||
|
||||
::: httpx.RemoteProtocolError
|
||||
:docstring:
|
||||
|
||||
::: httpx.ProxyError
|
||||
:docstring:
|
||||
|
||||
::: httpx.UnsupportedProtocol
|
||||
:docstring:
|
||||
|
||||
::: httpx.DecodingError
|
||||
:docstring:
|
||||
|
||||
::: httpx.TooManyRedirects
|
||||
:docstring:
|
||||
|
||||
::: httpx.HTTPStatusError
|
||||
:docstring:
|
||||
|
||||
::: httpx.InvalidURL
|
||||
:docstring:
|
||||
|
||||
::: httpx.CookieConflict
|
||||
:docstring:
|
||||
|
||||
::: httpx.StreamError
|
||||
:docstring:
|
||||
|
||||
::: httpx.StreamConsumed
|
||||
:docstring:
|
||||
|
||||
::: httpx.StreamClosed
|
||||
:docstring:
|
||||
|
||||
::: httpx.ResponseNotRead
|
||||
:docstring:
|
||||
|
||||
::: httpx.RequestNotRead
|
||||
:docstring:
|
||||
68
docs/http2.md
Normal file
@ -0,0 +1,68 @@
|
||||
# HTTP/2
|
||||
|
||||
HTTP/2 is a major new iteration of the HTTP protocol, that provides a far more
|
||||
efficient transport, with potential performance benefits. HTTP/2 does not change
|
||||
the core semantics of the request or response, but alters the way that data is
|
||||
sent to and from the server.
|
||||
|
||||
Rather than the text format that HTTP/1.1 uses, HTTP/2 is a binary format.
|
||||
The binary format provides full request and response multiplexing, and efficient
|
||||
compression of HTTP headers. The stream multiplexing means that where HTTP/1.1
|
||||
requires one TCP stream for each concurrent request, HTTP/2 allows a single TCP
|
||||
stream to handle multiple concurrent requests.
|
||||
|
||||
HTTP/2 also provides support for functionality such as response prioritization,
|
||||
and server push.
|
||||
|
||||
For a comprehensive guide to HTTP/2 you may want to check out "[http2 explained](https://http2-explained.haxx.se/)".
|
||||
|
||||
## Enabling HTTP/2
|
||||
|
||||
When using the `httpx` client, HTTP/2 support is not enabled by default, because
|
||||
HTTP/1.1 is a mature, battle-hardened transport layer, and our HTTP/1.1
|
||||
implementation may be considered the more robust option at this point in time.
|
||||
It is possible that a future version of `httpx` may enable HTTP/2 support by default.
|
||||
|
||||
If you're issuing highly concurrent requests you might want to consider
|
||||
trying out our HTTP/2 support. You can do so by first making sure to install
|
||||
the optional HTTP/2 dependencies...
|
||||
|
||||
```shell
|
||||
$ pip install httpx[http2]
|
||||
```
|
||||
|
||||
And then instantiating a client with HTTP/2 support enabled:
|
||||
|
||||
```python
|
||||
client = httpx.AsyncClient(http2=True)
|
||||
...
|
||||
```
|
||||
|
||||
You can also instantiate a client as a context manager, to ensure that all
|
||||
HTTP connections are nicely scoped, and will be closed once the context block
|
||||
is exited.
|
||||
|
||||
```python
|
||||
async with httpx.AsyncClient(http2=True) as client:
|
||||
...
|
||||
```
|
||||
|
||||
HTTP/2 support is available on both `Client` and `AsyncClient`, although it's
|
||||
typically more useful in async contexts if you're issuing lots of concurrent
|
||||
requests.
|
||||
|
||||
## Inspecting the HTTP version
|
||||
|
||||
Enabling HTTP/2 support on the client does not *necessarily* mean that your
|
||||
requests and responses will be transported over HTTP/2, since both the client
|
||||
*and* the server need to support HTTP/2. If you connect to a server that only
|
||||
supports HTTP/1.1 the client will use a standard HTTP/1.1 connection instead.
|
||||
|
||||
You can determine which version of the HTTP protocol was used by examining
|
||||
the `.http_version` property on the response.
|
||||
|
||||
```python
|
||||
client = httpx.AsyncClient(http2=True)
|
||||
response = await client.get(...)
|
||||
print(response.http_version) # "HTTP/1.0", "HTTP/1.1", or "HTTP/2".
|
||||
```
|
||||
BIN
docs/img/butterfly.png
Normal file
|
After Width: | Height: | Size: 668 KiB |
BIN
docs/img/gh-actions-fail-check.png
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
docs/img/gh-actions-fail-test.png
Normal file
|
After Width: | Height: | Size: 416 KiB |
BIN
docs/img/gh-actions-fail.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
docs/img/httpx-help.png
Normal file
|
After Width: | Height: | Size: 356 KiB |
BIN
docs/img/httpx-request.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
docs/img/rich-progress.gif
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/img/speakeasy.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
docs/img/tqdm-progress.gif
Normal file
|
After Width: | Height: | Size: 84 KiB |
@ -1,5 +1,5 @@
|
||||
<p align="center" style="margin: 0 0 10px">
|
||||
<img width="350" height="208" src="https://raw.githubusercontent.com/encode/httpx/master/docs/img/logo.jpg" alt='HTTPX'>
|
||||
<img width="350" height="208" src="https://raw.githubusercontent.com/encode/httpx/master/docs/img/butterfly.png" alt='HTTPX'>
|
||||
</p>
|
||||
|
||||
<h1 align="center" style="font-size: 3rem; margin: -15px 0">
|
||||
@ -10,11 +10,8 @@ HTTPX
|
||||
|
||||
<div align="center">
|
||||
<p>
|
||||
<a href="https://travis-ci.org/encode/httpx">
|
||||
<img src="https://travis-ci.org/encode/httpx.svg?branch=master" alt="Build Status">
|
||||
</a>
|
||||
<a href="https://codecov.io/gh/encode/httpx">
|
||||
<img src="https://codecov.io/gh/encode/httpx/branch/master/graph/badge.svg" alt="Coverage">
|
||||
<a href="https://github.com/encode/httpx/actions">
|
||||
<img src="https://github.com/encode/httpx/workflows/Test%20Suite/badge.svg" alt="Test Suite">
|
||||
</a>
|
||||
<a href="https://pypi.org/project/httpx/">
|
||||
<img src="https://badge.fury.io/py/httpx.svg" alt="Package version">
|
||||
@ -24,38 +21,54 @@ HTTPX
|
||||
<em>A next-generation HTTP client for Python.</em>
|
||||
</div>
|
||||
|
||||
!!! warning
|
||||
This project should be considered as an "alpha" release. It is substantially
|
||||
API complete, but there are still some areas that need more work.
|
||||
HTTPX is a fully featured HTTP client for Python 3, which provides sync and async APIs, and support for both HTTP/1.1 and HTTP/2.
|
||||
|
||||
---
|
||||
|
||||
Let's get started...
|
||||
Install HTTPX using pip:
|
||||
|
||||
```python
|
||||
```shell
|
||||
$ pip install httpx
|
||||
```
|
||||
|
||||
Now, let's get started:
|
||||
|
||||
```pycon
|
||||
>>> import httpx
|
||||
>>> r = httpx.get('https://www.example.org/')
|
||||
>>> r
|
||||
<Response [200 OK]>
|
||||
>>> r.status_code
|
||||
200
|
||||
>>> r.http_version
|
||||
'HTTP/1.1'
|
||||
>>> r.headers['content-type']
|
||||
'text/html; charset=UTF-8'
|
||||
>>> r.text
|
||||
'<!doctype html>\n<html>\n<head>\n<title>Example Domain</title>...'
|
||||
```
|
||||
|
||||
Or, using the command-line client.
|
||||
|
||||
```shell
|
||||
# The command line client is an optional dependency.
|
||||
$ pip install 'httpx[cli]'
|
||||
```
|
||||
|
||||
Which now allows us to use HTTPX directly from the command-line...
|
||||
|
||||

|
||||
|
||||
Sending a request...
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
HTTPX builds on the well-established usability of `requests`, and gives you:
|
||||
|
||||
* A requests-compatible API.
|
||||
* HTTP/2 and HTTP/1.1 support.
|
||||
* Support for [issuing HTTP requests in parallel](parallel.md). *(Coming soon)*
|
||||
* Standard synchronous interface, but [with `async`/`await` support if you need it](async.md).
|
||||
* Ability to [make requests directly to WSGI or ASGI applications](advanced.md#calling-into-python-web-apps).
|
||||
* A broadly [requests-compatible API](compatibility.md).
|
||||
* Standard synchronous interface, but with [async support if you need it](async.md).
|
||||
* HTTP/1.1 [and HTTP/2 support](http2.md).
|
||||
* Ability to make requests directly to [WSGI applications](advanced/transports.md#wsgi-transport) or [ASGI applications](advanced/transports.md#asgi-transport).
|
||||
* Strict timeouts everywhere.
|
||||
* Fully type annotated.
|
||||
* 100% test coverage.
|
||||
@ -66,13 +79,13 @@ Plus all the standard features of `requests`...
|
||||
* Keep-Alive & Connection Pooling
|
||||
* Sessions with Cookie Persistence
|
||||
* Browser-style SSL Verification
|
||||
* Basic/Digest Authentication *(Digest is still TODO)*
|
||||
* Basic/Digest Authentication
|
||||
* Elegant Key/Value Cookies
|
||||
* Automatic Decompression
|
||||
* Automatic Content Decoding
|
||||
* Unicode Response Bodies
|
||||
* Multipart File Uploads
|
||||
* HTTP(S) Proxy Support *(TODO)*
|
||||
* HTTP(S) Proxy Support
|
||||
* Connection Timeouts
|
||||
* Streaming Downloads
|
||||
* .netrc Support
|
||||
@ -82,28 +95,35 @@ Plus all the standard features of `requests`...
|
||||
|
||||
For a run-through of all the basics, head over to the [QuickStart](quickstart.md).
|
||||
|
||||
For more advanced topics, see the [Advanced Usage](advanced.md) section, or
|
||||
the specific topics on making [Parallel Requests](parallel.md) or using the
|
||||
[Async Client](async.md).
|
||||
For more advanced topics, see the **Advanced** section,
|
||||
the [async support](async.md) section, or the [HTTP/2](http2.md) section.
|
||||
|
||||
The [Developer Interface](api.md) provides a comprehensive API reference.
|
||||
|
||||
To find out about tools that integrate with HTTPX, see [Third Party Packages](third_party_packages.md).
|
||||
|
||||
## Dependencies
|
||||
|
||||
The HTTPX project relies on these excellent libraries:
|
||||
|
||||
* `h2` - HTTP/2 support.
|
||||
* `h11` - HTTP/1.1 support.
|
||||
* `httpcore` - The underlying transport implementation for `httpx`.
|
||||
* `h11` - HTTP/1.1 support.
|
||||
* `certifi` - SSL certificates.
|
||||
* `chardet` - Fallback auto-detection for response encoding.
|
||||
* `hstspreload` - determines whether IDNA-encoded host should be only accessed via HTTPS.
|
||||
* `idna` - Internationalized domain name support.
|
||||
* `rfc3986` - URL parsing & normalization.
|
||||
* `brotlipy` - Decoding for "brotli" compressed responses. *(Optional)*
|
||||
* `sniffio` - Async library autodetection.
|
||||
|
||||
As well as these optional installs:
|
||||
|
||||
* `h2` - HTTP/2 support. *(Optional, with `httpx[http2]`)*
|
||||
* `socksio` - SOCKS proxy support. *(Optional, with `httpx[socks]`)*
|
||||
* `rich` - Rich terminal support. *(Optional, with `httpx[cli]`)*
|
||||
* `click` - Command line client support. *(Optional, with `httpx[cli]`)*
|
||||
* `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx[brotli]`)*
|
||||
* `zstandard` - Decoding for "zstd" compressed responses. *(Optional, with `httpx[zstd]`)*
|
||||
|
||||
A huge amount of credit is due to `requests` for the API layout that
|
||||
much of this work follows, as well as to `urllib3` for plenty of design
|
||||
inspiration around the lower level networking details.
|
||||
inspiration around the lower-level networking details.
|
||||
|
||||
## Installation
|
||||
|
||||
@ -113,4 +133,18 @@ Install with pip:
|
||||
$ pip install httpx
|
||||
```
|
||||
|
||||
HTTPX requires Python 3.6+
|
||||
Or, to include the optional HTTP/2 support, use:
|
||||
|
||||
```shell
|
||||
$ pip install httpx[http2]
|
||||
```
|
||||
|
||||
To include the optional brotli and zstandard decoders support, use:
|
||||
|
||||
```shell
|
||||
$ pip install httpx[brotli,zstd]
|
||||
```
|
||||
|
||||
HTTPX requires Python 3.9+
|
||||
|
||||
[sync-support]: https://github.com/encode/httpx/issues/572
|
||||
|
||||
81
docs/logging.md
Normal file
@ -0,0 +1,81 @@
|
||||
# Logging
|
||||
|
||||
If you need to inspect the internal behaviour of `httpx`, you can use Python's standard logging to output information about the underlying network behaviour.
|
||||
|
||||
For example, the following configuration...
|
||||
|
||||
```python
|
||||
import logging
|
||||
import httpx
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(levelname)s [%(asctime)s] %(name)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
|
||||
httpx.get("https://www.example.com")
|
||||
```
|
||||
|
||||
Will send debug level output to the console, or wherever `stdout` is directed too...
|
||||
|
||||
```
|
||||
DEBUG [2024-09-28 17:27:40] httpcore.connection - connect_tcp.started host='www.example.com' port=443 local_address=None timeout=5.0 socket_options=None
|
||||
DEBUG [2024-09-28 17:27:41] httpcore.connection - connect_tcp.complete return_value=<httpcore._backends.sync.SyncStream object at 0x101f1e8e0>
|
||||
DEBUG [2024-09-28 17:27:41] httpcore.connection - start_tls.started ssl_context=SSLContext(verify=True) server_hostname='www.example.com' timeout=5.0
|
||||
DEBUG [2024-09-28 17:27:41] httpcore.connection - start_tls.complete return_value=<httpcore._backends.sync.SyncStream object at 0x1020f49a0>
|
||||
DEBUG [2024-09-28 17:27:41] httpcore.http11 - send_request_headers.started request=<Request [b'GET']>
|
||||
DEBUG [2024-09-28 17:27:41] httpcore.http11 - send_request_headers.complete
|
||||
DEBUG [2024-09-28 17:27:41] httpcore.http11 - send_request_body.started request=<Request [b'GET']>
|
||||
DEBUG [2024-09-28 17:27:41] httpcore.http11 - send_request_body.complete
|
||||
DEBUG [2024-09-28 17:27:41] httpcore.http11 - receive_response_headers.started request=<Request [b'GET']>
|
||||
DEBUG [2024-09-28 17:27:41] httpcore.http11 - receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'OK', [(b'Content-Encoding', b'gzip'), (b'Accept-Ranges', b'bytes'), (b'Age', b'407727'), (b'Cache-Control', b'max-age=604800'), (b'Content-Type', b'text/html; charset=UTF-8'), (b'Date', b'Sat, 28 Sep 2024 13:27:42 GMT'), (b'Etag', b'"3147526947+gzip"'), (b'Expires', b'Sat, 05 Oct 2024 13:27:42 GMT'), (b'Last-Modified', b'Thu, 17 Oct 2019 07:18:26 GMT'), (b'Server', b'ECAcc (dcd/7D43)'), (b'Vary', b'Accept-Encoding'), (b'X-Cache', b'HIT'), (b'Content-Length', b'648')])
|
||||
INFO [2024-09-28 17:27:41] httpx - HTTP Request: GET https://www.example.com "HTTP/1.1 200 OK"
|
||||
DEBUG [2024-09-28 17:27:41] httpcore.http11 - receive_response_body.started request=<Request [b'GET']>
|
||||
DEBUG [2024-09-28 17:27:41] httpcore.http11 - receive_response_body.complete
|
||||
DEBUG [2024-09-28 17:27:41] httpcore.http11 - response_closed.started
|
||||
DEBUG [2024-09-28 17:27:41] httpcore.http11 - response_closed.complete
|
||||
DEBUG [2024-09-28 17:27:41] httpcore.connection - close.started
|
||||
DEBUG [2024-09-28 17:27:41] httpcore.connection - close.complete
|
||||
```
|
||||
|
||||
Logging output includes information from both the high-level `httpx` logger, and the network-level `httpcore` logger, which can be configured separately.
|
||||
|
||||
For handling more complex logging configurations you might want to use the dictionary configuration style...
|
||||
|
||||
```python
|
||||
import logging.config
|
||||
import httpx
|
||||
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"handlers": {
|
||||
"default": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "http",
|
||||
"stream": "ext://sys.stderr"
|
||||
}
|
||||
},
|
||||
"formatters": {
|
||||
"http": {
|
||||
"format": "%(levelname)s [%(asctime)s] %(name)s - %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
}
|
||||
},
|
||||
'loggers': {
|
||||
'httpx': {
|
||||
'handlers': ['default'],
|
||||
'level': 'DEBUG',
|
||||
},
|
||||
'httpcore': {
|
||||
'handlers': ['default'],
|
||||
'level': 'DEBUG',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
logging.config.dictConfig(LOGGING_CONFIG)
|
||||
httpx.get('https://www.example.com')
|
||||
```
|
||||
|
||||
The exact formatting of the debug logging may be subject to change across different versions of `httpx` and `httpcore`. If you need to rely on a particular format it is recommended that you pin installation of these packages to fixed versions.
|
||||
54
docs/overrides/partials/nav.html
Normal file
@ -0,0 +1,54 @@
|
||||
{% import "partials/nav-item.html" as item with context %}
|
||||
|
||||
<!-- Determine class according to configuration -->
|
||||
{% set class = "md-nav md-nav--primary" %}
|
||||
{% if "navigation.tabs" in features %}
|
||||
{% set class = class ~ " md-nav--lifted" %}
|
||||
{% endif %}
|
||||
{% if "toc.integrate" in features %}
|
||||
{% set class = class ~ " md-nav--integrated" %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Main navigation -->
|
||||
<nav
|
||||
class="{{ class }}"
|
||||
aria-label="{{ lang.t('nav.title') }}"
|
||||
data-md-level="0"
|
||||
>
|
||||
|
||||
<!-- Site title -->
|
||||
<label class="md-nav__title" for="__drawer">
|
||||
<a
|
||||
href="{{ config.extra.homepage | d(nav.homepage.url, true) | url }}"
|
||||
title="{{ config.site_name | e }}"
|
||||
class="md-nav__button md-logo"
|
||||
aria-label="{{ config.site_name }}"
|
||||
data-md-component="logo"
|
||||
>
|
||||
{% include "partials/logo.html" %}
|
||||
</a>
|
||||
{{ config.site_name }}
|
||||
</label>
|
||||
|
||||
<!-- Repository information -->
|
||||
{% if config.repo_url %}
|
||||
<div class="md-nav__source">
|
||||
{% include "partials/source.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Navigation list -->
|
||||
<ul class="md-nav__list" data-md-scrollfix>
|
||||
{% for nav_item in nav %}
|
||||
{% set path = "__nav_" ~ loop.index %}
|
||||
{{ item.render(nav_item, path, 1) }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<ul class="md-nav__list" data-md-scrollfix style="padding-top: 15px; padding-left: 10px">
|
||||
<div>
|
||||
<a href="https://speakeasy.com"><img src="/img/speakeasy.png" width=150px style=></img></a>
|
||||
</div>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
# Parallel Requests
|
||||
|
||||
!!! warning
|
||||
This page documents some proposed functionality that is not yet released.
|
||||
See [pull request #52](https://github.com/encode/httpx/pull/52) for the
|
||||
first-pass of an implementation.
|
||||
|
||||
HTTPX allows you to make HTTP requests in parallel in a highly efficient way,
|
||||
using async under the hood, while still presenting a standard threaded interface.
|
||||
|
||||
This has the huge benefit of allowing you to efficiently make parallel HTTP
|
||||
requests without having to switch out to using async all the way through.
|
||||
|
||||
## Making Parallel Requests
|
||||
|
||||
Let's make two outgoing HTTP requests in parallel:
|
||||
|
||||
```python
|
||||
>>> with httpx.parallel() as parallel:
|
||||
>>> pending_one = parallel.get('https://example.com/1')
|
||||
>>> pending_two = parallel.get('https://example.com/2')
|
||||
>>> response_one = pending_one.get_response()
|
||||
>>> response_two = pending_two.get_response()
|
||||
```
|
||||
|
||||
If we're making lots of outgoing requests, we might not want to deal with the
|
||||
responses sequentially, but rather deal with each response that comes back
|
||||
as soon as it's available:
|
||||
|
||||
```python
|
||||
>>> with httpx.parallel() as parallel:
|
||||
>>> for counter in range(1, 10):
|
||||
>>> parallel.get(f'https://example.com/{counter}')
|
||||
>>> while parallel.has_pending_responses:
|
||||
>>> r = parallel.next_response()
|
||||
```
|
||||
|
||||
## Exceptions and Cancellations
|
||||
|
||||
The style of using `parallel` blocks ensures that you'll always have well
|
||||
defined exception and cancellation behaviours. Request exceptions are only ever
|
||||
raised when calling either `get_response` or `next_response`, and any pending
|
||||
requests are cancelled on exiting the block.
|
||||
|
||||
## Parallel requests with a Client
|
||||
|
||||
You can also call `parallel()` from a client instance, which allows you to
|
||||
control the authentication or dispatch behaviour for all requests within the
|
||||
block.
|
||||
|
||||
```python
|
||||
>>> client = httpx.Client()
|
||||
>>> with client.parallel() as parallel:
|
||||
>>> ...
|
||||
```
|
||||
|
||||
## Async parallel requests
|
||||
|
||||
If you're working within an async framework, then you'll want to use a fully
|
||||
async API for making requests.
|
||||
|
||||
```python
|
||||
>>> client = httpx.AsyncClient()
|
||||
>>> async with client.parallel() as parallel:
|
||||
>>> pending_one = await parallel.get('https://example.com/1')
|
||||
>>> pending_two = await parallel.get('https://example.com/2')
|
||||
>>> response_one = await pending_one.get_response()
|
||||
>>> response_two = await pending_two.get_response()
|
||||
```
|
||||
|
||||
See [the Async Client documentation](async.md) for more details.
|
||||
@ -1,19 +1,14 @@
|
||||
# QuickStart
|
||||
|
||||
!!! note
|
||||
This page closely follows the layout of the `requests` QuickStart documentation.
|
||||
The `httpx` library is designed to be API compatible with `requests` wherever
|
||||
possible.
|
||||
First, start by importing HTTPX:
|
||||
|
||||
First start by importing HTTPX:
|
||||
|
||||
```
|
||||
```pycon
|
||||
>>> import httpx
|
||||
```
|
||||
|
||||
Now, let’s try to get a webpage.
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> r = httpx.get('https://httpbin.org/get')
|
||||
>>> r
|
||||
<Response [200 OK]>
|
||||
@ -21,13 +16,13 @@ Now, let’s try to get a webpage.
|
||||
|
||||
Similarly, to make an HTTP POST request:
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> r = httpx.post('https://httpbin.org/post', data={'key': 'value'})
|
||||
```
|
||||
|
||||
The PUT, DELETE, HEAD, and OPTIONS requests all follow the same style:
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> r = httpx.put('https://httpbin.org/put', data={'key': 'value'})
|
||||
>>> r = httpx.delete('https://httpbin.org/delete')
|
||||
>>> r = httpx.head('https://httpbin.org/get')
|
||||
@ -38,7 +33,7 @@ The PUT, DELETE, HEAD, and OPTIONS requests all follow the same style:
|
||||
|
||||
To include URL query parameters in the request, use the `params` keyword:
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> params = {'key1': 'value1', 'key2': 'value2'}
|
||||
>>> r = httpx.get('https://httpbin.org/get', params=params)
|
||||
```
|
||||
@ -46,14 +41,14 @@ To include URL query parameters in the request, use the `params` keyword:
|
||||
To see how the values get encoding into the URL string, we can inspect the
|
||||
resulting URL that was used to make the request:
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> r.url
|
||||
URL('https://httpbin.org/get?key2=value2&key1=value1')
|
||||
```
|
||||
|
||||
You can also pass a list of items as a value:
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> params = {'key1': 'value1', 'key2': ['value2', 'value3']}
|
||||
>>> r = httpx.get('https://httpbin.org/get', params=params)
|
||||
>>> r.url
|
||||
@ -62,25 +57,35 @@ URL('https://httpbin.org/get?key1=value1&key2=value2&key2=value3')
|
||||
|
||||
## Response Content
|
||||
|
||||
HTTPX will automatically handle decoding the response content into unicode text.
|
||||
HTTPX will automatically handle decoding the response content into Unicode text.
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> r = httpx.get('https://www.example.org/')
|
||||
>>> r.text
|
||||
'<!doctype html>\n<html>\n<head>\n<title>Example Domain</title>...'
|
||||
```
|
||||
|
||||
You can inspect what encoding has been used to decode the response.
|
||||
You can inspect what encoding will be used to decode the response.
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> r.encoding
|
||||
'UTF-8'
|
||||
```
|
||||
|
||||
If you need to override the standard behavior and explicitly set the encoding to
|
||||
In some cases the response may not contain an explicit encoding, in which case HTTPX
|
||||
will attempt to automatically determine an encoding to use.
|
||||
|
||||
```pycon
|
||||
>>> r.encoding
|
||||
None
|
||||
>>> r.text
|
||||
'<!doctype html>\n<html>\n<head>\n<title>Example Domain</title>...'
|
||||
```
|
||||
|
||||
If you need to override the standard behaviour and explicitly set the encoding to
|
||||
use, then you can do that too.
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> r.encoding = 'ISO-8859-1'
|
||||
```
|
||||
|
||||
@ -88,18 +93,19 @@ use, then you can do that too.
|
||||
|
||||
The response content can also be accessed as bytes, for non-text responses:
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> r.content
|
||||
b'<!doctype html>\n<html>\n<head>\n<title>Example Domain</title>...'
|
||||
```
|
||||
|
||||
Any `gzip` and `deflate` HTTP response encodings will automatically
|
||||
be decoded for you. If `brotlipy` is installed, then the `brotli` response
|
||||
encoding will also be supported.
|
||||
encoding will be supported. If `zstandard` is installed, then `zstd`
|
||||
response encodings will also be supported.
|
||||
|
||||
For example, to create an image from binary data returned by a request, you can use the following code:
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> from PIL import Image
|
||||
>>> from io import BytesIO
|
||||
>>> i = Image.open(BytesIO(r.content))
|
||||
@ -109,7 +115,7 @@ For example, to create an image from binary data returned by a request, you can
|
||||
|
||||
Often Web API responses will be encoded as JSON.
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> r = httpx.get('https://api.github.com/events')
|
||||
>>> r.json()
|
||||
[{u'repository': {u'open_issues': 0, u'url': 'https://github.com/...' ... }}]
|
||||
@ -119,8 +125,8 @@ Often Web API responses will be encoded as JSON.
|
||||
|
||||
To include additional headers in the outgoing request, use the `headers` keyword argument:
|
||||
|
||||
```python
|
||||
>>> url = 'http://httpbin.org/headers'
|
||||
```pycon
|
||||
>>> url = 'https://httpbin.org/headers'
|
||||
>>> headers = {'user-agent': 'my-app/0.0.1'}
|
||||
>>> r = httpx.get(url, headers=headers)
|
||||
```
|
||||
@ -128,10 +134,10 @@ To include additional headers in the outgoing request, use the `headers` keyword
|
||||
## Sending Form Encoded Data
|
||||
|
||||
Some types of HTTP requests, such as `POST` and `PUT` requests, can include data
|
||||
in the request body. One common way of including that is as form encoded data,
|
||||
in the request body. One common way of including that is as form-encoded data,
|
||||
which is used for HTML forms.
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> data = {'key1': 'value1', 'key2': 'value2'}
|
||||
>>> r = httpx.post("https://httpbin.org/post", data=data)
|
||||
>>> print(r.text)
|
||||
@ -145,9 +151,9 @@ which is used for HTML forms.
|
||||
}
|
||||
```
|
||||
|
||||
Form encoded data can also include multiple values form a given key.
|
||||
Form encoded data can also include multiple values from a given key.
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> data = {'key1': ['value1', 'value2']}
|
||||
>>> r = httpx.post("https://httpbin.org/post", data=data)
|
||||
>>> print(r.text)
|
||||
@ -167,9 +173,10 @@ Form encoded data can also include multiple values form a given key.
|
||||
|
||||
You can also upload files, using HTTP multipart encoding:
|
||||
|
||||
```python
|
||||
>>> files = {'upload-file': open('report.xls', 'rb')}
|
||||
>>> r = httpx.post("https://httpbin.org/post", files=files)
|
||||
```pycon
|
||||
>>> with open('report.xls', 'rb') as report_file:
|
||||
... files = {'upload-file': report_file}
|
||||
... r = httpx.post("https://httpbin.org/post", files=files)
|
||||
>>> print(r.text)
|
||||
{
|
||||
...
|
||||
@ -183,9 +190,10 @@ You can also upload files, using HTTP multipart encoding:
|
||||
You can also explicitly set the filename and content type, by using a tuple
|
||||
of items for the file value:
|
||||
|
||||
```python
|
||||
>>> files = {'upload-file': ('report.xls', open('report.xls', 'rb'), 'application/vnd.ms-excel')}
|
||||
>>> r = httpx.post("https://httpbin.org/post", files=files)
|
||||
```pycon
|
||||
>>> with open('report.xls', 'rb') as report_file:
|
||||
... files = {'upload-file': ('report.xls', report_file, 'application/vnd.ms-excel')}
|
||||
... r = httpx.post("https://httpbin.org/post", files=files)
|
||||
>>> print(r.text)
|
||||
{
|
||||
...
|
||||
@ -196,12 +204,32 @@ of items for the file value:
|
||||
}
|
||||
```
|
||||
|
||||
If you need to include non-file data fields in the multipart form, use the `data=...` parameter:
|
||||
|
||||
```pycon
|
||||
>>> data = {'message': 'Hello, world!'}
|
||||
>>> with open('report.xls', 'rb') as report_file:
|
||||
... files = {'file': report_file}
|
||||
... r = httpx.post("https://httpbin.org/post", data=data, files=files)
|
||||
>>> print(r.text)
|
||||
{
|
||||
...
|
||||
"files": {
|
||||
"file": "<... binary content ...>"
|
||||
},
|
||||
"form": {
|
||||
"message": "Hello, world!",
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Sending JSON Encoded Data
|
||||
|
||||
Form encoded data is okay if all you need is simple key-value data structure.
|
||||
Form encoded data is okay if all you need is a simple key-value data structure.
|
||||
For more complicated data structures you'll often want to use JSON encoding instead.
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> data = {'integer': 123, 'boolean': True, 'list': ['a', 'b', 'c']}
|
||||
>>> r = httpx.post("https://httpbin.org/post", json=data)
|
||||
>>> print(r.text)
|
||||
@ -222,17 +250,22 @@ For more complicated data structures you'll often want to use JSON encoding inst
|
||||
|
||||
## Sending Binary Request Data
|
||||
|
||||
For other encodings you should use either a `bytes` type, or a generator
|
||||
that yields `bytes`.
|
||||
For other encodings, you should use the `content=...` parameter, passing
|
||||
either a `bytes` type or a generator that yields `bytes`.
|
||||
|
||||
You'll probably also want to set a custom `Content-Type` header when uploading
|
||||
```pycon
|
||||
>>> content = b'Hello, world'
|
||||
>>> r = httpx.post("https://httpbin.org/post", content=content)
|
||||
```
|
||||
|
||||
You may also want to set a custom `Content-Type` header when uploading
|
||||
binary data.
|
||||
|
||||
## Response Status Codes
|
||||
## Response Status Codes
|
||||
|
||||
We can inspect the HTTP status code of the response:
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> r = httpx.get('https://httpbin.org/get')
|
||||
>>> r.status_code
|
||||
200
|
||||
@ -240,35 +273,43 @@ We can inspect the HTTP status code of the response:
|
||||
|
||||
HTTPX also includes an easy shortcut for accessing status codes by their text phrase.
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> r.status_code == httpx.codes.OK
|
||||
True
|
||||
```
|
||||
|
||||
We can raise an exception for any Client or Server error responses (4xx or 5xx status codes):
|
||||
We can raise an exception for any responses which are not a 2xx success code:
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> not_found = httpx.get('https://httpbin.org/status/404')
|
||||
>>> not_found.status_code
|
||||
404
|
||||
>>> not_found.raise_for_status()
|
||||
Traceback (most recent call last):
|
||||
File "/Users/tomchristie/GitHub/encode/httpcore/httpx/models.py", line 776, in raise_for_status
|
||||
raise HttpError(message)
|
||||
httpx.exceptions.HttpError: 404 Not Found
|
||||
File "/Users/tomchristie/GitHub/encode/httpcore/httpx/models.py", line 837, in raise_for_status
|
||||
raise HTTPStatusError(message, response=self)
|
||||
httpx._exceptions.HTTPStatusError: 404 Client Error: Not Found for url: https://httpbin.org/status/404
|
||||
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404
|
||||
```
|
||||
|
||||
Any successful response codes will simply return `None` rather than raising an exception.
|
||||
Any successful response codes will return the `Response` instance rather than raising an exception.
|
||||
|
||||
``` python
|
||||
```pycon
|
||||
>>> r.raise_for_status()
|
||||
```
|
||||
|
||||
The method returns the response instance, allowing you to use it inline. For example:
|
||||
|
||||
```pycon
|
||||
>>> r = httpx.get('...').raise_for_status()
|
||||
>>> data = httpx.get('...').raise_for_status().json()
|
||||
```
|
||||
|
||||
## Response Headers
|
||||
|
||||
The response headers are available as a dictionary-like interface.
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> r.headers
|
||||
Headers({
|
||||
'content-encoding': 'gzip',
|
||||
@ -283,7 +324,7 @@ Headers({
|
||||
|
||||
The `Headers` data type is case-insensitive, so you can use any capitalization.
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> r.headers['Content-Type']
|
||||
'application/json'
|
||||
|
||||
@ -291,26 +332,73 @@ The `Headers` data type is case-insensitive, so you can use any capitalization.
|
||||
'application/json'
|
||||
```
|
||||
|
||||
Multiple values for a single response header are represented as a single comma separated
|
||||
value, as per [RFC 7230](https://tools.ietf.org/html/rfc7230#section-3.2):
|
||||
Multiple values for a single response header are represented as a single comma-separated value, as per [RFC 7230](https://tools.ietf.org/html/rfc7230#section-3.2):
|
||||
|
||||
> A recipient MAY combine multiple header fields with the same field name into one “field-name: field-value” pair, without changing the semantics of the message, by appending each subsequent field value to the combined field value in order, separated by a comma.
|
||||
> A recipient MAY combine multiple header fields with the same field name into one “field-name: field-value” pair, without changing the semantics of the message, by appending each subsequent field-value to the combined field value in order, separated by a comma.
|
||||
|
||||
## Streaming Responses
|
||||
|
||||
For large downloads you may want to use streaming responses that do not load the entire response body into memory at once.
|
||||
|
||||
You can stream the binary content of the response...
|
||||
|
||||
```pycon
|
||||
>>> with httpx.stream("GET", "https://www.example.com") as r:
|
||||
... for data in r.iter_bytes():
|
||||
... print(data)
|
||||
```
|
||||
|
||||
Or the text of the response...
|
||||
|
||||
```pycon
|
||||
>>> with httpx.stream("GET", "https://www.example.com") as r:
|
||||
... for text in r.iter_text():
|
||||
... print(text)
|
||||
```
|
||||
|
||||
Or stream the text, on a line-by-line basis...
|
||||
|
||||
```pycon
|
||||
>>> with httpx.stream("GET", "https://www.example.com") as r:
|
||||
... for line in r.iter_lines():
|
||||
... print(line)
|
||||
```
|
||||
|
||||
HTTPX will use universal line endings, normalising all cases to `\n`.
|
||||
|
||||
In some cases you might want to access the raw bytes on the response without applying any HTTP content decoding. In this case any content encoding that the web server has applied such as `gzip`, `deflate`, `brotli`, or `zstd` will
|
||||
not be automatically decoded.
|
||||
|
||||
```pycon
|
||||
>>> with httpx.stream("GET", "https://www.example.com") as r:
|
||||
... for chunk in r.iter_raw():
|
||||
... print(chunk)
|
||||
```
|
||||
|
||||
If you're using streaming responses in any of these ways then the `response.content` and `response.text` attributes will not be available, and will raise errors if accessed. However you can also use the response streaming functionality to conditionally load the response body:
|
||||
|
||||
```pycon
|
||||
>>> with httpx.stream("GET", "https://www.example.com") as r:
|
||||
... if int(r.headers['Content-Length']) < TOO_LONG:
|
||||
... r.read()
|
||||
... print(r.text)
|
||||
```
|
||||
|
||||
## Cookies
|
||||
|
||||
Any cookies that are set on the response can be easily accessed:
|
||||
|
||||
```python
|
||||
>>> r = httpx.get('http://httpbin.org/cookies/set?chocolate=chip', allow_redirects=False)
|
||||
```pycon
|
||||
>>> r = httpx.get('https://httpbin.org/cookies/set?chocolate=chip')
|
||||
>>> r.cookies['chocolate']
|
||||
'chip'
|
||||
```
|
||||
|
||||
To include cookies in an outgoing request, use the `cookies` parameter:
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> cookies = {"peanut": "butter"}
|
||||
>>> r = httpx.get('http://httpbin.org/cookies', cookies=cookies)
|
||||
>>> r = httpx.get('https://httpbin.org/cookies', cookies=cookies)
|
||||
>>> r.json()
|
||||
{'cookies': {'peanut': 'butter'}}
|
||||
```
|
||||
@ -318,7 +406,7 @@ To include cookies in an outgoing request, use the `cookies` parameter:
|
||||
Cookies are returned in a `Cookies` instance, which is a dict-like data structure
|
||||
with additional API for accessing cookies by their domain or path.
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> cookies = httpx.Cookies()
|
||||
>>> cookies.set('cookie_on_domain', 'hello, there!', domain='httpbin.org')
|
||||
>>> cookies.set('cookie_off_domain', 'nope.', domain='example.org')
|
||||
@ -329,16 +417,25 @@ with additional API for accessing cookies by their domain or path.
|
||||
|
||||
## Redirection and History
|
||||
|
||||
By default HTTPX will follow redirects for anything except `HEAD` requests.
|
||||
|
||||
The `history` property of the response can be used to inspect any followed redirects.
|
||||
It contains a list of all any redirect responses that were followed, in the order
|
||||
in which they were made.
|
||||
By default, HTTPX will **not** follow redirects for all HTTP methods, although
|
||||
this can be explicitly enabled.
|
||||
|
||||
For example, GitHub redirects all HTTP requests to HTTPS.
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> r = httpx.get('http://github.com/')
|
||||
>>> r.status_code
|
||||
301
|
||||
>>> r.history
|
||||
[]
|
||||
>>> r.next_request
|
||||
<Request('GET', 'https://github.com/')>
|
||||
```
|
||||
|
||||
You can modify the default redirection handling with the `follow_redirects` parameter:
|
||||
|
||||
```pycon
|
||||
>>> r = httpx.get('http://github.com/', follow_redirects=True)
|
||||
>>> r.url
|
||||
URL('https://github.com/')
|
||||
>>> r.status_code
|
||||
@ -347,25 +444,9 @@ URL('https://github.com/')
|
||||
[<Response [301 Moved Permanently]>]
|
||||
```
|
||||
|
||||
You can modify the default redirection handling with the allow_redirects parameter:
|
||||
|
||||
```python
|
||||
>>> r = httpx.get('http://github.com/', allow_redirects=False)
|
||||
>>> r.status_code
|
||||
301
|
||||
>>> r.history
|
||||
[]
|
||||
```
|
||||
|
||||
If you’re making a `HEAD` request, you can use this to enable redirection:
|
||||
|
||||
```python
|
||||
>>> r = httpx.head('http://github.com/', allow_redirects=True)
|
||||
>>> r.url
|
||||
'https://github.com/'
|
||||
>>> r.history
|
||||
[<Response [301 Moved Permanently]>]
|
||||
```
|
||||
The `history` property of the response can be used to inspect any followed redirects.
|
||||
It contains a list of any redirect responses that were followed, in the order
|
||||
in which they were made.
|
||||
|
||||
## Timeouts
|
||||
|
||||
@ -376,10 +457,18 @@ raise an error rather than hanging indefinitely.
|
||||
The default timeout for network inactivity is five seconds. You can modify the
|
||||
value to be more or less strict:
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> httpx.get('https://github.com/', timeout=0.001)
|
||||
```
|
||||
|
||||
You can also disable the timeout behavior completely...
|
||||
|
||||
```pycon
|
||||
>>> httpx.get('https://github.com/', timeout=None)
|
||||
```
|
||||
|
||||
For advanced timeout management, see [Timeout fine-tuning](advanced/timeouts.md#fine-tuning-the-configuration).
|
||||
|
||||
## Authentication
|
||||
|
||||
HTTPX supports Basic and Digest HTTP authentication.
|
||||
@ -388,7 +477,7 @@ To provide Basic authentication credentials, pass a 2-tuple of
|
||||
plaintext `str` or `bytes` objects as the `auth` argument to the request
|
||||
functions:
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> httpx.get("https://example.com", auth=("my_user", "password123"))
|
||||
```
|
||||
|
||||
@ -397,7 +486,62 @@ a `DigestAuth` object with the plaintext username and password as arguments.
|
||||
This object can be then passed as the `auth` argument to the request methods
|
||||
as above:
|
||||
|
||||
```python
|
||||
```pycon
|
||||
>>> auth = httpx.DigestAuth("my_user", "password123")
|
||||
>>> httpx.get("https://example.com", auth=auth)
|
||||
<Response [200 OK]>
|
||||
```
|
||||
|
||||
## Exceptions
|
||||
|
||||
HTTPX will raise exceptions if an error occurs.
|
||||
|
||||
The most important exception classes in HTTPX are `RequestError` and `HTTPStatusError`.
|
||||
|
||||
The `RequestError` class is a superclass that encompasses any exception that occurs
|
||||
while issuing an HTTP request. These exceptions include a `.request` attribute.
|
||||
|
||||
```python
|
||||
try:
|
||||
response = httpx.get("https://www.example.com/")
|
||||
except httpx.RequestError as exc:
|
||||
print(f"An error occurred while requesting {exc.request.url!r}.")
|
||||
```
|
||||
|
||||
The `HTTPStatusError` class is raised by `response.raise_for_status()` on responses which are not a 2xx success code.
|
||||
These exceptions include both a `.request` and a `.response` attribute.
|
||||
|
||||
```python
|
||||
response = httpx.get("https://www.example.com/")
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
print(f"Error response {exc.response.status_code} while requesting {exc.request.url!r}.")
|
||||
```
|
||||
|
||||
There is also a base class `HTTPError` that includes both of these categories, and can be used
|
||||
to catch either failed requests, or 4xx and 5xx responses.
|
||||
|
||||
You can either use this base class to catch both categories...
|
||||
|
||||
```python
|
||||
try:
|
||||
response = httpx.get("https://www.example.com/")
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
print(f"Error while requesting {exc.request.url!r}.")
|
||||
```
|
||||
|
||||
Or handle each case explicitly...
|
||||
|
||||
```python
|
||||
try:
|
||||
response = httpx.get("https://www.example.com/")
|
||||
response.raise_for_status()
|
||||
except httpx.RequestError as exc:
|
||||
print(f"An error occurred while requesting {exc.request.url!r}.")
|
||||
except httpx.HTTPStatusError as exc:
|
||||
print(f"Error response {exc.response.status_code} while requesting {exc.request.url!r}.")
|
||||
```
|
||||
|
||||
For a full list of available exceptions, see [Exceptions (API Reference)](exceptions.md).
|
||||
|
||||
107
docs/third_party_packages.md
Normal file
@ -0,0 +1,107 @@
|
||||
# Third Party Packages
|
||||
|
||||
As HTTPX usage grows, there is an expanding community of developers building tools and libraries that integrate with HTTPX, or depend on HTTPX. Here are some of them.
|
||||
|
||||
<!-- NOTE: Entries are alphabetised. -->
|
||||
|
||||
## Plugins
|
||||
|
||||
### Hishel
|
||||
|
||||
[GitHub](https://github.com/karpetrosyan/hishel) - [Documentation](https://hishel.com/)
|
||||
|
||||
An elegant HTTP Cache implementation for HTTPX and HTTP Core.
|
||||
|
||||
### HTTPX-Auth
|
||||
|
||||
[GitHub](https://github.com/Colin-b/httpx_auth) - [Documentation](https://colin-b.github.io/httpx_auth/)
|
||||
|
||||
Provides authentication classes to be used with HTTPX's [authentication parameter](advanced/authentication.md#customizing-authentication).
|
||||
|
||||
### httpx-caching
|
||||
|
||||
[Github](https://github.com/johtso/httpx-caching)
|
||||
|
||||
This package adds caching functionality to HTTPX
|
||||
|
||||
### httpx-secure
|
||||
|
||||
[GitHub](https://github.com/Zaczero/httpx-secure)
|
||||
|
||||
Drop-in SSRF protection for httpx with DNS caching and custom validation support.
|
||||
|
||||
### httpx-socks
|
||||
|
||||
[GitHub](https://github.com/romis2012/httpx-socks)
|
||||
|
||||
Proxy (HTTP, SOCKS) transports for httpx.
|
||||
|
||||
### httpx-sse
|
||||
|
||||
[GitHub](https://github.com/florimondmanca/httpx-sse)
|
||||
|
||||
Allows consuming Server-Sent Events (SSE) with HTTPX.
|
||||
|
||||
### httpx-retries
|
||||
|
||||
[GitHub](https://github.com/will-ockmore/httpx-retries) - [Documentation](https://will-ockmore.github.io/httpx-retries/)
|
||||
|
||||
A retry layer for HTTPX.
|
||||
|
||||
### httpx-ws
|
||||
|
||||
[GitHub](https://github.com/frankie567/httpx-ws) - [Documentation](https://frankie567.github.io/httpx-ws/)
|
||||
|
||||
WebSocket support for HTTPX.
|
||||
|
||||
### pytest-HTTPX
|
||||
|
||||
[GitHub](https://github.com/Colin-b/pytest_httpx) - [Documentation](https://colin-b.github.io/pytest_httpx/)
|
||||
|
||||
Provides a [pytest](https://docs.pytest.org/en/latest/) fixture to mock HTTPX within test cases.
|
||||
|
||||
### RESPX
|
||||
|
||||
[GitHub](https://github.com/lundberg/respx) - [Documentation](https://lundberg.github.io/respx/)
|
||||
|
||||
A utility for mocking out HTTPX.
|
||||
|
||||
### rpc.py
|
||||
|
||||
[Github](https://github.com/abersheeran/rpc.py) - [Documentation](https://github.com/abersheeran/rpc.py#rpcpy)
|
||||
|
||||
A fast and powerful RPC framework based on ASGI/WSGI. Use HTTPX as the client of the RPC service.
|
||||
|
||||
## Libraries with HTTPX support
|
||||
|
||||
### Authlib
|
||||
|
||||
[GitHub](https://github.com/lepture/authlib) - [Documentation](https://docs.authlib.org/en/latest/)
|
||||
|
||||
A python library for building OAuth and OpenID Connect clients and servers. Includes an [OAuth HTTPX client](https://docs.authlib.org/en/latest/client/httpx.html).
|
||||
|
||||
### Gidgethub
|
||||
|
||||
[GitHub](https://github.com/brettcannon/gidgethub) - [Documentation](https://gidgethub.readthedocs.io/en/latest/index.html)
|
||||
|
||||
An asynchronous GitHub API library. Includes [HTTPX support](https://gidgethub.readthedocs.io/en/latest/httpx.html).
|
||||
|
||||
### httpdbg
|
||||
|
||||
[GitHub](https://github.com/cle-b/httpdbg) - [Documentation](https://httpdbg.readthedocs.io/)
|
||||
|
||||
A tool for python developers to easily debug the HTTP(S) client requests in a python program.
|
||||
|
||||
### VCR.py
|
||||
|
||||
[GitHub](https://github.com/kevin1024/vcrpy) - [Documentation](https://vcrpy.readthedocs.io/)
|
||||
|
||||
Record and repeat requests.
|
||||
|
||||
## Gists
|
||||
|
||||
### urllib3-transport
|
||||
|
||||
[GitHub](https://gist.github.com/florimondmanca/d56764d78d748eb9f73165da388e546e)
|
||||
|
||||
This public gist provides an example implementation for a [custom transport](advanced/transports.md#custom-transports) implementation on top of the battle-tested [`urllib3`](https://urllib3.readthedocs.io) library.
|
||||
63
docs/troubleshooting.md
Normal file
@ -0,0 +1,63 @@
|
||||
# Troubleshooting
|
||||
|
||||
This page lists some common problems or issues you could encounter while developing with HTTPX, as well as possible solutions.
|
||||
|
||||
## Proxies
|
||||
|
||||
---
|
||||
|
||||
### "`The handshake operation timed out`" on HTTPS requests when using a proxy
|
||||
|
||||
**Description**: When using a proxy and making an HTTPS request, you see an exception looking like this:
|
||||
|
||||
```console
|
||||
httpx.ProxyError: _ssl.c:1091: The handshake operation timed out
|
||||
```
|
||||
|
||||
**Similar issues**: [encode/httpx#1412](https://github.com/encode/httpx/issues/1412), [encode/httpx#1433](https://github.com/encode/httpx/issues/1433)
|
||||
|
||||
**Resolution**: it is likely that you've set up your proxies like this...
|
||||
|
||||
```python
|
||||
mounts = {
|
||||
"http://": httpx.HTTPTransport(proxy="http://myproxy.org"),
|
||||
"https://": httpx.HTTPTransport(proxy="https://myproxy.org"),
|
||||
}
|
||||
```
|
||||
|
||||
Using this setup, you're telling HTTPX to connect to the proxy using HTTP for HTTP requests, and using HTTPS for HTTPS requests.
|
||||
|
||||
But if you get the error above, it is likely that your proxy doesn't support connecting via HTTPS. Don't worry: that's a [common gotcha](advanced/proxies.md#http-proxies).
|
||||
|
||||
Change the scheme of your HTTPS proxy to `http://...` instead of `https://...`:
|
||||
|
||||
```python
|
||||
mounts = {
|
||||
"http://": httpx.HTTPTransport(proxy="http://myproxy.org"),
|
||||
"https://": httpx.HTTPTransport(proxy="http://myproxy.org"),
|
||||
}
|
||||
```
|
||||
|
||||
This can be simplified to:
|
||||
|
||||
```python
|
||||
proxy = "http://myproxy.org"
|
||||
with httpx.Client(proxy=proxy) as client:
|
||||
...
|
||||
```
|
||||
|
||||
For more information, see [Proxies: FORWARD vs TUNNEL](advanced/proxies.md#forward-vs-tunnel).
|
||||
|
||||
---
|
||||
|
||||
### Error when making requests to an HTTPS proxy
|
||||
|
||||
**Description**: your proxy _does_ support connecting via HTTPS, but you are seeing errors along the lines of...
|
||||
|
||||
```console
|
||||
httpx.ProxyError: [SSL: PRE_MAC_LENGTH_TOO_LONG] invalid alert (_ssl.c:1091)
|
||||
```
|
||||
|
||||
**Similar issues**: [encode/httpx#1424](https://github.com/encode/httpx/issues/1424).
|
||||
|
||||
**Resolution**: HTTPX does not properly support HTTPS proxies at this time. If that's something you're interested in having, please see [encode/httpx#1434](https://github.com/encode/httpx/issues/1434) and consider lending a hand there.
|
||||
@ -1,143 +1,106 @@
|
||||
from .__version__ import __description__, __title__, __version__
|
||||
from .api import delete, get, head, options, patch, post, put, request
|
||||
from .client import AsyncClient, Client
|
||||
from .concurrency.asyncio import AsyncioBackend
|
||||
from .concurrency.base import (
|
||||
BaseBackgroundManager,
|
||||
BasePoolSemaphore,
|
||||
BaseTCPStream,
|
||||
ConcurrencyBackend,
|
||||
)
|
||||
from .config import (
|
||||
USER_AGENT,
|
||||
CertTypes,
|
||||
HTTPVersionConfig,
|
||||
HTTPVersionTypes,
|
||||
PoolLimits,
|
||||
SSLConfig,
|
||||
TimeoutConfig,
|
||||
TimeoutTypes,
|
||||
VerifyTypes,
|
||||
)
|
||||
from .dispatch.base import AsyncDispatcher, Dispatcher
|
||||
from .dispatch.connection import HTTPConnection
|
||||
from .dispatch.connection_pool import ConnectionPool
|
||||
from .dispatch.proxy_http import HTTPProxy, HTTPProxyMode
|
||||
from .exceptions import (
|
||||
ConnectTimeout,
|
||||
CookieConflict,
|
||||
DecodingError,
|
||||
InvalidURL,
|
||||
NotRedirectResponse,
|
||||
PoolTimeout,
|
||||
ProtocolError,
|
||||
ProxyError,
|
||||
ReadTimeout,
|
||||
RedirectBodyUnavailable,
|
||||
RedirectLoop,
|
||||
ResponseClosed,
|
||||
ResponseNotRead,
|
||||
StreamConsumed,
|
||||
Timeout,
|
||||
TooManyRedirects,
|
||||
WriteTimeout,
|
||||
)
|
||||
from .middleware.digest_auth import DigestAuth
|
||||
from .models import (
|
||||
URL,
|
||||
AsyncRequest,
|
||||
AsyncRequestData,
|
||||
AsyncResponse,
|
||||
AsyncResponseContent,
|
||||
AuthTypes,
|
||||
Cookies,
|
||||
CookieTypes,
|
||||
Headers,
|
||||
HeaderTypes,
|
||||
Origin,
|
||||
QueryParams,
|
||||
QueryParamTypes,
|
||||
Request,
|
||||
RequestData,
|
||||
RequestFiles,
|
||||
Response,
|
||||
ResponseContent,
|
||||
URLTypes,
|
||||
)
|
||||
from .status_codes import StatusCode, codes
|
||||
from ._api import *
|
||||
from ._auth import *
|
||||
from ._client import *
|
||||
from ._config import *
|
||||
from ._content import *
|
||||
from ._exceptions import *
|
||||
from ._models import *
|
||||
from ._status_codes import *
|
||||
from ._transports import *
|
||||
from ._types import *
|
||||
from ._urls import *
|
||||
|
||||
try:
|
||||
from ._main import main
|
||||
except ImportError: # pragma: no cover
|
||||
|
||||
def main() -> None: # type: ignore
|
||||
import sys
|
||||
|
||||
print(
|
||||
"The httpx command line client could not run because the required "
|
||||
"dependencies were not installed.\nMake sure you've installed "
|
||||
"everything with: pip install 'httpx[cli]'"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"__description__",
|
||||
"__title__",
|
||||
"__version__",
|
||||
"delete",
|
||||
"get",
|
||||
"head",
|
||||
"options",
|
||||
"patch",
|
||||
"post",
|
||||
"patch",
|
||||
"put",
|
||||
"request",
|
||||
"ASGITransport",
|
||||
"AsyncBaseTransport",
|
||||
"AsyncByteStream",
|
||||
"AsyncClient",
|
||||
"AsyncHTTPTransport",
|
||||
"Auth",
|
||||
"BaseTransport",
|
||||
"BasicAuth",
|
||||
"ByteStream",
|
||||
"Client",
|
||||
"AsyncioBackend",
|
||||
"USER_AGENT",
|
||||
"CertTypes",
|
||||
"PoolLimits",
|
||||
"SSLConfig",
|
||||
"TimeoutConfig",
|
||||
"VerifyTypes",
|
||||
"HTTPConnection",
|
||||
"BasePoolSemaphore",
|
||||
"BaseBackgroundManager",
|
||||
"ConnectionPool",
|
||||
"HTTPProxy",
|
||||
"HTTPProxyMode",
|
||||
"CloseError",
|
||||
"codes",
|
||||
"ConnectError",
|
||||
"ConnectTimeout",
|
||||
"CookieConflict",
|
||||
"DecodingError",
|
||||
"InvalidURL",
|
||||
"NotRedirectResponse",
|
||||
"PoolTimeout",
|
||||
"ProtocolError",
|
||||
"ReadTimeout",
|
||||
"RedirectBodyUnavailable",
|
||||
"RedirectLoop",
|
||||
"ResponseClosed",
|
||||
"ResponseNotRead",
|
||||
"StreamConsumed",
|
||||
"ProxyError",
|
||||
"Timeout",
|
||||
"TooManyRedirects",
|
||||
"WriteTimeout",
|
||||
"AsyncDispatcher",
|
||||
"BaseTCPStream",
|
||||
"ConcurrencyBackend",
|
||||
"Dispatcher",
|
||||
"URL",
|
||||
"URLTypes",
|
||||
"StatusCode",
|
||||
"codes",
|
||||
"TimeoutTypes",
|
||||
"HTTPVersionTypes",
|
||||
"HTTPVersionConfig",
|
||||
"AsyncRequest",
|
||||
"AsyncRequestData",
|
||||
"AsyncResponse",
|
||||
"AsyncResponseContent",
|
||||
"AuthTypes",
|
||||
"Cookies",
|
||||
"CookieTypes",
|
||||
"Headers",
|
||||
"HeaderTypes",
|
||||
"Origin",
|
||||
"QueryParams",
|
||||
"QueryParamTypes",
|
||||
"Request",
|
||||
"RequestData",
|
||||
"Response",
|
||||
"ResponseContent",
|
||||
"RequestFiles",
|
||||
"create_ssl_context",
|
||||
"DecodingError",
|
||||
"delete",
|
||||
"DigestAuth",
|
||||
"FunctionAuth",
|
||||
"get",
|
||||
"head",
|
||||
"Headers",
|
||||
"HTTPError",
|
||||
"HTTPStatusError",
|
||||
"HTTPTransport",
|
||||
"InvalidURL",
|
||||
"Limits",
|
||||
"LocalProtocolError",
|
||||
"main",
|
||||
"MockTransport",
|
||||
"NetRCAuth",
|
||||
"NetworkError",
|
||||
"options",
|
||||
"patch",
|
||||
"PoolTimeout",
|
||||
"post",
|
||||
"ProtocolError",
|
||||
"Proxy",
|
||||
"ProxyError",
|
||||
"put",
|
||||
"QueryParams",
|
||||
"ReadError",
|
||||
"ReadTimeout",
|
||||
"RemoteProtocolError",
|
||||
"request",
|
||||
"Request",
|
||||
"RequestError",
|
||||
"RequestNotRead",
|
||||
"Response",
|
||||
"ResponseNotRead",
|
||||
"stream",
|
||||
"StreamClosed",
|
||||
"StreamConsumed",
|
||||
"StreamError",
|
||||
"SyncByteStream",
|
||||
"Timeout",
|
||||
"TimeoutException",
|
||||
"TooManyRedirects",
|
||||
"TransportError",
|
||||
"UnsupportedProtocol",
|
||||
"URL",
|
||||
"USE_CLIENT_DEFAULT",
|
||||
"WriteError",
|
||||
"WriteTimeout",
|
||||
"WSGITransport",
|
||||
]
|
||||
|
||||
|
||||
__locals = locals()
|
||||
for __name in __all__:
|
||||
if not __name.startswith("__"):
|
||||
setattr(__locals[__name], "__module__", "httpx") # noqa
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
__title__ = "httpx"
|
||||
__description__ = "A next generation HTTP client, for Python 3."
|
||||
__version__ = "0.7.4"
|
||||
__version__ = "0.28.1"
|
||||
|
||||
438
httpx/_api.py
Normal file
@ -0,0 +1,438 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from contextlib import contextmanager
|
||||
|
||||
from ._client import Client
|
||||
from ._config import DEFAULT_TIMEOUT_CONFIG
|
||||
from ._models import Response
|
||||
from ._types import (
|
||||
AuthTypes,
|
||||
CookieTypes,
|
||||
HeaderTypes,
|
||||
ProxyTypes,
|
||||
QueryParamTypes,
|
||||
RequestContent,
|
||||
RequestData,
|
||||
RequestFiles,
|
||||
TimeoutTypes,
|
||||
)
|
||||
from ._urls import URL
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import ssl # pragma: no cover
|
||||
|
||||
|
||||
__all__ = [
|
||||
"delete",
|
||||
"get",
|
||||
"head",
|
||||
"options",
|
||||
"patch",
|
||||
"post",
|
||||
"put",
|
||||
"request",
|
||||
"stream",
|
||||
]
|
||||
|
||||
|
||||
def request(
|
||||
method: str,
|
||||
url: URL | str,
|
||||
*,
|
||||
params: QueryParamTypes | None = None,
|
||||
content: RequestContent | None = None,
|
||||
data: RequestData | None = None,
|
||||
files: RequestFiles | None = None,
|
||||
json: typing.Any | None = None,
|
||||
headers: HeaderTypes | None = None,
|
||||
cookies: CookieTypes | None = None,
|
||||
auth: AuthTypes | None = None,
|
||||
proxy: ProxyTypes | None = None,
|
||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||
follow_redirects: bool = False,
|
||||
verify: ssl.SSLContext | str | bool = True,
|
||||
trust_env: bool = True,
|
||||
) -> Response:
|
||||
"""
|
||||
Sends an HTTP request.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
* **method** - HTTP method for the new `Request` object: `GET`, `OPTIONS`,
|
||||
`HEAD`, `POST`, `PUT`, `PATCH`, or `DELETE`.
|
||||
* **url** - URL for the new `Request` object.
|
||||
* **params** - *(optional)* Query parameters to include in the URL, as a
|
||||
string, dictionary, or sequence of two-tuples.
|
||||
* **content** - *(optional)* Binary content to include in the body of the
|
||||
request, as bytes or a byte iterator.
|
||||
* **data** - *(optional)* Form data to include in the body of the request,
|
||||
as a dictionary.
|
||||
* **files** - *(optional)* A dictionary of upload files to include in the
|
||||
body of the request.
|
||||
* **json** - *(optional)* A JSON serializable object to include in the body
|
||||
of the request.
|
||||
* **headers** - *(optional)* Dictionary of HTTP headers to include in the
|
||||
request.
|
||||
* **cookies** - *(optional)* Dictionary of Cookie items to include in the
|
||||
request.
|
||||
* **auth** - *(optional)* An authentication class to use when sending the
|
||||
request.
|
||||
* **proxy** - *(optional)* A proxy URL where all the traffic should be routed.
|
||||
* **timeout** - *(optional)* The timeout configuration to use when sending
|
||||
the request.
|
||||
* **follow_redirects** - *(optional)* Enables or disables HTTP redirects.
|
||||
* **verify** - *(optional)* Either `True` to use an SSL context with the
|
||||
default CA bundle, `False` to disable verification, or an instance of
|
||||
`ssl.SSLContext` to use a custom context.
|
||||
* **trust_env** - *(optional)* Enables or disables usage of environment
|
||||
variables for configuration.
|
||||
|
||||
**Returns:** `Response`
|
||||
|
||||
Usage:
|
||||
|
||||
```
|
||||
>>> import httpx
|
||||
>>> response = httpx.request('GET', 'https://httpbin.org/get')
|
||||
>>> response
|
||||
<Response [200 OK]>
|
||||
```
|
||||
"""
|
||||
with Client(
|
||||
cookies=cookies,
|
||||
proxy=proxy,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
trust_env=trust_env,
|
||||
) as client:
|
||||
return client.request(
|
||||
method=method,
|
||||
url=url,
|
||||
content=content,
|
||||
data=data,
|
||||
files=files,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=headers,
|
||||
auth=auth,
|
||||
follow_redirects=follow_redirects,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def stream(
|
||||
method: str,
|
||||
url: URL | str,
|
||||
*,
|
||||
params: QueryParamTypes | None = None,
|
||||
content: RequestContent | None = None,
|
||||
data: RequestData | None = None,
|
||||
files: RequestFiles | None = None,
|
||||
json: typing.Any | None = None,
|
||||
headers: HeaderTypes | None = None,
|
||||
cookies: CookieTypes | None = None,
|
||||
auth: AuthTypes | None = None,
|
||||
proxy: ProxyTypes | None = None,
|
||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||
follow_redirects: bool = False,
|
||||
verify: ssl.SSLContext | str | bool = True,
|
||||
trust_env: bool = True,
|
||||
) -> typing.Iterator[Response]:
|
||||
"""
|
||||
Alternative to `httpx.request()` that streams the response body
|
||||
instead of loading it into memory at once.
|
||||
|
||||
**Parameters**: See `httpx.request`.
|
||||
|
||||
See also: [Streaming Responses][0]
|
||||
|
||||
[0]: /quickstart#streaming-responses
|
||||
"""
|
||||
with Client(
|
||||
cookies=cookies,
|
||||
proxy=proxy,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
trust_env=trust_env,
|
||||
) as client:
|
||||
with client.stream(
|
||||
method=method,
|
||||
url=url,
|
||||
content=content,
|
||||
data=data,
|
||||
files=files,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=headers,
|
||||
auth=auth,
|
||||
follow_redirects=follow_redirects,
|
||||
) as response:
|
||||
yield response
|
||||
|
||||
|
||||
def get(
|
||||
url: URL | str,
|
||||
*,
|
||||
params: QueryParamTypes | None = None,
|
||||
headers: HeaderTypes | None = None,
|
||||
cookies: CookieTypes | None = None,
|
||||
auth: AuthTypes | None = None,
|
||||
proxy: ProxyTypes | None = None,
|
||||
follow_redirects: bool = False,
|
||||
verify: ssl.SSLContext | str | bool = True,
|
||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||
trust_env: bool = True,
|
||||
) -> Response:
|
||||
"""
|
||||
Sends a `GET` request.
|
||||
|
||||
**Parameters**: See `httpx.request`.
|
||||
|
||||
Note that the `data`, `files`, `json` and `content` parameters are not available
|
||||
on this function, as `GET` requests should not include a request body.
|
||||
"""
|
||||
return request(
|
||||
"GET",
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
proxy=proxy,
|
||||
follow_redirects=follow_redirects,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
trust_env=trust_env,
|
||||
)
|
||||
|
||||
|
||||
def options(
|
||||
url: URL | str,
|
||||
*,
|
||||
params: QueryParamTypes | None = None,
|
||||
headers: HeaderTypes | None = None,
|
||||
cookies: CookieTypes | None = None,
|
||||
auth: AuthTypes | None = None,
|
||||
proxy: ProxyTypes | None = None,
|
||||
follow_redirects: bool = False,
|
||||
verify: ssl.SSLContext | str | bool = True,
|
||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||
trust_env: bool = True,
|
||||
) -> Response:
|
||||
"""
|
||||
Sends an `OPTIONS` request.
|
||||
|
||||
**Parameters**: See `httpx.request`.
|
||||
|
||||
Note that the `data`, `files`, `json` and `content` parameters are not available
|
||||
on this function, as `OPTIONS` requests should not include a request body.
|
||||
"""
|
||||
return request(
|
||||
"OPTIONS",
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
proxy=proxy,
|
||||
follow_redirects=follow_redirects,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
trust_env=trust_env,
|
||||
)
|
||||
|
||||
|
||||
def head(
|
||||
url: URL | str,
|
||||
*,
|
||||
params: QueryParamTypes | None = None,
|
||||
headers: HeaderTypes | None = None,
|
||||
cookies: CookieTypes | None = None,
|
||||
auth: AuthTypes | None = None,
|
||||
proxy: ProxyTypes | None = None,
|
||||
follow_redirects: bool = False,
|
||||
verify: ssl.SSLContext | str | bool = True,
|
||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||
trust_env: bool = True,
|
||||
) -> Response:
|
||||
"""
|
||||
Sends a `HEAD` request.
|
||||
|
||||
**Parameters**: See `httpx.request`.
|
||||
|
||||
Note that the `data`, `files`, `json` and `content` parameters are not available
|
||||
on this function, as `HEAD` requests should not include a request body.
|
||||
"""
|
||||
return request(
|
||||
"HEAD",
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
proxy=proxy,
|
||||
follow_redirects=follow_redirects,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
trust_env=trust_env,
|
||||
)
|
||||
|
||||
|
||||
def post(
|
||||
url: URL | str,
|
||||
*,
|
||||
content: RequestContent | None = None,
|
||||
data: RequestData | None = None,
|
||||
files: RequestFiles | None = None,
|
||||
json: typing.Any | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
headers: HeaderTypes | None = None,
|
||||
cookies: CookieTypes | None = None,
|
||||
auth: AuthTypes | None = None,
|
||||
proxy: ProxyTypes | None = None,
|
||||
follow_redirects: bool = False,
|
||||
verify: ssl.SSLContext | str | bool = True,
|
||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||
trust_env: bool = True,
|
||||
) -> Response:
|
||||
"""
|
||||
Sends a `POST` request.
|
||||
|
||||
**Parameters**: See `httpx.request`.
|
||||
"""
|
||||
return request(
|
||||
"POST",
|
||||
url,
|
||||
content=content,
|
||||
data=data,
|
||||
files=files,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
proxy=proxy,
|
||||
follow_redirects=follow_redirects,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
trust_env=trust_env,
|
||||
)
|
||||
|
||||
|
||||
def put(
|
||||
url: URL | str,
|
||||
*,
|
||||
content: RequestContent | None = None,
|
||||
data: RequestData | None = None,
|
||||
files: RequestFiles | None = None,
|
||||
json: typing.Any | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
headers: HeaderTypes | None = None,
|
||||
cookies: CookieTypes | None = None,
|
||||
auth: AuthTypes | None = None,
|
||||
proxy: ProxyTypes | None = None,
|
||||
follow_redirects: bool = False,
|
||||
verify: ssl.SSLContext | str | bool = True,
|
||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||
trust_env: bool = True,
|
||||
) -> Response:
|
||||
"""
|
||||
Sends a `PUT` request.
|
||||
|
||||
**Parameters**: See `httpx.request`.
|
||||
"""
|
||||
return request(
|
||||
"PUT",
|
||||
url,
|
||||
content=content,
|
||||
data=data,
|
||||
files=files,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
proxy=proxy,
|
||||
follow_redirects=follow_redirects,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
trust_env=trust_env,
|
||||
)
|
||||
|
||||
|
||||
def patch(
|
||||
url: URL | str,
|
||||
*,
|
||||
content: RequestContent | None = None,
|
||||
data: RequestData | None = None,
|
||||
files: RequestFiles | None = None,
|
||||
json: typing.Any | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
headers: HeaderTypes | None = None,
|
||||
cookies: CookieTypes | None = None,
|
||||
auth: AuthTypes | None = None,
|
||||
proxy: ProxyTypes | None = None,
|
||||
follow_redirects: bool = False,
|
||||
verify: ssl.SSLContext | str | bool = True,
|
||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||
trust_env: bool = True,
|
||||
) -> Response:
|
||||
"""
|
||||
Sends a `PATCH` request.
|
||||
|
||||
**Parameters**: See `httpx.request`.
|
||||
"""
|
||||
return request(
|
||||
"PATCH",
|
||||
url,
|
||||
content=content,
|
||||
data=data,
|
||||
files=files,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
proxy=proxy,
|
||||
follow_redirects=follow_redirects,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
trust_env=trust_env,
|
||||
)
|
||||
|
||||
|
||||
def delete(
|
||||
url: URL | str,
|
||||
*,
|
||||
params: QueryParamTypes | None = None,
|
||||
headers: HeaderTypes | None = None,
|
||||
cookies: CookieTypes | None = None,
|
||||
auth: AuthTypes | None = None,
|
||||
proxy: ProxyTypes | None = None,
|
||||
follow_redirects: bool = False,
|
||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||
verify: ssl.SSLContext | str | bool = True,
|
||||
trust_env: bool = True,
|
||||
) -> Response:
|
||||
"""
|
||||
Sends a `DELETE` request.
|
||||
|
||||
**Parameters**: See `httpx.request`.
|
||||
|
||||
Note that the `data`, `files`, `json` and `content` parameters are not available
|
||||
on this function, as `DELETE` requests should not include a request body.
|
||||
"""
|
||||
return request(
|
||||
"DELETE",
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
auth=auth,
|
||||
proxy=proxy,
|
||||
follow_redirects=follow_redirects,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
trust_env=trust_env,
|
||||
)
|
||||
348
httpx/_auth.py
Normal file
@ -0,0 +1,348 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import typing
|
||||
from base64 import b64encode
|
||||
from urllib.request import parse_http_list
|
||||
|
||||
from ._exceptions import ProtocolError
|
||||
from ._models import Cookies, Request, Response
|
||||
from ._utils import to_bytes, to_str, unquote
|
||||
|
||||
if typing.TYPE_CHECKING: # pragma: no cover
|
||||
from hashlib import _Hash
|
||||
|
||||
|
||||
__all__ = ["Auth", "BasicAuth", "DigestAuth", "FunctionAuth", "NetRCAuth"]
|
||||
|
||||
|
||||
class Auth:
|
||||
"""
|
||||
Base class for all authentication schemes.
|
||||
|
||||
To implement a custom authentication scheme, subclass `Auth` and override
|
||||
the `.auth_flow()` method.
|
||||
|
||||
If the authentication scheme does I/O such as disk access or network calls, or uses
|
||||
synchronization primitives such as locks, you should override `.sync_auth_flow()`
|
||||
and/or `.async_auth_flow()` instead of `.auth_flow()` to provide specialized
|
||||
implementations that will be used by `Client` and `AsyncClient` respectively.
|
||||
"""
|
||||
|
||||
requires_request_body = False
|
||||
requires_response_body = False
|
||||
|
||||
def auth_flow(self, request: Request) -> typing.Generator[Request, Response, None]:
|
||||
"""
|
||||
Execute the authentication flow.
|
||||
|
||||
To dispatch a request, `yield` it:
|
||||
|
||||
```
|
||||
yield request
|
||||
```
|
||||
|
||||
The client will `.send()` the response back into the flow generator. You can
|
||||
access it like so:
|
||||
|
||||
```
|
||||
response = yield request
|
||||
```
|
||||
|
||||
A `return` (or reaching the end of the generator) will result in the
|
||||
client returning the last response obtained from the server.
|
||||
|
||||
You can dispatch as many requests as is necessary.
|
||||
"""
|
||||
yield request
|
||||
|
||||
def sync_auth_flow(
|
||||
self, request: Request
|
||||
) -> typing.Generator[Request, Response, None]:
|
||||
"""
|
||||
Execute the authentication flow synchronously.
|
||||
|
||||
By default, this defers to `.auth_flow()`. You should override this method
|
||||
when the authentication scheme does I/O and/or uses concurrency primitives.
|
||||
"""
|
||||
if self.requires_request_body:
|
||||
request.read()
|
||||
|
||||
flow = self.auth_flow(request)
|
||||
request = next(flow)
|
||||
|
||||
while True:
|
||||
response = yield request
|
||||
if self.requires_response_body:
|
||||
response.read()
|
||||
|
||||
try:
|
||||
request = flow.send(response)
|
||||
except StopIteration:
|
||||
break
|
||||
|
||||
async def async_auth_flow(
|
||||
self, request: Request
|
||||
) -> typing.AsyncGenerator[Request, Response]:
|
||||
"""
|
||||
Execute the authentication flow asynchronously.
|
||||
|
||||
By default, this defers to `.auth_flow()`. You should override this method
|
||||
when the authentication scheme does I/O and/or uses concurrency primitives.
|
||||
"""
|
||||
if self.requires_request_body:
|
||||
await request.aread()
|
||||
|
||||
flow = self.auth_flow(request)
|
||||
request = next(flow)
|
||||
|
||||
while True:
|
||||
response = yield request
|
||||
if self.requires_response_body:
|
||||
await response.aread()
|
||||
|
||||
try:
|
||||
request = flow.send(response)
|
||||
except StopIteration:
|
||||
break
|
||||
|
||||
|
||||
class FunctionAuth(Auth):
|
||||
"""
|
||||
Allows the 'auth' argument to be passed as a simple callable function,
|
||||
that takes the request, and returns a new, modified request.
|
||||
"""
|
||||
|
||||
def __init__(self, func: typing.Callable[[Request], Request]) -> None:
|
||||
self._func = func
|
||||
|
||||
def auth_flow(self, request: Request) -> typing.Generator[Request, Response, None]:
|
||||
yield self._func(request)
|
||||
|
||||
|
||||
class BasicAuth(Auth):
|
||||
"""
|
||||
Allows the 'auth' argument to be passed as a (username, password) pair,
|
||||
and uses HTTP Basic authentication.
|
||||
"""
|
||||
|
||||
def __init__(self, username: str | bytes, password: str | bytes) -> None:
|
||||
self._auth_header = self._build_auth_header(username, password)
|
||||
|
||||
def auth_flow(self, request: Request) -> typing.Generator[Request, Response, None]:
|
||||
request.headers["Authorization"] = self._auth_header
|
||||
yield request
|
||||
|
||||
def _build_auth_header(self, username: str | bytes, password: str | bytes) -> str:
|
||||
userpass = b":".join((to_bytes(username), to_bytes(password)))
|
||||
token = b64encode(userpass).decode()
|
||||
return f"Basic {token}"
|
||||
|
||||
|
||||
class NetRCAuth(Auth):
|
||||
"""
|
||||
Use a 'netrc' file to lookup basic auth credentials based on the url host.
|
||||
"""
|
||||
|
||||
def __init__(self, file: str | None = None) -> None:
|
||||
# Lazily import 'netrc'.
|
||||
# There's no need for us to load this module unless 'NetRCAuth' is being used.
|
||||
import netrc
|
||||
|
||||
self._netrc_info = netrc.netrc(file)
|
||||
|
||||
def auth_flow(self, request: Request) -> typing.Generator[Request, Response, None]:
|
||||
auth_info = self._netrc_info.authenticators(request.url.host)
|
||||
if auth_info is None or not auth_info[2]:
|
||||
# The netrc file did not have authentication credentials for this host.
|
||||
yield request
|
||||
else:
|
||||
# Build a basic auth header with credentials from the netrc file.
|
||||
request.headers["Authorization"] = self._build_auth_header(
|
||||
username=auth_info[0], password=auth_info[2]
|
||||
)
|
||||
yield request
|
||||
|
||||
def _build_auth_header(self, username: str | bytes, password: str | bytes) -> str:
|
||||
userpass = b":".join((to_bytes(username), to_bytes(password)))
|
||||
token = b64encode(userpass).decode()
|
||||
return f"Basic {token}"
|
||||
|
||||
|
||||
class DigestAuth(Auth):
|
||||
_ALGORITHM_TO_HASH_FUNCTION: dict[str, typing.Callable[[bytes], _Hash]] = {
|
||||
"MD5": hashlib.md5,
|
||||
"MD5-SESS": hashlib.md5,
|
||||
"SHA": hashlib.sha1,
|
||||
"SHA-SESS": hashlib.sha1,
|
||||
"SHA-256": hashlib.sha256,
|
||||
"SHA-256-SESS": hashlib.sha256,
|
||||
"SHA-512": hashlib.sha512,
|
||||
"SHA-512-SESS": hashlib.sha512,
|
||||
}
|
||||
|
||||
def __init__(self, username: str | bytes, password: str | bytes) -> None:
|
||||
self._username = to_bytes(username)
|
||||
self._password = to_bytes(password)
|
||||
self._last_challenge: _DigestAuthChallenge | None = None
|
||||
self._nonce_count = 1
|
||||
|
||||
def auth_flow(self, request: Request) -> typing.Generator[Request, Response, None]:
|
||||
if self._last_challenge:
|
||||
request.headers["Authorization"] = self._build_auth_header(
|
||||
request, self._last_challenge
|
||||
)
|
||||
|
||||
response = yield request
|
||||
|
||||
if response.status_code != 401 or "www-authenticate" not in response.headers:
|
||||
# If the response is not a 401 then we don't
|
||||
# need to build an authenticated request.
|
||||
return
|
||||
|
||||
for auth_header in response.headers.get_list("www-authenticate"):
|
||||
if auth_header.lower().startswith("digest "):
|
||||
break
|
||||
else:
|
||||
# If the response does not include a 'WWW-Authenticate: Digest ...'
|
||||
# header, then we don't need to build an authenticated request.
|
||||
return
|
||||
|
||||
self._last_challenge = self._parse_challenge(request, response, auth_header)
|
||||
self._nonce_count = 1
|
||||
|
||||
request.headers["Authorization"] = self._build_auth_header(
|
||||
request, self._last_challenge
|
||||
)
|
||||
if response.cookies:
|
||||
Cookies(response.cookies).set_cookie_header(request=request)
|
||||
yield request
|
||||
|
||||
def _parse_challenge(
|
||||
self, request: Request, response: Response, auth_header: str
|
||||
) -> _DigestAuthChallenge:
|
||||
"""
|
||||
Returns a challenge from a Digest WWW-Authenticate header.
|
||||
These take the form of:
|
||||
`Digest realm="realm@host.com",qop="auth,auth-int",nonce="abc",opaque="xyz"`
|
||||
"""
|
||||
scheme, _, fields = auth_header.partition(" ")
|
||||
|
||||
# This method should only ever have been called with a Digest auth header.
|
||||
assert scheme.lower() == "digest"
|
||||
|
||||
header_dict: dict[str, str] = {}
|
||||
for field in parse_http_list(fields):
|
||||
key, value = field.strip().split("=", 1)
|
||||
header_dict[key] = unquote(value)
|
||||
|
||||
try:
|
||||
realm = header_dict["realm"].encode()
|
||||
nonce = header_dict["nonce"].encode()
|
||||
algorithm = header_dict.get("algorithm", "MD5")
|
||||
opaque = header_dict["opaque"].encode() if "opaque" in header_dict else None
|
||||
qop = header_dict["qop"].encode() if "qop" in header_dict else None
|
||||
return _DigestAuthChallenge(
|
||||
realm=realm, nonce=nonce, algorithm=algorithm, opaque=opaque, qop=qop
|
||||
)
|
||||
except KeyError as exc:
|
||||
message = "Malformed Digest WWW-Authenticate header"
|
||||
raise ProtocolError(message, request=request) from exc
|
||||
|
||||
def _build_auth_header(
|
||||
self, request: Request, challenge: _DigestAuthChallenge
|
||||
) -> str:
|
||||
hash_func = self._ALGORITHM_TO_HASH_FUNCTION[challenge.algorithm.upper()]
|
||||
|
||||
def digest(data: bytes) -> bytes:
|
||||
return hash_func(data).hexdigest().encode()
|
||||
|
||||
A1 = b":".join((self._username, challenge.realm, self._password))
|
||||
|
||||
path = request.url.raw_path
|
||||
A2 = b":".join((request.method.encode(), path))
|
||||
# TODO: implement auth-int
|
||||
HA2 = digest(A2)
|
||||
|
||||
nc_value = b"%08x" % self._nonce_count
|
||||
cnonce = self._get_client_nonce(self._nonce_count, challenge.nonce)
|
||||
self._nonce_count += 1
|
||||
|
||||
HA1 = digest(A1)
|
||||
if challenge.algorithm.lower().endswith("-sess"):
|
||||
HA1 = digest(b":".join((HA1, challenge.nonce, cnonce)))
|
||||
|
||||
qop = self._resolve_qop(challenge.qop, request=request)
|
||||
if qop is None:
|
||||
# Following RFC 2069
|
||||
digest_data = [HA1, challenge.nonce, HA2]
|
||||
else:
|
||||
# Following RFC 2617/7616
|
||||
digest_data = [HA1, challenge.nonce, nc_value, cnonce, qop, HA2]
|
||||
|
||||
format_args = {
|
||||
"username": self._username,
|
||||
"realm": challenge.realm,
|
||||
"nonce": challenge.nonce,
|
||||
"uri": path,
|
||||
"response": digest(b":".join(digest_data)),
|
||||
"algorithm": challenge.algorithm.encode(),
|
||||
}
|
||||
if challenge.opaque:
|
||||
format_args["opaque"] = challenge.opaque
|
||||
if qop:
|
||||
format_args["qop"] = b"auth"
|
||||
format_args["nc"] = nc_value
|
||||
format_args["cnonce"] = cnonce
|
||||
|
||||
return "Digest " + self._get_header_value(format_args)
|
||||
|
||||
def _get_client_nonce(self, nonce_count: int, nonce: bytes) -> bytes:
|
||||
s = str(nonce_count).encode()
|
||||
s += nonce
|
||||
s += time.ctime().encode()
|
||||
s += os.urandom(8)
|
||||
|
||||
return hashlib.sha1(s).hexdigest()[:16].encode()
|
||||
|
||||
def _get_header_value(self, header_fields: dict[str, bytes]) -> str:
|
||||
NON_QUOTED_FIELDS = ("algorithm", "qop", "nc")
|
||||
QUOTED_TEMPLATE = '{}="{}"'
|
||||
NON_QUOTED_TEMPLATE = "{}={}"
|
||||
|
||||
header_value = ""
|
||||
for i, (field, value) in enumerate(header_fields.items()):
|
||||
if i > 0:
|
||||
header_value += ", "
|
||||
template = (
|
||||
QUOTED_TEMPLATE
|
||||
if field not in NON_QUOTED_FIELDS
|
||||
else NON_QUOTED_TEMPLATE
|
||||
)
|
||||
header_value += template.format(field, to_str(value))
|
||||
|
||||
return header_value
|
||||
|
||||
def _resolve_qop(self, qop: bytes | None, request: Request) -> bytes | None:
|
||||
if qop is None:
|
||||
return None
|
||||
qops = re.split(b", ?", qop)
|
||||
if b"auth" in qops:
|
||||
return b"auth"
|
||||
|
||||
if qops == [b"auth-int"]:
|
||||
raise NotImplementedError("Digest auth-int support is not yet implemented")
|
||||
|
||||
message = f'Unexpected qop value "{qop!r}" in digest auth'
|
||||
raise ProtocolError(message, request=request)
|
||||
|
||||
|
||||
class _DigestAuthChallenge(typing.NamedTuple):
|
||||
realm: bytes
|
||||
nonce: bytes
|
||||
algorithm: str
|
||||
opaque: bytes | None
|
||||
qop: bytes | None
|
||||
2019
httpx/_client.py
Normal file
248
httpx/_config.py
Normal file
@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import typing
|
||||
|
||||
from ._models import Headers
|
||||
from ._types import CertTypes, HeaderTypes, TimeoutTypes
|
||||
from ._urls import URL
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import ssl # pragma: no cover
|
||||
|
||||
__all__ = ["Limits", "Proxy", "Timeout", "create_ssl_context"]
|
||||
|
||||
|
||||
class UnsetType:
|
||||
pass # pragma: no cover
|
||||
|
||||
|
||||
UNSET = UnsetType()
|
||||
|
||||
|
||||
def create_ssl_context(
|
||||
verify: ssl.SSLContext | str | bool = True,
|
||||
cert: CertTypes | None = None,
|
||||
trust_env: bool = True,
|
||||
) -> ssl.SSLContext:
|
||||
import ssl
|
||||
import warnings
|
||||
|
||||
import certifi
|
||||
|
||||
if verify is True:
|
||||
if trust_env and os.environ.get("SSL_CERT_FILE"): # pragma: nocover
|
||||
ctx = ssl.create_default_context(cafile=os.environ["SSL_CERT_FILE"])
|
||||
elif trust_env and os.environ.get("SSL_CERT_DIR"): # pragma: nocover
|
||||
ctx = ssl.create_default_context(capath=os.environ["SSL_CERT_DIR"])
|
||||
else:
|
||||
# Default case...
|
||||
ctx = ssl.create_default_context(cafile=certifi.where())
|
||||
elif verify is False:
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
elif isinstance(verify, str): # pragma: nocover
|
||||
message = (
|
||||
"`verify=<str>` is deprecated. "
|
||||
"Use `verify=ssl.create_default_context(cafile=...)` "
|
||||
"or `verify=ssl.create_default_context(capath=...)` instead."
|
||||
)
|
||||
warnings.warn(message, DeprecationWarning)
|
||||
if os.path.isdir(verify):
|
||||
return ssl.create_default_context(capath=verify)
|
||||
return ssl.create_default_context(cafile=verify)
|
||||
else:
|
||||
ctx = verify
|
||||
|
||||
if cert: # pragma: nocover
|
||||
message = (
|
||||
"`cert=...` is deprecated. Use `verify=<ssl_context>` instead,"
|
||||
"with `.load_cert_chain()` to configure the certificate chain."
|
||||
)
|
||||
warnings.warn(message, DeprecationWarning)
|
||||
if isinstance(cert, str):
|
||||
ctx.load_cert_chain(cert)
|
||||
else:
|
||||
ctx.load_cert_chain(*cert)
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class Timeout:
|
||||
"""
|
||||
Timeout configuration.
|
||||
|
||||
**Usage**:
|
||||
|
||||
Timeout(None) # No timeouts.
|
||||
Timeout(5.0) # 5s timeout on all operations.
|
||||
Timeout(None, connect=5.0) # 5s timeout on connect, no other timeouts.
|
||||
Timeout(5.0, connect=10.0) # 10s timeout on connect. 5s timeout elsewhere.
|
||||
Timeout(5.0, pool=None) # No timeout on acquiring connection from pool.
|
||||
# 5s timeout elsewhere.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
timeout: TimeoutTypes | UnsetType = UNSET,
|
||||
*,
|
||||
connect: None | float | UnsetType = UNSET,
|
||||
read: None | float | UnsetType = UNSET,
|
||||
write: None | float | UnsetType = UNSET,
|
||||
pool: None | float | UnsetType = UNSET,
|
||||
) -> None:
|
||||
if isinstance(timeout, Timeout):
|
||||
# Passed as a single explicit Timeout.
|
||||
assert connect is UNSET
|
||||
assert read is UNSET
|
||||
assert write is UNSET
|
||||
assert pool is UNSET
|
||||
self.connect = timeout.connect # type: typing.Optional[float]
|
||||
self.read = timeout.read # type: typing.Optional[float]
|
||||
self.write = timeout.write # type: typing.Optional[float]
|
||||
self.pool = timeout.pool # type: typing.Optional[float]
|
||||
elif isinstance(timeout, tuple):
|
||||
# Passed as a tuple.
|
||||
self.connect = timeout[0]
|
||||
self.read = timeout[1]
|
||||
self.write = None if len(timeout) < 3 else timeout[2]
|
||||
self.pool = None if len(timeout) < 4 else timeout[3]
|
||||
elif not (
|
||||
isinstance(connect, UnsetType)
|
||||
or isinstance(read, UnsetType)
|
||||
or isinstance(write, UnsetType)
|
||||
or isinstance(pool, UnsetType)
|
||||
):
|
||||
self.connect = connect
|
||||
self.read = read
|
||||
self.write = write
|
||||
self.pool = pool
|
||||
else:
|
||||
if isinstance(timeout, UnsetType):
|
||||
raise ValueError(
|
||||
"httpx.Timeout must either include a default, or set all "
|
||||
"four parameters explicitly."
|
||||
)
|
||||
self.connect = timeout if isinstance(connect, UnsetType) else connect
|
||||
self.read = timeout if isinstance(read, UnsetType) else read
|
||||
self.write = timeout if isinstance(write, UnsetType) else write
|
||||
self.pool = timeout if isinstance(pool, UnsetType) else pool
|
||||
|
||||
def as_dict(self) -> dict[str, float | None]:
|
||||
return {
|
||||
"connect": self.connect,
|
||||
"read": self.read,
|
||||
"write": self.write,
|
||||
"pool": self.pool,
|
||||
}
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
return (
|
||||
isinstance(other, self.__class__)
|
||||
and self.connect == other.connect
|
||||
and self.read == other.read
|
||||
and self.write == other.write
|
||||
and self.pool == other.pool
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
class_name = self.__class__.__name__
|
||||
if len({self.connect, self.read, self.write, self.pool}) == 1:
|
||||
return f"{class_name}(timeout={self.connect})"
|
||||
return (
|
||||
f"{class_name}(connect={self.connect}, "
|
||||
f"read={self.read}, write={self.write}, pool={self.pool})"
|
||||
)
|
||||
|
||||
|
||||
class Limits:
|
||||
"""
|
||||
Configuration for limits to various client behaviors.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
* **max_connections** - The maximum number of concurrent connections that may be
|
||||
established.
|
||||
* **max_keepalive_connections** - Allow the connection pool to maintain
|
||||
keep-alive connections below this point. Should be less than or equal
|
||||
to `max_connections`.
|
||||
* **keepalive_expiry** - Time limit on idle keep-alive connections in seconds.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_connections: int | None = None,
|
||||
max_keepalive_connections: int | None = None,
|
||||
keepalive_expiry: float | None = 5.0,
|
||||
) -> None:
|
||||
self.max_connections = max_connections
|
||||
self.max_keepalive_connections = max_keepalive_connections
|
||||
self.keepalive_expiry = keepalive_expiry
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
return (
|
||||
isinstance(other, self.__class__)
|
||||
and self.max_connections == other.max_connections
|
||||
and self.max_keepalive_connections == other.max_keepalive_connections
|
||||
and self.keepalive_expiry == other.keepalive_expiry
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
class_name = self.__class__.__name__
|
||||
return (
|
||||
f"{class_name}(max_connections={self.max_connections}, "
|
||||
f"max_keepalive_connections={self.max_keepalive_connections}, "
|
||||
f"keepalive_expiry={self.keepalive_expiry})"
|
||||
)
|
||||
|
||||
|
||||
class Proxy:
|
||||
def __init__(
|
||||
self,
|
||||
url: URL | str,
|
||||
*,
|
||||
ssl_context: ssl.SSLContext | None = None,
|
||||
auth: tuple[str, str] | None = None,
|
||||
headers: HeaderTypes | None = None,
|
||||
) -> None:
|
||||
url = URL(url)
|
||||
headers = Headers(headers)
|
||||
|
||||
if url.scheme not in ("http", "https", "socks5", "socks5h"):
|
||||
raise ValueError(f"Unknown scheme for proxy URL {url!r}")
|
||||
|
||||
if url.username or url.password:
|
||||
# Remove any auth credentials from the URL.
|
||||
auth = (url.username, url.password)
|
||||
url = url.copy_with(username=None, password=None)
|
||||
|
||||
self.url = url
|
||||
self.auth = auth
|
||||
self.headers = headers
|
||||
self.ssl_context = ssl_context
|
||||
|
||||
@property
|
||||
def raw_auth(self) -> tuple[bytes, bytes] | None:
|
||||
# The proxy authentication as raw bytes.
|
||||
return (
|
||||
None
|
||||
if self.auth is None
|
||||
else (self.auth[0].encode("utf-8"), self.auth[1].encode("utf-8"))
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
# The authentication is represented with the password component masked.
|
||||
auth = (self.auth[0], "********") if self.auth else None
|
||||
|
||||
# Build a nice concise representation.
|
||||
url_str = f"{str(self.url)!r}"
|
||||
auth_str = f", auth={auth!r}" if auth else ""
|
||||
headers_str = f", headers={dict(self.headers)!r}" if self.headers else ""
|
||||
return f"Proxy({url_str}{auth_str}{headers_str})"
|
||||
|
||||
|
||||
DEFAULT_TIMEOUT_CONFIG = Timeout(timeout=5.0)
|
||||
DEFAULT_LIMITS = Limits(max_connections=100, max_keepalive_connections=20)
|
||||
DEFAULT_MAX_REDIRECTS = 20
|
||||
240
httpx/_content.py
Normal file
@ -0,0 +1,240 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import warnings
|
||||
from json import dumps as json_dumps
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncIterable,
|
||||
AsyncIterator,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Mapping,
|
||||
)
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from ._exceptions import StreamClosed, StreamConsumed
|
||||
from ._multipart import MultipartStream
|
||||
from ._types import (
|
||||
AsyncByteStream,
|
||||
RequestContent,
|
||||
RequestData,
|
||||
RequestFiles,
|
||||
ResponseContent,
|
||||
SyncByteStream,
|
||||
)
|
||||
from ._utils import peek_filelike_length, primitive_value_to_str
|
||||
|
||||
__all__ = ["ByteStream"]
|
||||
|
||||
|
||||
class ByteStream(AsyncByteStream, SyncByteStream):
|
||||
def __init__(self, stream: bytes) -> None:
|
||||
self._stream = stream
|
||||
|
||||
def __iter__(self) -> Iterator[bytes]:
|
||||
yield self._stream
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
yield self._stream
|
||||
|
||||
|
||||
class IteratorByteStream(SyncByteStream):
|
||||
CHUNK_SIZE = 65_536
|
||||
|
||||
def __init__(self, stream: Iterable[bytes]) -> None:
|
||||
self._stream = stream
|
||||
self._is_stream_consumed = False
|
||||
self._is_generator = inspect.isgenerator(stream)
|
||||
|
||||
def __iter__(self) -> Iterator[bytes]:
|
||||
if self._is_stream_consumed and self._is_generator:
|
||||
raise StreamConsumed()
|
||||
|
||||
self._is_stream_consumed = True
|
||||
if hasattr(self._stream, "read"):
|
||||
# File-like interfaces should use 'read' directly.
|
||||
chunk = self._stream.read(self.CHUNK_SIZE)
|
||||
while chunk:
|
||||
yield chunk
|
||||
chunk = self._stream.read(self.CHUNK_SIZE)
|
||||
else:
|
||||
# Otherwise iterate.
|
||||
for part in self._stream:
|
||||
yield part
|
||||
|
||||
|
||||
class AsyncIteratorByteStream(AsyncByteStream):
|
||||
CHUNK_SIZE = 65_536
|
||||
|
||||
def __init__(self, stream: AsyncIterable[bytes]) -> None:
|
||||
self._stream = stream
|
||||
self._is_stream_consumed = False
|
||||
self._is_generator = inspect.isasyncgen(stream)
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
if self._is_stream_consumed and self._is_generator:
|
||||
raise StreamConsumed()
|
||||
|
||||
self._is_stream_consumed = True
|
||||
if hasattr(self._stream, "aread"):
|
||||
# File-like interfaces should use 'aread' directly.
|
||||
chunk = await self._stream.aread(self.CHUNK_SIZE)
|
||||
while chunk:
|
||||
yield chunk
|
||||
chunk = await self._stream.aread(self.CHUNK_SIZE)
|
||||
else:
|
||||
# Otherwise iterate.
|
||||
async for part in self._stream:
|
||||
yield part
|
||||
|
||||
|
||||
class UnattachedStream(AsyncByteStream, SyncByteStream):
|
||||
"""
|
||||
If a request or response is serialized using pickle, then it is no longer
|
||||
attached to a stream for I/O purposes. Any stream operations should result
|
||||
in `httpx.StreamClosed`.
|
||||
"""
|
||||
|
||||
def __iter__(self) -> Iterator[bytes]:
|
||||
raise StreamClosed()
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
raise StreamClosed()
|
||||
yield b"" # pragma: no cover
|
||||
|
||||
|
||||
def encode_content(
|
||||
content: str | bytes | Iterable[bytes] | AsyncIterable[bytes],
|
||||
) -> tuple[dict[str, str], SyncByteStream | AsyncByteStream]:
|
||||
if isinstance(content, (bytes, str)):
|
||||
body = content.encode("utf-8") if isinstance(content, str) else content
|
||||
content_length = len(body)
|
||||
headers = {"Content-Length": str(content_length)} if body else {}
|
||||
return headers, ByteStream(body)
|
||||
|
||||
elif isinstance(content, Iterable) and not isinstance(content, dict):
|
||||
# `not isinstance(content, dict)` is a bit oddly specific, but it
|
||||
# catches a case that's easy for users to make in error, and would
|
||||
# otherwise pass through here, like any other bytes-iterable,
|
||||
# because `dict` happens to be iterable. See issue #2491.
|
||||
content_length_or_none = peek_filelike_length(content)
|
||||
|
||||
if content_length_or_none is None:
|
||||
headers = {"Transfer-Encoding": "chunked"}
|
||||
else:
|
||||
headers = {"Content-Length": str(content_length_or_none)}
|
||||
return headers, IteratorByteStream(content) # type: ignore
|
||||
|
||||
elif isinstance(content, AsyncIterable):
|
||||
headers = {"Transfer-Encoding": "chunked"}
|
||||
return headers, AsyncIteratorByteStream(content)
|
||||
|
||||
raise TypeError(f"Unexpected type for 'content', {type(content)!r}")
|
||||
|
||||
|
||||
def encode_urlencoded_data(
|
||||
data: RequestData,
|
||||
) -> tuple[dict[str, str], ByteStream]:
|
||||
plain_data = []
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (list, tuple)):
|
||||
plain_data.extend([(key, primitive_value_to_str(item)) for item in value])
|
||||
else:
|
||||
plain_data.append((key, primitive_value_to_str(value)))
|
||||
body = urlencode(plain_data, doseq=True).encode("utf-8")
|
||||
content_length = str(len(body))
|
||||
content_type = "application/x-www-form-urlencoded"
|
||||
headers = {"Content-Length": content_length, "Content-Type": content_type}
|
||||
return headers, ByteStream(body)
|
||||
|
||||
|
||||
def encode_multipart_data(
|
||||
data: RequestData, files: RequestFiles, boundary: bytes | None
|
||||
) -> tuple[dict[str, str], MultipartStream]:
|
||||
multipart = MultipartStream(data=data, files=files, boundary=boundary)
|
||||
headers = multipart.get_headers()
|
||||
return headers, multipart
|
||||
|
||||
|
||||
def encode_text(text: str) -> tuple[dict[str, str], ByteStream]:
|
||||
body = text.encode("utf-8")
|
||||
content_length = str(len(body))
|
||||
content_type = "text/plain; charset=utf-8"
|
||||
headers = {"Content-Length": content_length, "Content-Type": content_type}
|
||||
return headers, ByteStream(body)
|
||||
|
||||
|
||||
def encode_html(html: str) -> tuple[dict[str, str], ByteStream]:
|
||||
body = html.encode("utf-8")
|
||||
content_length = str(len(body))
|
||||
content_type = "text/html; charset=utf-8"
|
||||
headers = {"Content-Length": content_length, "Content-Type": content_type}
|
||||
return headers, ByteStream(body)
|
||||
|
||||
|
||||
def encode_json(json: Any) -> tuple[dict[str, str], ByteStream]:
|
||||
body = json_dumps(
|
||||
json, ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
||||
).encode("utf-8")
|
||||
content_length = str(len(body))
|
||||
content_type = "application/json"
|
||||
headers = {"Content-Length": content_length, "Content-Type": content_type}
|
||||
return headers, ByteStream(body)
|
||||
|
||||
|
||||
def encode_request(
|
||||
content: RequestContent | None = None,
|
||||
data: RequestData | None = None,
|
||||
files: RequestFiles | None = None,
|
||||
json: Any | None = None,
|
||||
boundary: bytes | None = None,
|
||||
) -> tuple[dict[str, str], SyncByteStream | AsyncByteStream]:
|
||||
"""
|
||||
Handles encoding the given `content`, `data`, `files`, and `json`,
|
||||
returning a two-tuple of (<headers>, <stream>).
|
||||
"""
|
||||
if data is not None and not isinstance(data, Mapping):
|
||||
# We prefer to separate `content=<bytes|str|byte iterator|bytes aiterator>`
|
||||
# for raw request content, and `data=<form data>` for url encoded or
|
||||
# multipart form content.
|
||||
#
|
||||
# However for compat with requests, we *do* still support
|
||||
# `data=<bytes...>` usages. We deal with that case here, treating it
|
||||
# as if `content=<...>` had been supplied instead.
|
||||
message = "Use 'content=<...>' to upload raw bytes/text content."
|
||||
warnings.warn(message, DeprecationWarning, stacklevel=2)
|
||||
return encode_content(data)
|
||||
|
||||
if content is not None:
|
||||
return encode_content(content)
|
||||
elif files:
|
||||
return encode_multipart_data(data or {}, files, boundary)
|
||||
elif data:
|
||||
return encode_urlencoded_data(data)
|
||||
elif json is not None:
|
||||
return encode_json(json)
|
||||
|
||||
return {}, ByteStream(b"")
|
||||
|
||||
|
||||
def encode_response(
|
||||
content: ResponseContent | None = None,
|
||||
text: str | None = None,
|
||||
html: str | None = None,
|
||||
json: Any | None = None,
|
||||
) -> tuple[dict[str, str], SyncByteStream | AsyncByteStream]:
|
||||
"""
|
||||
Handles encoding the given `content`, returning a two-tuple of
|
||||
(<headers>, <stream>).
|
||||
"""
|
||||
if content is not None:
|
||||
return encode_content(content)
|
||||
elif text is not None:
|
||||
return encode_text(text)
|
||||
elif html is not None:
|
||||
return encode_html(html)
|
||||
elif json is not None:
|
||||
return encode_json(json)
|
||||
|
||||
return {}, ByteStream(b"")
|
||||
393
httpx/_decoders.py
Normal file
@ -0,0 +1,393 @@
|
||||
"""
|
||||
Handlers for Content-Encoding.
|
||||
|
||||
See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import io
|
||||
import typing
|
||||
import zlib
|
||||
|
||||
from ._exceptions import DecodingError
|
||||
|
||||
# Brotli support is optional
|
||||
try:
|
||||
# The C bindings in `brotli` are recommended for CPython.
|
||||
import brotli
|
||||
except ImportError: # pragma: no cover
|
||||
try:
|
||||
# The CFFI bindings in `brotlicffi` are recommended for PyPy
|
||||
# and other environments.
|
||||
import brotlicffi as brotli
|
||||
except ImportError:
|
||||
brotli = None
|
||||
|
||||
|
||||
# Zstandard support is optional
|
||||
try:
|
||||
import zstandard
|
||||
except ImportError: # pragma: no cover
|
||||
zstandard = None # type: ignore
|
||||
|
||||
|
||||
class ContentDecoder:
|
||||
def decode(self, data: bytes) -> bytes:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
def flush(self) -> bytes:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
|
||||
class IdentityDecoder(ContentDecoder):
|
||||
"""
|
||||
Handle unencoded data.
|
||||
"""
|
||||
|
||||
def decode(self, data: bytes) -> bytes:
|
||||
return data
|
||||
|
||||
def flush(self) -> bytes:
|
||||
return b""
|
||||
|
||||
|
||||
class DeflateDecoder(ContentDecoder):
|
||||
"""
|
||||
Handle 'deflate' decoding.
|
||||
|
||||
See: https://stackoverflow.com/questions/1838699
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.first_attempt = True
|
||||
self.decompressor = zlib.decompressobj()
|
||||
|
||||
def decode(self, data: bytes) -> bytes:
|
||||
was_first_attempt = self.first_attempt
|
||||
self.first_attempt = False
|
||||
try:
|
||||
return self.decompressor.decompress(data)
|
||||
except zlib.error as exc:
|
||||
if was_first_attempt:
|
||||
self.decompressor = zlib.decompressobj(-zlib.MAX_WBITS)
|
||||
return self.decode(data)
|
||||
raise DecodingError(str(exc)) from exc
|
||||
|
||||
def flush(self) -> bytes:
|
||||
try:
|
||||
return self.decompressor.flush()
|
||||
except zlib.error as exc: # pragma: no cover
|
||||
raise DecodingError(str(exc)) from exc
|
||||
|
||||
|
||||
class GZipDecoder(ContentDecoder):
|
||||
"""
|
||||
Handle 'gzip' decoding.
|
||||
|
||||
See: https://stackoverflow.com/questions/1838699
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.decompressor = zlib.decompressobj(zlib.MAX_WBITS | 16)
|
||||
|
||||
def decode(self, data: bytes) -> bytes:
|
||||
try:
|
||||
return self.decompressor.decompress(data)
|
||||
except zlib.error as exc:
|
||||
raise DecodingError(str(exc)) from exc
|
||||
|
||||
def flush(self) -> bytes:
|
||||
try:
|
||||
return self.decompressor.flush()
|
||||
except zlib.error as exc: # pragma: no cover
|
||||
raise DecodingError(str(exc)) from exc
|
||||
|
||||
|
||||
class BrotliDecoder(ContentDecoder):
|
||||
"""
|
||||
Handle 'brotli' decoding.
|
||||
|
||||
Requires `pip install brotlipy`. See: https://brotlipy.readthedocs.io/
|
||||
or `pip install brotli`. See https://github.com/google/brotli
|
||||
Supports both 'brotlipy' and 'Brotli' packages since they share an import
|
||||
name. The top branches are for 'brotlipy' and bottom branches for 'Brotli'
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
if brotli is None: # pragma: no cover
|
||||
raise ImportError(
|
||||
"Using 'BrotliDecoder', but neither of the 'brotlicffi' or 'brotli' "
|
||||
"packages have been installed. "
|
||||
"Make sure to install httpx using `pip install httpx[brotli]`."
|
||||
) from None
|
||||
|
||||
self.decompressor = brotli.Decompressor()
|
||||
self.seen_data = False
|
||||
self._decompress: typing.Callable[[bytes], bytes]
|
||||
if hasattr(self.decompressor, "decompress"):
|
||||
# The 'brotlicffi' package.
|
||||
self._decompress = self.decompressor.decompress # pragma: no cover
|
||||
else:
|
||||
# The 'brotli' package.
|
||||
self._decompress = self.decompressor.process # pragma: no cover
|
||||
|
||||
def decode(self, data: bytes) -> bytes:
|
||||
if not data:
|
||||
return b""
|
||||
self.seen_data = True
|
||||
try:
|
||||
return self._decompress(data)
|
||||
except brotli.error as exc:
|
||||
raise DecodingError(str(exc)) from exc
|
||||
|
||||
def flush(self) -> bytes:
|
||||
if not self.seen_data:
|
||||
return b""
|
||||
try:
|
||||
if hasattr(self.decompressor, "finish"):
|
||||
# Only available in the 'brotlicffi' package.
|
||||
|
||||
# As the decompressor decompresses eagerly, this
|
||||
# will never actually emit any data. However, it will potentially throw
|
||||
# errors if a truncated or damaged data stream has been used.
|
||||
self.decompressor.finish() # pragma: no cover
|
||||
return b""
|
||||
except brotli.error as exc: # pragma: no cover
|
||||
raise DecodingError(str(exc)) from exc
|
||||
|
||||
|
||||
class ZStandardDecoder(ContentDecoder):
|
||||
"""
|
||||
Handle 'zstd' RFC 8878 decoding.
|
||||
|
||||
Requires `pip install zstandard`.
|
||||
Can be installed as a dependency of httpx using `pip install httpx[zstd]`.
|
||||
"""
|
||||
|
||||
# inspired by the ZstdDecoder implementation in urllib3
|
||||
def __init__(self) -> None:
|
||||
if zstandard is None: # pragma: no cover
|
||||
raise ImportError(
|
||||
"Using 'ZStandardDecoder', ..."
|
||||
"Make sure to install httpx using `pip install httpx[zstd]`."
|
||||
) from None
|
||||
|
||||
self.decompressor = zstandard.ZstdDecompressor().decompressobj()
|
||||
self.seen_data = False
|
||||
|
||||
def decode(self, data: bytes) -> bytes:
|
||||
assert zstandard is not None
|
||||
self.seen_data = True
|
||||
output = io.BytesIO()
|
||||
try:
|
||||
output.write(self.decompressor.decompress(data))
|
||||
while self.decompressor.eof and self.decompressor.unused_data:
|
||||
unused_data = self.decompressor.unused_data
|
||||
self.decompressor = zstandard.ZstdDecompressor().decompressobj()
|
||||
output.write(self.decompressor.decompress(unused_data))
|
||||
except zstandard.ZstdError as exc:
|
||||
raise DecodingError(str(exc)) from exc
|
||||
return output.getvalue()
|
||||
|
||||
def flush(self) -> bytes:
|
||||
if not self.seen_data:
|
||||
return b""
|
||||
ret = self.decompressor.flush() # note: this is a no-op
|
||||
if not self.decompressor.eof:
|
||||
raise DecodingError("Zstandard data is incomplete") # pragma: no cover
|
||||
return bytes(ret)
|
||||
|
||||
|
||||
class MultiDecoder(ContentDecoder):
|
||||
"""
|
||||
Handle the case where multiple encodings have been applied.
|
||||
"""
|
||||
|
||||
def __init__(self, children: typing.Sequence[ContentDecoder]) -> None:
|
||||
"""
|
||||
'children' should be a sequence of decoders in the order in which
|
||||
each was applied.
|
||||
"""
|
||||
# Note that we reverse the order for decoding.
|
||||
self.children = list(reversed(children))
|
||||
|
||||
def decode(self, data: bytes) -> bytes:
|
||||
for child in self.children:
|
||||
data = child.decode(data)
|
||||
return data
|
||||
|
||||
def flush(self) -> bytes:
|
||||
data = b""
|
||||
for child in self.children:
|
||||
data = child.decode(data) + child.flush()
|
||||
return data
|
||||
|
||||
|
||||
class ByteChunker:
|
||||
"""
|
||||
Handles returning byte content in fixed-size chunks.
|
||||
"""
|
||||
|
||||
def __init__(self, chunk_size: int | None = None) -> None:
|
||||
self._buffer = io.BytesIO()
|
||||
self._chunk_size = chunk_size
|
||||
|
||||
def decode(self, content: bytes) -> list[bytes]:
|
||||
if self._chunk_size is None:
|
||||
return [content] if content else []
|
||||
|
||||
self._buffer.write(content)
|
||||
if self._buffer.tell() >= self._chunk_size:
|
||||
value = self._buffer.getvalue()
|
||||
chunks = [
|
||||
value[i : i + self._chunk_size]
|
||||
for i in range(0, len(value), self._chunk_size)
|
||||
]
|
||||
if len(chunks[-1]) == self._chunk_size:
|
||||
self._buffer.seek(0)
|
||||
self._buffer.truncate()
|
||||
return chunks
|
||||
else:
|
||||
self._buffer.seek(0)
|
||||
self._buffer.write(chunks[-1])
|
||||
self._buffer.truncate()
|
||||
return chunks[:-1]
|
||||
else:
|
||||
return []
|
||||
|
||||
def flush(self) -> list[bytes]:
|
||||
value = self._buffer.getvalue()
|
||||
self._buffer.seek(0)
|
||||
self._buffer.truncate()
|
||||
return [value] if value else []
|
||||
|
||||
|
||||
class TextChunker:
|
||||
"""
|
||||
Handles returning text content in fixed-size chunks.
|
||||
"""
|
||||
|
||||
def __init__(self, chunk_size: int | None = None) -> None:
|
||||
self._buffer = io.StringIO()
|
||||
self._chunk_size = chunk_size
|
||||
|
||||
def decode(self, content: str) -> list[str]:
|
||||
if self._chunk_size is None:
|
||||
return [content] if content else []
|
||||
|
||||
self._buffer.write(content)
|
||||
if self._buffer.tell() >= self._chunk_size:
|
||||
value = self._buffer.getvalue()
|
||||
chunks = [
|
||||
value[i : i + self._chunk_size]
|
||||
for i in range(0, len(value), self._chunk_size)
|
||||
]
|
||||
if len(chunks[-1]) == self._chunk_size:
|
||||
self._buffer.seek(0)
|
||||
self._buffer.truncate()
|
||||
return chunks
|
||||
else:
|
||||
self._buffer.seek(0)
|
||||
self._buffer.write(chunks[-1])
|
||||
self._buffer.truncate()
|
||||
return chunks[:-1]
|
||||
else:
|
||||
return []
|
||||
|
||||
def flush(self) -> list[str]:
|
||||
value = self._buffer.getvalue()
|
||||
self._buffer.seek(0)
|
||||
self._buffer.truncate()
|
||||
return [value] if value else []
|
||||
|
||||
|
||||
class TextDecoder:
|
||||
"""
|
||||
Handles incrementally decoding bytes into text
|
||||
"""
|
||||
|
||||
def __init__(self, encoding: str = "utf-8") -> None:
|
||||
self.decoder = codecs.getincrementaldecoder(encoding)(errors="replace")
|
||||
|
||||
def decode(self, data: bytes) -> str:
|
||||
return self.decoder.decode(data)
|
||||
|
||||
def flush(self) -> str:
|
||||
return self.decoder.decode(b"", True)
|
||||
|
||||
|
||||
class LineDecoder:
|
||||
"""
|
||||
Handles incrementally reading lines from text.
|
||||
|
||||
Has the same behaviour as the stdllib splitlines,
|
||||
but handling the input iteratively.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.buffer: list[str] = []
|
||||
self.trailing_cr: bool = False
|
||||
|
||||
def decode(self, text: str) -> list[str]:
|
||||
# See https://docs.python.org/3/library/stdtypes.html#str.splitlines
|
||||
NEWLINE_CHARS = "\n\r\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029"
|
||||
|
||||
# We always push a trailing `\r` into the next decode iteration.
|
||||
if self.trailing_cr:
|
||||
text = "\r" + text
|
||||
self.trailing_cr = False
|
||||
if text.endswith("\r"):
|
||||
self.trailing_cr = True
|
||||
text = text[:-1]
|
||||
|
||||
if not text:
|
||||
# NOTE: the edge case input of empty text doesn't occur in practice,
|
||||
# because other httpx internals filter out this value
|
||||
return [] # pragma: no cover
|
||||
|
||||
trailing_newline = text[-1] in NEWLINE_CHARS
|
||||
lines = text.splitlines()
|
||||
|
||||
if len(lines) == 1 and not trailing_newline:
|
||||
# No new lines, buffer the input and continue.
|
||||
self.buffer.append(lines[0])
|
||||
return []
|
||||
|
||||
if self.buffer:
|
||||
# Include any existing buffer in the first portion of the
|
||||
# splitlines result.
|
||||
lines = ["".join(self.buffer) + lines[0]] + lines[1:]
|
||||
self.buffer = []
|
||||
|
||||
if not trailing_newline:
|
||||
# If the last segment of splitlines is not newline terminated,
|
||||
# then drop it from our output and start a new buffer.
|
||||
self.buffer = [lines.pop()]
|
||||
|
||||
return lines
|
||||
|
||||
def flush(self) -> list[str]:
|
||||
if not self.buffer and not self.trailing_cr:
|
||||
return []
|
||||
|
||||
lines = ["".join(self.buffer)]
|
||||
self.buffer = []
|
||||
self.trailing_cr = False
|
||||
return lines
|
||||
|
||||
|
||||
SUPPORTED_DECODERS = {
|
||||
"identity": IdentityDecoder,
|
||||
"gzip": GZipDecoder,
|
||||
"deflate": DeflateDecoder,
|
||||
"br": BrotliDecoder,
|
||||
"zstd": ZStandardDecoder,
|
||||
}
|
||||
|
||||
|
||||
if brotli is None:
|
||||
SUPPORTED_DECODERS.pop("br") # pragma: no cover
|
||||
if zstandard is None:
|
||||
SUPPORTED_DECODERS.pop("zstd") # pragma: no cover
|
||||
377
httpx/_exceptions.py
Normal file
@ -0,0 +1,377 @@
|
||||
"""
|
||||
Our exception hierarchy:
|
||||
|
||||
* HTTPError
|
||||
x RequestError
|
||||
+ TransportError
|
||||
- TimeoutException
|
||||
· ConnectTimeout
|
||||
· ReadTimeout
|
||||
· WriteTimeout
|
||||
· PoolTimeout
|
||||
- NetworkError
|
||||
· ConnectError
|
||||
· ReadError
|
||||
· WriteError
|
||||
· CloseError
|
||||
- ProtocolError
|
||||
· LocalProtocolError
|
||||
· RemoteProtocolError
|
||||
- ProxyError
|
||||
- UnsupportedProtocol
|
||||
+ DecodingError
|
||||
+ TooManyRedirects
|
||||
x HTTPStatusError
|
||||
* InvalidURL
|
||||
* CookieConflict
|
||||
* StreamError
|
||||
x StreamConsumed
|
||||
x StreamClosed
|
||||
x ResponseNotRead
|
||||
x RequestNotRead
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import typing
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ._models import Request, Response # pragma: no cover
|
||||
|
||||
__all__ = [
|
||||
"CloseError",
|
||||
"ConnectError",
|
||||
"ConnectTimeout",
|
||||
"CookieConflict",
|
||||
"DecodingError",
|
||||
"HTTPError",
|
||||
"HTTPStatusError",
|
||||
"InvalidURL",
|
||||
"LocalProtocolError",
|
||||
"NetworkError",
|
||||
"PoolTimeout",
|
||||
"ProtocolError",
|
||||
"ProxyError",
|
||||
"ReadError",
|
||||
"ReadTimeout",
|
||||
"RemoteProtocolError",
|
||||
"RequestError",
|
||||
"RequestNotRead",
|
||||
"ResponseNotRead",
|
||||
"StreamClosed",
|
||||
"StreamConsumed",
|
||||
"StreamError",
|
||||
"TimeoutException",
|
||||
"TooManyRedirects",
|
||||
"TransportError",
|
||||
"UnsupportedProtocol",
|
||||
"WriteError",
|
||||
"WriteTimeout",
|
||||
]
|
||||
|
||||
|
||||
class HTTPError(Exception):
|
||||
"""
|
||||
Base class for `RequestError` and `HTTPStatusError`.
|
||||
|
||||
Useful for `try...except` blocks when issuing a request,
|
||||
and then calling `.raise_for_status()`.
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
try:
|
||||
response = httpx.get("https://www.example.com")
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
print(f"HTTP Exception for {exc.request.url} - {exc}")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self._request: Request | None = None
|
||||
|
||||
@property
|
||||
def request(self) -> Request:
|
||||
if self._request is None:
|
||||
raise RuntimeError("The .request property has not been set.")
|
||||
return self._request
|
||||
|
||||
@request.setter
|
||||
def request(self, request: Request) -> None:
|
||||
self._request = request
|
||||
|
||||
|
||||
class RequestError(HTTPError):
|
||||
"""
|
||||
Base class for all exceptions that may occur when issuing a `.request()`.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, request: Request | None = None) -> None:
|
||||
super().__init__(message)
|
||||
# At the point an exception is raised we won't typically have a request
|
||||
# instance to associate it with.
|
||||
#
|
||||
# The 'request_context' context manager is used within the Client and
|
||||
# Response methods in order to ensure that any raised exceptions
|
||||
# have a `.request` property set on them.
|
||||
self._request = request
|
||||
|
||||
|
||||
class TransportError(RequestError):
|
||||
"""
|
||||
Base class for all exceptions that occur at the level of the Transport API.
|
||||
"""
|
||||
|
||||
|
||||
# Timeout exceptions...
|
||||
|
||||
|
||||
class TimeoutException(TransportError):
|
||||
"""
|
||||
The base class for timeout errors.
|
||||
|
||||
An operation has timed out.
|
||||
"""
|
||||
|
||||
|
||||
class ConnectTimeout(TimeoutException):
|
||||
"""
|
||||
Timed out while connecting to the host.
|
||||
"""
|
||||
|
||||
|
||||
class ReadTimeout(TimeoutException):
|
||||
"""
|
||||
Timed out while receiving data from the host.
|
||||
"""
|
||||
|
||||
|
||||
class WriteTimeout(TimeoutException):
|
||||
"""
|
||||
Timed out while sending data to the host.
|
||||
"""
|
||||
|
||||
|
||||
class PoolTimeout(TimeoutException):
|
||||
"""
|
||||
Timed out waiting to acquire a connection from the pool.
|
||||
"""
|
||||
|
||||
|
||||
# Core networking exceptions...
|
||||
|
||||
|
||||
class NetworkError(TransportError):
|
||||
"""
|
||||
The base class for network-related errors.
|
||||
|
||||
An error occurred while interacting with the network.
|
||||
"""
|
||||
|
||||
|
||||
class ReadError(NetworkError):
|
||||
"""
|
||||
Failed to receive data from the network.
|
||||
"""
|
||||
|
||||
|
||||
class WriteError(NetworkError):
|
||||
"""
|
||||
Failed to send data through the network.
|
||||
"""
|
||||
|
||||
|
||||
class ConnectError(NetworkError):
|
||||
"""
|
||||
Failed to establish a connection.
|
||||
"""
|
||||
|
||||
|
||||
class CloseError(NetworkError):
|
||||
"""
|
||||
Failed to close a connection.
|
||||
"""
|
||||
|
||||
|
||||
# Other transport exceptions...
|
||||
|
||||
|
||||
class ProxyError(TransportError):
|
||||
"""
|
||||
An error occurred while establishing a proxy connection.
|
||||
"""
|
||||
|
||||
|
||||
class UnsupportedProtocol(TransportError):
|
||||
"""
|
||||
Attempted to make a request to an unsupported protocol.
|
||||
|
||||
For example issuing a request to `ftp://www.example.com`.
|
||||
"""
|
||||
|
||||
|
||||
class ProtocolError(TransportError):
|
||||
"""
|
||||
The protocol was violated.
|
||||
"""
|
||||
|
||||
|
||||
class LocalProtocolError(ProtocolError):
|
||||
"""
|
||||
A protocol was violated by the client.
|
||||
|
||||
For example if the user instantiated a `Request` instance explicitly,
|
||||
failed to include the mandatory `Host:` header, and then issued it directly
|
||||
using `client.send()`.
|
||||
"""
|
||||
|
||||
|
||||
class RemoteProtocolError(ProtocolError):
|
||||
"""
|
||||
The protocol was violated by the server.
|
||||
|
||||
For example, returning malformed HTTP.
|
||||
"""
|
||||
|
||||
|
||||
# Other request exceptions...
|
||||
|
||||
|
||||
class DecodingError(RequestError):
|
||||
"""
|
||||
Decoding of the response failed, due to a malformed encoding.
|
||||
"""
|
||||
|
||||
|
||||
class TooManyRedirects(RequestError):
|
||||
"""
|
||||
Too many redirects.
|
||||
"""
|
||||
|
||||
|
||||
# Client errors
|
||||
|
||||
|
||||
class HTTPStatusError(HTTPError):
|
||||
"""
|
||||
The response had an error HTTP status of 4xx or 5xx.
|
||||
|
||||
May be raised when calling `response.raise_for_status()`
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, request: Request, response: Response) -> None:
|
||||
super().__init__(message)
|
||||
self.request = request
|
||||
self.response = response
|
||||
|
||||
|
||||
class InvalidURL(Exception):
|
||||
"""
|
||||
URL is improperly formed or cannot be parsed.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class CookieConflict(Exception):
|
||||
"""
|
||||
Attempted to lookup a cookie by name, but multiple cookies existed.
|
||||
|
||||
Can occur when calling `response.cookies.get(...)`.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# Stream exceptions...
|
||||
|
||||
# These may occur as the result of a programming error, by accessing
|
||||
# the request/response stream in an invalid manner.
|
||||
|
||||
|
||||
class StreamError(RuntimeError):
|
||||
"""
|
||||
The base class for stream exceptions.
|
||||
|
||||
The developer made an error in accessing the request stream in
|
||||
an invalid way.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class StreamConsumed(StreamError):
|
||||
"""
|
||||
Attempted to read or stream content, but the content has already
|
||||
been streamed.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
message = (
|
||||
"Attempted to read or stream some content, but the content has "
|
||||
"already been streamed. For requests, this could be due to passing "
|
||||
"a generator as request content, and then receiving a redirect "
|
||||
"response or a secondary request as part of an authentication flow."
|
||||
"For responses, this could be due to attempting to stream the response "
|
||||
"content more than once."
|
||||
)
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class StreamClosed(StreamError):
|
||||
"""
|
||||
Attempted to read or stream response content, but the request has been
|
||||
closed.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
message = "Attempted to read or stream content, but the stream has been closed."
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ResponseNotRead(StreamError):
|
||||
"""
|
||||
Attempted to access streaming response content, without having called `read()`.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
message = (
|
||||
"Attempted to access streaming response content,"
|
||||
" without having called `read()`."
|
||||
)
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class RequestNotRead(StreamError):
|
||||
"""
|
||||
Attempted to access streaming request content, without having called `read()`.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
message = (
|
||||
"Attempted to access streaming request content,"
|
||||
" without having called `read()`."
|
||||
)
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def request_context(
|
||||
request: Request | None = None,
|
||||
) -> typing.Iterator[None]:
|
||||
"""
|
||||
A context manager that can be used to attach the given request context
|
||||
to any `RequestError` exceptions that are raised within the block.
|
||||
"""
|
||||
try:
|
||||
yield
|
||||
except RequestError as exc:
|
||||
if request is not None:
|
||||
exc.request = request
|
||||
raise exc
|
||||
506
httpx/_main.py
Normal file
@ -0,0 +1,506 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import sys
|
||||
import typing
|
||||
|
||||
import click
|
||||
import pygments.lexers
|
||||
import pygments.util
|
||||
import rich.console
|
||||
import rich.markup
|
||||
import rich.progress
|
||||
import rich.syntax
|
||||
import rich.table
|
||||
|
||||
from ._client import Client
|
||||
from ._exceptions import RequestError
|
||||
from ._models import Response
|
||||
from ._status_codes import codes
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import httpcore # pragma: no cover
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
console = rich.console.Console()
|
||||
|
||||
console.print("[bold]HTTPX :butterfly:", justify="center")
|
||||
console.print()
|
||||
console.print("A next generation HTTP client.", justify="center")
|
||||
console.print()
|
||||
console.print(
|
||||
"Usage: [bold]httpx[/bold] [cyan]<URL> [OPTIONS][/cyan] ", justify="left"
|
||||
)
|
||||
console.print()
|
||||
|
||||
table = rich.table.Table.grid(padding=1, pad_edge=True)
|
||||
table.add_column("Parameter", no_wrap=True, justify="left", style="bold")
|
||||
table.add_column("Description")
|
||||
table.add_row(
|
||||
"-m, --method [cyan]METHOD",
|
||||
"Request method, such as GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD.\n"
|
||||
"[Default: GET, or POST if a request body is included]",
|
||||
)
|
||||
table.add_row(
|
||||
"-p, --params [cyan]<NAME VALUE> ...",
|
||||
"Query parameters to include in the request URL.",
|
||||
)
|
||||
table.add_row(
|
||||
"-c, --content [cyan]TEXT", "Byte content to include in the request body."
|
||||
)
|
||||
table.add_row(
|
||||
"-d, --data [cyan]<NAME VALUE> ...", "Form data to include in the request body."
|
||||
)
|
||||
table.add_row(
|
||||
"-f, --files [cyan]<NAME FILENAME> ...",
|
||||
"Form files to include in the request body.",
|
||||
)
|
||||
table.add_row("-j, --json [cyan]TEXT", "JSON data to include in the request body.")
|
||||
table.add_row(
|
||||
"-h, --headers [cyan]<NAME VALUE> ...",
|
||||
"Include additional HTTP headers in the request.",
|
||||
)
|
||||
table.add_row(
|
||||
"--cookies [cyan]<NAME VALUE> ...", "Cookies to include in the request."
|
||||
)
|
||||
table.add_row(
|
||||
"--auth [cyan]<USER PASS>",
|
||||
"Username and password to include in the request. Specify '-' for the password"
|
||||
" to use a password prompt. Note that using --verbose/-v will expose"
|
||||
" the Authorization header, including the password encoding"
|
||||
" in a trivially reversible format.",
|
||||
)
|
||||
|
||||
table.add_row(
|
||||
"--proxy [cyan]URL",
|
||||
"Send the request via a proxy. Should be the URL giving the proxy address.",
|
||||
)
|
||||
|
||||
table.add_row(
|
||||
"--timeout [cyan]FLOAT",
|
||||
"Timeout value to use for network operations, such as establishing the"
|
||||
" connection, reading some data, etc... [Default: 5.0]",
|
||||
)
|
||||
|
||||
table.add_row("--follow-redirects", "Automatically follow redirects.")
|
||||
table.add_row("--no-verify", "Disable SSL verification.")
|
||||
table.add_row(
|
||||
"--http2", "Send the request using HTTP/2, if the remote server supports it."
|
||||
)
|
||||
|
||||
table.add_row(
|
||||
"--download [cyan]FILE",
|
||||
"Save the response content as a file, rather than displaying it.",
|
||||
)
|
||||
|
||||
table.add_row("-v, --verbose", "Verbose output. Show request as well as response.")
|
||||
table.add_row("--help", "Show this message and exit.")
|
||||
console.print(table)
|
||||
|
||||
|
||||
def get_lexer_for_response(response: Response) -> str:
|
||||
content_type = response.headers.get("Content-Type")
|
||||
if content_type is not None:
|
||||
mime_type, _, _ = content_type.partition(";")
|
||||
try:
|
||||
return typing.cast(
|
||||
str, pygments.lexers.get_lexer_for_mimetype(mime_type.strip()).name
|
||||
)
|
||||
except pygments.util.ClassNotFound: # pragma: no cover
|
||||
pass
|
||||
return "" # pragma: no cover
|
||||
|
||||
|
||||
def format_request_headers(request: httpcore.Request, http2: bool = False) -> str:
|
||||
version = "HTTP/2" if http2 else "HTTP/1.1"
|
||||
headers = [
|
||||
(name.lower() if http2 else name, value) for name, value in request.headers
|
||||
]
|
||||
method = request.method.decode("ascii")
|
||||
target = request.url.target.decode("ascii")
|
||||
lines = [f"{method} {target} {version}"] + [
|
||||
f"{name.decode('ascii')}: {value.decode('ascii')}" for name, value in headers
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_response_headers(
|
||||
http_version: bytes,
|
||||
status: int,
|
||||
reason_phrase: bytes | None,
|
||||
headers: list[tuple[bytes, bytes]],
|
||||
) -> str:
|
||||
version = http_version.decode("ascii")
|
||||
reason = (
|
||||
codes.get_reason_phrase(status)
|
||||
if reason_phrase is None
|
||||
else reason_phrase.decode("ascii")
|
||||
)
|
||||
lines = [f"{version} {status} {reason}"] + [
|
||||
f"{name.decode('ascii')}: {value.decode('ascii')}" for name, value in headers
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def print_request_headers(request: httpcore.Request, http2: bool = False) -> None:
|
||||
console = rich.console.Console()
|
||||
http_text = format_request_headers(request, http2=http2)
|
||||
syntax = rich.syntax.Syntax(http_text, "http", theme="ansi_dark", word_wrap=True)
|
||||
console.print(syntax)
|
||||
syntax = rich.syntax.Syntax("", "http", theme="ansi_dark", word_wrap=True)
|
||||
console.print(syntax)
|
||||
|
||||
|
||||
def print_response_headers(
|
||||
http_version: bytes,
|
||||
status: int,
|
||||
reason_phrase: bytes | None,
|
||||
headers: list[tuple[bytes, bytes]],
|
||||
) -> None:
|
||||
console = rich.console.Console()
|
||||
http_text = format_response_headers(http_version, status, reason_phrase, headers)
|
||||
syntax = rich.syntax.Syntax(http_text, "http", theme="ansi_dark", word_wrap=True)
|
||||
console.print(syntax)
|
||||
syntax = rich.syntax.Syntax("", "http", theme="ansi_dark", word_wrap=True)
|
||||
console.print(syntax)
|
||||
|
||||
|
||||
def print_response(response: Response) -> None:
|
||||
console = rich.console.Console()
|
||||
lexer_name = get_lexer_for_response(response)
|
||||
if lexer_name:
|
||||
if lexer_name.lower() == "json":
|
||||
try:
|
||||
data = response.json()
|
||||
text = json.dumps(data, indent=4)
|
||||
except ValueError: # pragma: no cover
|
||||
text = response.text
|
||||
else:
|
||||
text = response.text
|
||||
|
||||
syntax = rich.syntax.Syntax(text, lexer_name, theme="ansi_dark", word_wrap=True)
|
||||
console.print(syntax)
|
||||
else:
|
||||
console.print(f"<{len(response.content)} bytes of binary data>")
|
||||
|
||||
|
||||
_PCTRTT = typing.Tuple[typing.Tuple[str, str], ...]
|
||||
_PCTRTTT = typing.Tuple[_PCTRTT, ...]
|
||||
_PeerCertRetDictType = typing.Dict[str, typing.Union[str, _PCTRTTT, _PCTRTT]]
|
||||
|
||||
|
||||
def format_certificate(cert: _PeerCertRetDictType) -> str: # pragma: no cover
|
||||
lines = []
|
||||
for key, value in cert.items():
|
||||
if isinstance(value, (list, tuple)):
|
||||
lines.append(f"* {key}:")
|
||||
for item in value:
|
||||
if key in ("subject", "issuer"):
|
||||
for sub_item in item:
|
||||
lines.append(f"* {sub_item[0]}: {sub_item[1]!r}")
|
||||
elif isinstance(item, tuple) and len(item) == 2:
|
||||
lines.append(f"* {item[0]}: {item[1]!r}")
|
||||
else:
|
||||
lines.append(f"* {item!r}")
|
||||
else:
|
||||
lines.append(f"* {key}: {value!r}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def trace(
|
||||
name: str, info: typing.Mapping[str, typing.Any], verbose: bool = False
|
||||
) -> None:
|
||||
console = rich.console.Console()
|
||||
if name == "connection.connect_tcp.started" and verbose:
|
||||
host = info["host"]
|
||||
console.print(f"* Connecting to {host!r}")
|
||||
elif name == "connection.connect_tcp.complete" and verbose:
|
||||
stream = info["return_value"]
|
||||
server_addr = stream.get_extra_info("server_addr")
|
||||
console.print(f"* Connected to {server_addr[0]!r} on port {server_addr[1]}")
|
||||
elif name == "connection.start_tls.complete" and verbose: # pragma: no cover
|
||||
stream = info["return_value"]
|
||||
ssl_object = stream.get_extra_info("ssl_object")
|
||||
version = ssl_object.version()
|
||||
cipher = ssl_object.cipher()
|
||||
server_cert = ssl_object.getpeercert()
|
||||
alpn = ssl_object.selected_alpn_protocol()
|
||||
console.print(f"* SSL established using {version!r} / {cipher[0]!r}")
|
||||
console.print(f"* Selected ALPN protocol: {alpn!r}")
|
||||
if server_cert:
|
||||
console.print("* Server certificate:")
|
||||
console.print(format_certificate(server_cert))
|
||||
elif name == "http11.send_request_headers.started" and verbose:
|
||||
request = info["request"]
|
||||
print_request_headers(request, http2=False)
|
||||
elif name == "http2.send_request_headers.started" and verbose: # pragma: no cover
|
||||
request = info["request"]
|
||||
print_request_headers(request, http2=True)
|
||||
elif name == "http11.receive_response_headers.complete":
|
||||
http_version, status, reason_phrase, headers = info["return_value"]
|
||||
print_response_headers(http_version, status, reason_phrase, headers)
|
||||
elif name == "http2.receive_response_headers.complete": # pragma: no cover
|
||||
status, headers = info["return_value"]
|
||||
http_version = b"HTTP/2"
|
||||
reason_phrase = None
|
||||
print_response_headers(http_version, status, reason_phrase, headers)
|
||||
|
||||
|
||||
def download_response(response: Response, download: typing.BinaryIO) -> None:
|
||||
console = rich.console.Console()
|
||||
console.print()
|
||||
content_length = response.headers.get("Content-Length")
|
||||
with rich.progress.Progress(
|
||||
"[progress.description]{task.description}",
|
||||
"[progress.percentage]{task.percentage:>3.0f}%",
|
||||
rich.progress.BarColumn(bar_width=None),
|
||||
rich.progress.DownloadColumn(),
|
||||
rich.progress.TransferSpeedColumn(),
|
||||
) as progress:
|
||||
description = f"Downloading [bold]{rich.markup.escape(download.name)}"
|
||||
download_task = progress.add_task(
|
||||
description,
|
||||
total=int(content_length or 0),
|
||||
start=content_length is not None,
|
||||
)
|
||||
for chunk in response.iter_bytes():
|
||||
download.write(chunk)
|
||||
progress.update(download_task, completed=response.num_bytes_downloaded)
|
||||
|
||||
|
||||
def validate_json(
|
||||
ctx: click.Context,
|
||||
param: click.Option | click.Parameter,
|
||||
value: typing.Any,
|
||||
) -> typing.Any:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError: # pragma: no cover
|
||||
raise click.BadParameter("Not valid JSON")
|
||||
|
||||
|
||||
def validate_auth(
|
||||
ctx: click.Context,
|
||||
param: click.Option | click.Parameter,
|
||||
value: typing.Any,
|
||||
) -> typing.Any:
|
||||
if value == (None, None):
|
||||
return None
|
||||
|
||||
username, password = value
|
||||
if password == "-": # pragma: no cover
|
||||
password = click.prompt("Password", hide_input=True)
|
||||
return (username, password)
|
||||
|
||||
|
||||
def handle_help(
|
||||
ctx: click.Context,
|
||||
param: click.Option | click.Parameter,
|
||||
value: typing.Any,
|
||||
) -> None:
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
|
||||
print_help()
|
||||
ctx.exit()
|
||||
|
||||
|
||||
@click.command(add_help_option=False)
|
||||
@click.argument("url", type=str)
|
||||
@click.option(
|
||||
"--method",
|
||||
"-m",
|
||||
"method",
|
||||
type=str,
|
||||
help=(
|
||||
"Request method, such as GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD. "
|
||||
"[Default: GET, or POST if a request body is included]"
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--params",
|
||||
"-p",
|
||||
"params",
|
||||
type=(str, str),
|
||||
multiple=True,
|
||||
help="Query parameters to include in the request URL.",
|
||||
)
|
||||
@click.option(
|
||||
"--content",
|
||||
"-c",
|
||||
"content",
|
||||
type=str,
|
||||
help="Byte content to include in the request body.",
|
||||
)
|
||||
@click.option(
|
||||
"--data",
|
||||
"-d",
|
||||
"data",
|
||||
type=(str, str),
|
||||
multiple=True,
|
||||
help="Form data to include in the request body.",
|
||||
)
|
||||
@click.option(
|
||||
"--files",
|
||||
"-f",
|
||||
"files",
|
||||
type=(str, click.File(mode="rb")),
|
||||
multiple=True,
|
||||
help="Form files to include in the request body.",
|
||||
)
|
||||
@click.option(
|
||||
"--json",
|
||||
"-j",
|
||||
"json",
|
||||
type=str,
|
||||
callback=validate_json,
|
||||
help="JSON data to include in the request body.",
|
||||
)
|
||||
@click.option(
|
||||
"--headers",
|
||||
"-h",
|
||||
"headers",
|
||||
type=(str, str),
|
||||
multiple=True,
|
||||
help="Include additional HTTP headers in the request.",
|
||||
)
|
||||
@click.option(
|
||||
"--cookies",
|
||||
"cookies",
|
||||
type=(str, str),
|
||||
multiple=True,
|
||||
help="Cookies to include in the request.",
|
||||
)
|
||||
@click.option(
|
||||
"--auth",
|
||||
"auth",
|
||||
type=(str, str),
|
||||
default=(None, None),
|
||||
callback=validate_auth,
|
||||
help=(
|
||||
"Username and password to include in the request. "
|
||||
"Specify '-' for the password to use a password prompt. "
|
||||
"Note that using --verbose/-v will expose the Authorization header, "
|
||||
"including the password encoding in a trivially reversible format."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--proxy",
|
||||
"proxy",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Send the request via a proxy. Should be the URL giving the proxy address.",
|
||||
)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
"timeout",
|
||||
type=float,
|
||||
default=5.0,
|
||||
help=(
|
||||
"Timeout value to use for network operations, such as establishing the "
|
||||
"connection, reading some data, etc... [Default: 5.0]"
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--follow-redirects",
|
||||
"follow_redirects",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Automatically follow redirects.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-verify",
|
||||
"verify",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Disable SSL verification.",
|
||||
)
|
||||
@click.option(
|
||||
"--http2",
|
||||
"http2",
|
||||
type=bool,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Send the request using HTTP/2, if the remote server supports it.",
|
||||
)
|
||||
@click.option(
|
||||
"--download",
|
||||
type=click.File("wb"),
|
||||
help="Save the response content as a file, rather than displaying it.",
|
||||
)
|
||||
@click.option(
|
||||
"--verbose",
|
||||
"-v",
|
||||
type=bool,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Verbose. Show request as well as response.",
|
||||
)
|
||||
@click.option(
|
||||
"--help",
|
||||
is_flag=True,
|
||||
is_eager=True,
|
||||
expose_value=False,
|
||||
callback=handle_help,
|
||||
help="Show this message and exit.",
|
||||
)
|
||||
def main(
|
||||
url: str,
|
||||
method: str,
|
||||
params: list[tuple[str, str]],
|
||||
content: str,
|
||||
data: list[tuple[str, str]],
|
||||
files: list[tuple[str, click.File]],
|
||||
json: str,
|
||||
headers: list[tuple[str, str]],
|
||||
cookies: list[tuple[str, str]],
|
||||
auth: tuple[str, str] | None,
|
||||
proxy: str,
|
||||
timeout: float,
|
||||
follow_redirects: bool,
|
||||
verify: bool,
|
||||
http2: bool,
|
||||
download: typing.BinaryIO | None,
|
||||
verbose: bool,
|
||||
) -> None:
|
||||
"""
|
||||
An HTTP command line client.
|
||||
Sends a request and displays the response.
|
||||
"""
|
||||
if not method:
|
||||
method = "POST" if content or data or files or json else "GET"
|
||||
|
||||
try:
|
||||
with Client(proxy=proxy, timeout=timeout, http2=http2, verify=verify) as client:
|
||||
with client.stream(
|
||||
method,
|
||||
url,
|
||||
params=list(params),
|
||||
content=content,
|
||||
data=dict(data),
|
||||
files=files, # type: ignore
|
||||
json=json,
|
||||
headers=headers,
|
||||
cookies=dict(cookies),
|
||||
auth=auth,
|
||||
follow_redirects=follow_redirects,
|
||||
extensions={"trace": functools.partial(trace, verbose=verbose)},
|
||||
) as response:
|
||||
if download is not None:
|
||||
download_response(response, download)
|
||||
else:
|
||||
response.read()
|
||||
if response.content:
|
||||
print_response(response)
|
||||
|
||||
except RequestError as exc:
|
||||
console = rich.console.Console()
|
||||
console.print(f"[red]{type(exc).__name__}[/red]: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
sys.exit(0 if response.is_success else 1)
|
||||
1277
httpx/_models.py
Normal file
300
httpx/_multipart.py
Normal file
@ -0,0 +1,300 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
from ._types import (
|
||||
AsyncByteStream,
|
||||
FileContent,
|
||||
FileTypes,
|
||||
RequestData,
|
||||
RequestFiles,
|
||||
SyncByteStream,
|
||||
)
|
||||
from ._utils import (
|
||||
peek_filelike_length,
|
||||
primitive_value_to_str,
|
||||
to_bytes,
|
||||
)
|
||||
|
||||
_HTML5_FORM_ENCODING_REPLACEMENTS = {'"': "%22", "\\": "\\\\"}
|
||||
_HTML5_FORM_ENCODING_REPLACEMENTS.update(
|
||||
{chr(c): "%{:02X}".format(c) for c in range(0x1F + 1) if c != 0x1B}
|
||||
)
|
||||
_HTML5_FORM_ENCODING_RE = re.compile(
|
||||
r"|".join([re.escape(c) for c in _HTML5_FORM_ENCODING_REPLACEMENTS.keys()])
|
||||
)
|
||||
|
||||
|
||||
def _format_form_param(name: str, value: str) -> bytes:
|
||||
"""
|
||||
Encode a name/value pair within a multipart form.
|
||||
"""
|
||||
|
||||
def replacer(match: typing.Match[str]) -> str:
|
||||
return _HTML5_FORM_ENCODING_REPLACEMENTS[match.group(0)]
|
||||
|
||||
value = _HTML5_FORM_ENCODING_RE.sub(replacer, value)
|
||||
return f'{name}="{value}"'.encode()
|
||||
|
||||
|
||||
def _guess_content_type(filename: str | None) -> str | None:
|
||||
"""
|
||||
Guesses the mimetype based on a filename. Defaults to `application/octet-stream`.
|
||||
|
||||
Returns `None` if `filename` is `None` or empty.
|
||||
"""
|
||||
if filename:
|
||||
return mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
return None
|
||||
|
||||
|
||||
def get_multipart_boundary_from_content_type(
|
||||
content_type: bytes | None,
|
||||
) -> bytes | None:
|
||||
if not content_type or not content_type.startswith(b"multipart/form-data"):
|
||||
return None
|
||||
# parse boundary according to
|
||||
# https://www.rfc-editor.org/rfc/rfc2046#section-5.1.1
|
||||
if b";" in content_type:
|
||||
for section in content_type.split(b";"):
|
||||
if section.strip().lower().startswith(b"boundary="):
|
||||
return section.strip()[len(b"boundary=") :].strip(b'"')
|
||||
return None
|
||||
|
||||
|
||||
class DataField:
|
||||
"""
|
||||
A single form field item, within a multipart form field.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, value: str | bytes | int | float | None) -> None:
|
||||
if not isinstance(name, str):
|
||||
raise TypeError(
|
||||
f"Invalid type for name. Expected str, got {type(name)}: {name!r}"
|
||||
)
|
||||
if value is not None and not isinstance(value, (str, bytes, int, float)):
|
||||
raise TypeError(
|
||||
"Invalid type for value. Expected primitive type,"
|
||||
f" got {type(value)}: {value!r}"
|
||||
)
|
||||
self.name = name
|
||||
self.value: str | bytes = (
|
||||
value if isinstance(value, bytes) else primitive_value_to_str(value)
|
||||
)
|
||||
|
||||
def render_headers(self) -> bytes:
|
||||
if not hasattr(self, "_headers"):
|
||||
name = _format_form_param("name", self.name)
|
||||
self._headers = b"".join(
|
||||
[b"Content-Disposition: form-data; ", name, b"\r\n\r\n"]
|
||||
)
|
||||
|
||||
return self._headers
|
||||
|
||||
def render_data(self) -> bytes:
|
||||
if not hasattr(self, "_data"):
|
||||
self._data = to_bytes(self.value)
|
||||
|
||||
return self._data
|
||||
|
||||
def get_length(self) -> int:
|
||||
headers = self.render_headers()
|
||||
data = self.render_data()
|
||||
return len(headers) + len(data)
|
||||
|
||||
def render(self) -> typing.Iterator[bytes]:
|
||||
yield self.render_headers()
|
||||
yield self.render_data()
|
||||
|
||||
|
||||
class FileField:
|
||||
"""
|
||||
A single file field item, within a multipart form field.
|
||||
"""
|
||||
|
||||
CHUNK_SIZE = 64 * 1024
|
||||
|
||||
def __init__(self, name: str, value: FileTypes) -> None:
|
||||
self.name = name
|
||||
|
||||
fileobj: FileContent
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
content_type: str | None = None
|
||||
|
||||
# This large tuple based API largely mirror's requests' API
|
||||
# It would be good to think of better APIs for this that we could
|
||||
# include in httpx 2.0 since variable length tuples(especially of 4 elements)
|
||||
# are quite unwieldly
|
||||
if isinstance(value, tuple):
|
||||
if len(value) == 2:
|
||||
# neither the 3rd parameter (content_type) nor the 4th (headers)
|
||||
# was included
|
||||
filename, fileobj = value
|
||||
elif len(value) == 3:
|
||||
filename, fileobj, content_type = value
|
||||
else:
|
||||
# all 4 parameters included
|
||||
filename, fileobj, content_type, headers = value # type: ignore
|
||||
else:
|
||||
filename = Path(str(getattr(value, "name", "upload"))).name
|
||||
fileobj = value
|
||||
|
||||
if content_type is None:
|
||||
content_type = _guess_content_type(filename)
|
||||
|
||||
has_content_type_header = any("content-type" in key.lower() for key in headers)
|
||||
if content_type is not None and not has_content_type_header:
|
||||
# note that unlike requests, we ignore the content_type provided in the 3rd
|
||||
# tuple element if it is also included in the headers requests does
|
||||
# the opposite (it overwrites the headerwith the 3rd tuple element)
|
||||
headers["Content-Type"] = content_type
|
||||
|
||||
if isinstance(fileobj, io.StringIO):
|
||||
raise TypeError(
|
||||
"Multipart file uploads require 'io.BytesIO', not 'io.StringIO'."
|
||||
)
|
||||
if isinstance(fileobj, io.TextIOBase):
|
||||
raise TypeError(
|
||||
"Multipart file uploads must be opened in binary mode, not text mode."
|
||||
)
|
||||
|
||||
self.filename = filename
|
||||
self.file = fileobj
|
||||
self.headers = headers
|
||||
|
||||
def get_length(self) -> int | None:
|
||||
headers = self.render_headers()
|
||||
|
||||
if isinstance(self.file, (str, bytes)):
|
||||
return len(headers) + len(to_bytes(self.file))
|
||||
|
||||
file_length = peek_filelike_length(self.file)
|
||||
|
||||
# If we can't determine the filesize without reading it into memory,
|
||||
# then return `None` here, to indicate an unknown file length.
|
||||
if file_length is None:
|
||||
return None
|
||||
|
||||
return len(headers) + file_length
|
||||
|
||||
def render_headers(self) -> bytes:
|
||||
if not hasattr(self, "_headers"):
|
||||
parts = [
|
||||
b"Content-Disposition: form-data; ",
|
||||
_format_form_param("name", self.name),
|
||||
]
|
||||
if self.filename:
|
||||
filename = _format_form_param("filename", self.filename)
|
||||
parts.extend([b"; ", filename])
|
||||
for header_name, header_value in self.headers.items():
|
||||
key, val = f"\r\n{header_name}: ".encode(), header_value.encode()
|
||||
parts.extend([key, val])
|
||||
parts.append(b"\r\n\r\n")
|
||||
self._headers = b"".join(parts)
|
||||
|
||||
return self._headers
|
||||
|
||||
def render_data(self) -> typing.Iterator[bytes]:
|
||||
if isinstance(self.file, (str, bytes)):
|
||||
yield to_bytes(self.file)
|
||||
return
|
||||
|
||||
if hasattr(self.file, "seek"):
|
||||
try:
|
||||
self.file.seek(0)
|
||||
except io.UnsupportedOperation:
|
||||
pass
|
||||
|
||||
chunk = self.file.read(self.CHUNK_SIZE)
|
||||
while chunk:
|
||||
yield to_bytes(chunk)
|
||||
chunk = self.file.read(self.CHUNK_SIZE)
|
||||
|
||||
def render(self) -> typing.Iterator[bytes]:
|
||||
yield self.render_headers()
|
||||
yield from self.render_data()
|
||||
|
||||
|
||||
class MultipartStream(SyncByteStream, AsyncByteStream):
|
||||
"""
|
||||
Request content as streaming multipart encoded form data.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: RequestData,
|
||||
files: RequestFiles,
|
||||
boundary: bytes | None = None,
|
||||
) -> None:
|
||||
if boundary is None:
|
||||
boundary = os.urandom(16).hex().encode("ascii")
|
||||
|
||||
self.boundary = boundary
|
||||
self.content_type = "multipart/form-data; boundary=%s" % boundary.decode(
|
||||
"ascii"
|
||||
)
|
||||
self.fields = list(self._iter_fields(data, files))
|
||||
|
||||
def _iter_fields(
|
||||
self, data: RequestData, files: RequestFiles
|
||||
) -> typing.Iterator[FileField | DataField]:
|
||||
for name, value in data.items():
|
||||
if isinstance(value, (tuple, list)):
|
||||
for item in value:
|
||||
yield DataField(name=name, value=item)
|
||||
else:
|
||||
yield DataField(name=name, value=value)
|
||||
|
||||
file_items = files.items() if isinstance(files, typing.Mapping) else files
|
||||
for name, value in file_items:
|
||||
yield FileField(name=name, value=value)
|
||||
|
||||
def iter_chunks(self) -> typing.Iterator[bytes]:
|
||||
for field in self.fields:
|
||||
yield b"--%s\r\n" % self.boundary
|
||||
yield from field.render()
|
||||
yield b"\r\n"
|
||||
yield b"--%s--\r\n" % self.boundary
|
||||
|
||||
def get_content_length(self) -> int | None:
|
||||
"""
|
||||
Return the length of the multipart encoded content, or `None` if
|
||||
any of the files have a length that cannot be determined upfront.
|
||||
"""
|
||||
boundary_length = len(self.boundary)
|
||||
length = 0
|
||||
|
||||
for field in self.fields:
|
||||
field_length = field.get_length()
|
||||
if field_length is None:
|
||||
return None
|
||||
|
||||
length += 2 + boundary_length + 2 # b"--{boundary}\r\n"
|
||||
length += field_length
|
||||
length += 2 # b"\r\n"
|
||||
|
||||
length += 2 + boundary_length + 4 # b"--{boundary}--\r\n"
|
||||
return length
|
||||
|
||||
# Content stream interface.
|
||||
|
||||
def get_headers(self) -> dict[str, str]:
|
||||
content_length = self.get_content_length()
|
||||
content_type = self.content_type
|
||||
if content_length is None:
|
||||
return {"Transfer-Encoding": "chunked", "Content-Type": content_type}
|
||||
return {"Content-Length": str(content_length), "Content-Type": content_type}
|
||||
|
||||
def __iter__(self) -> typing.Iterator[bytes]:
|
||||
for chunk in self.iter_chunks():
|
||||
yield chunk
|
||||
|
||||
async def __aiter__(self) -> typing.AsyncIterator[bytes]:
|
||||
for chunk in self.iter_chunks():
|
||||
yield chunk
|
||||
@ -1,9 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
__all__ = ["codes"]
|
||||
|
||||
class StatusCode(IntEnum):
|
||||
|
||||
class codes(IntEnum):
|
||||
"""HTTP status codes and reason phrases
|
||||
|
||||
Status codes from the following RFCs are all observed:
|
||||
|
||||
* RFC 7231: Hypertext Transfer Protocol (HTTP/1.1), obsoletes 2616
|
||||
* RFC 6585: Additional HTTP Status Codes
|
||||
* RFC 3229: Delta encoding in HTTP
|
||||
@ -15,13 +21,15 @@ class StatusCode(IntEnum):
|
||||
* RFC 7540: Hypertext Transfer Protocol Version 2 (HTTP/2)
|
||||
* RFC 2324: Hyper Text Coffee Pot Control Protocol (HTCPCP/1.0)
|
||||
* RFC 7725: An HTTP Status Code to Report Legal Obstacles
|
||||
* RFC 8297: An HTTP Status Code for Indicating Hints
|
||||
* RFC 8470: Using Early Data in HTTP
|
||||
"""
|
||||
|
||||
def __new__(cls, value: int, phrase: str = "") -> "StatusCode":
|
||||
obj = int.__new__(cls, value) # type: ignore
|
||||
def __new__(cls, value: int, phrase: str = "") -> codes:
|
||||
obj = int.__new__(cls, value)
|
||||
obj._value_ = value
|
||||
|
||||
obj.phrase = phrase
|
||||
obj.phrase = phrase # type: ignore[attr-defined]
|
||||
return obj
|
||||
|
||||
def __str__(self) -> str:
|
||||
@ -30,37 +38,57 @@ class StatusCode(IntEnum):
|
||||
@classmethod
|
||||
def get_reason_phrase(cls, value: int) -> str:
|
||||
try:
|
||||
return StatusCode(value).phrase # type: ignore
|
||||
return codes(value).phrase # type: ignore
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def is_informational(cls, value: int) -> bool:
|
||||
"""
|
||||
Returns `True` for 1xx status codes, `False` otherwise.
|
||||
"""
|
||||
return 100 <= value <= 199
|
||||
|
||||
@classmethod
|
||||
def is_success(cls, value: int) -> bool:
|
||||
"""
|
||||
Returns `True` for 2xx status codes, `False` otherwise.
|
||||
"""
|
||||
return 200 <= value <= 299
|
||||
|
||||
@classmethod
|
||||
def is_redirect(cls, value: int) -> bool:
|
||||
return value in (
|
||||
# 301 (Cacheable redirect. Method may change to GET.)
|
||||
StatusCode.MOVED_PERMANENTLY,
|
||||
# 302 (Uncacheable redirect. Method may change to GET.)
|
||||
StatusCode.FOUND,
|
||||
# 303 (Client should make a GET or HEAD request.)
|
||||
StatusCode.SEE_OTHER,
|
||||
# 307 (Equiv. 302, but retain method)
|
||||
StatusCode.TEMPORARY_REDIRECT,
|
||||
# 308 (Equiv. 301, but retain method)
|
||||
StatusCode.PERMANENT_REDIRECT,
|
||||
)
|
||||
"""
|
||||
Returns `True` for 3xx status codes, `False` otherwise.
|
||||
"""
|
||||
return 300 <= value <= 399
|
||||
|
||||
@classmethod
|
||||
def is_client_error(cls, value: int) -> bool:
|
||||
"""
|
||||
Returns `True` for 4xx status codes, `False` otherwise.
|
||||
"""
|
||||
return 400 <= value <= 499
|
||||
|
||||
@classmethod
|
||||
def is_server_error(cls, value: int) -> bool:
|
||||
"""
|
||||
Returns `True` for 5xx status codes, `False` otherwise.
|
||||
"""
|
||||
return 500 <= value <= 599
|
||||
|
||||
@classmethod
|
||||
def is_error(cls, value: int) -> bool:
|
||||
"""
|
||||
Returns `True` for 4xx or 5xx status codes, `False` otherwise.
|
||||
"""
|
||||
return 400 <= value <= 599
|
||||
|
||||
# informational
|
||||
CONTINUE = 100, "Continue"
|
||||
SWITCHING_PROTOCOLS = 101, "Switching Protocols"
|
||||
PROCESSING = 102, "Processing"
|
||||
EARLY_HINTS = 103, "Early Hints"
|
||||
|
||||
# success
|
||||
OK = 200, "OK"
|
||||
@ -108,6 +136,7 @@ class StatusCode(IntEnum):
|
||||
UNPROCESSABLE_ENTITY = 422, "Unprocessable Entity"
|
||||
LOCKED = 423, "Locked"
|
||||
FAILED_DEPENDENCY = 424, "Failed Dependency"
|
||||
TOO_EARLY = 425, "Too Early"
|
||||
UPGRADE_REQUIRED = 426, "Upgrade Required"
|
||||
PRECONDITION_REQUIRED = 428, "Precondition Required"
|
||||
TOO_MANY_REQUESTS = 429, "Too Many Requests"
|
||||
@ -128,8 +157,6 @@ class StatusCode(IntEnum):
|
||||
NETWORK_AUTHENTICATION_REQUIRED = 511, "Network Authentication Required"
|
||||
|
||||
|
||||
codes = StatusCode
|
||||
|
||||
# Include lower-case styles for `requests` compatibility.
|
||||
# Include lower-case styles for `requests` compatibility.
|
||||
for code in codes:
|
||||
setattr(codes, code._name_.lower(), int(code))
|
||||
15
httpx/_transports/__init__.py
Normal file
@ -0,0 +1,15 @@
|
||||
from .asgi import *
|
||||
from .base import *
|
||||
from .default import *
|
||||
from .mock import *
|
||||
from .wsgi import *
|
||||
|
||||
__all__ = [
|
||||
"ASGITransport",
|
||||
"AsyncBaseTransport",
|
||||
"BaseTransport",
|
||||
"AsyncHTTPTransport",
|
||||
"HTTPTransport",
|
||||
"MockTransport",
|
||||
"WSGITransport",
|
||||
]
|
||||
187
httpx/_transports/asgi.py
Normal file
@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from .._models import Request, Response
|
||||
from .._types import AsyncByteStream
|
||||
from .base import AsyncBaseTransport
|
||||
|
||||
if typing.TYPE_CHECKING: # pragma: no cover
|
||||
import asyncio
|
||||
|
||||
import trio
|
||||
|
||||
Event = typing.Union[asyncio.Event, trio.Event]
|
||||
|
||||
|
||||
_Message = typing.MutableMapping[str, typing.Any]
|
||||
_Receive = typing.Callable[[], typing.Awaitable[_Message]]
|
||||
_Send = typing.Callable[
|
||||
[typing.MutableMapping[str, typing.Any]], typing.Awaitable[None]
|
||||
]
|
||||
_ASGIApp = typing.Callable[
|
||||
[typing.MutableMapping[str, typing.Any], _Receive, _Send], typing.Awaitable[None]
|
||||
]
|
||||
|
||||
__all__ = ["ASGITransport"]
|
||||
|
||||
|
||||
def is_running_trio() -> bool:
|
||||
try:
|
||||
# sniffio is a dependency of trio.
|
||||
|
||||
# See https://github.com/python-trio/trio/issues/2802
|
||||
import sniffio
|
||||
|
||||
if sniffio.current_async_library() == "trio":
|
||||
return True
|
||||
except ImportError: # pragma: nocover
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def create_event() -> Event:
|
||||
if is_running_trio():
|
||||
import trio
|
||||
|
||||
return trio.Event()
|
||||
|
||||
import asyncio
|
||||
|
||||
return asyncio.Event()
|
||||
|
||||
|
||||
class ASGIResponseStream(AsyncByteStream):
|
||||
def __init__(self, body: list[bytes]) -> None:
|
||||
self._body = body
|
||||
|
||||
async def __aiter__(self) -> typing.AsyncIterator[bytes]:
|
||||
yield b"".join(self._body)
|
||||
|
||||
|
||||
class ASGITransport(AsyncBaseTransport):
|
||||
"""
|
||||
A custom AsyncTransport that handles sending requests directly to an ASGI app.
|
||||
|
||||
```python
|
||||
transport = httpx.ASGITransport(
|
||||
app=app,
|
||||
root_path="/submount",
|
||||
client=("1.2.3.4", 123)
|
||||
)
|
||||
client = httpx.AsyncClient(transport=transport)
|
||||
```
|
||||
|
||||
Arguments:
|
||||
|
||||
* `app` - The ASGI application.
|
||||
* `raise_app_exceptions` - Boolean indicating if exceptions in the application
|
||||
should be raised. Default to `True`. Can be set to `False` for use cases
|
||||
such as testing the content of a client 500 response.
|
||||
* `root_path` - The root path on which the ASGI application should be mounted.
|
||||
* `client` - A two-tuple indicating the client IP and port of incoming requests.
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: _ASGIApp,
|
||||
raise_app_exceptions: bool = True,
|
||||
root_path: str = "",
|
||||
client: tuple[str, int] = ("127.0.0.1", 123),
|
||||
) -> None:
|
||||
self.app = app
|
||||
self.raise_app_exceptions = raise_app_exceptions
|
||||
self.root_path = root_path
|
||||
self.client = client
|
||||
|
||||
async def handle_async_request(
|
||||
self,
|
||||
request: Request,
|
||||
) -> Response:
|
||||
assert isinstance(request.stream, AsyncByteStream)
|
||||
|
||||
# ASGI scope.
|
||||
scope = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": request.method,
|
||||
"headers": [(k.lower(), v) for (k, v) in request.headers.raw],
|
||||
"scheme": request.url.scheme,
|
||||
"path": request.url.path,
|
||||
"raw_path": request.url.raw_path.split(b"?")[0],
|
||||
"query_string": request.url.query,
|
||||
"server": (request.url.host, request.url.port),
|
||||
"client": self.client,
|
||||
"root_path": self.root_path,
|
||||
}
|
||||
|
||||
# Request.
|
||||
request_body_chunks = request.stream.__aiter__()
|
||||
request_complete = False
|
||||
|
||||
# Response.
|
||||
status_code = None
|
||||
response_headers = None
|
||||
body_parts = []
|
||||
response_started = False
|
||||
response_complete = create_event()
|
||||
|
||||
# ASGI callables.
|
||||
|
||||
async def receive() -> dict[str, typing.Any]:
|
||||
nonlocal request_complete
|
||||
|
||||
if request_complete:
|
||||
await response_complete.wait()
|
||||
return {"type": "http.disconnect"}
|
||||
|
||||
try:
|
||||
body = await request_body_chunks.__anext__()
|
||||
except StopAsyncIteration:
|
||||
request_complete = True
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
return {"type": "http.request", "body": body, "more_body": True}
|
||||
|
||||
async def send(message: typing.MutableMapping[str, typing.Any]) -> None:
|
||||
nonlocal status_code, response_headers, response_started
|
||||
|
||||
if message["type"] == "http.response.start":
|
||||
assert not response_started
|
||||
|
||||
status_code = message["status"]
|
||||
response_headers = message.get("headers", [])
|
||||
response_started = True
|
||||
|
||||
elif message["type"] == "http.response.body":
|
||||
assert not response_complete.is_set()
|
||||
body = message.get("body", b"")
|
||||
more_body = message.get("more_body", False)
|
||||
|
||||
if body and request.method != "HEAD":
|
||||
body_parts.append(body)
|
||||
|
||||
if not more_body:
|
||||
response_complete.set()
|
||||
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
except Exception: # noqa: PIE-786
|
||||
if self.raise_app_exceptions:
|
||||
raise
|
||||
|
||||
response_complete.set()
|
||||
if status_code is None:
|
||||
status_code = 500
|
||||
if response_headers is None:
|
||||
response_headers = {}
|
||||
|
||||
assert response_complete.is_set()
|
||||
assert status_code is not None
|
||||
assert response_headers is not None
|
||||
|
||||
stream = ASGIResponseStream(body_parts)
|
||||
|
||||
return Response(status_code, headers=response_headers, stream=stream)
|
||||
86
httpx/_transports/base.py
Normal file
@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from types import TracebackType
|
||||
|
||||
from .._models import Request, Response
|
||||
|
||||
T = typing.TypeVar("T", bound="BaseTransport")
|
||||
A = typing.TypeVar("A", bound="AsyncBaseTransport")
|
||||
|
||||
__all__ = ["AsyncBaseTransport", "BaseTransport"]
|
||||
|
||||
|
||||
class BaseTransport:
|
||||
def __enter__(self: T) -> T:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: TracebackType | None = None,
|
||||
) -> None:
|
||||
self.close()
|
||||
|
||||
def handle_request(self, request: Request) -> Response:
|
||||
"""
|
||||
Send a single HTTP request and return a response.
|
||||
|
||||
Developers shouldn't typically ever need to call into this API directly,
|
||||
since the Client class provides all the higher level user-facing API
|
||||
niceties.
|
||||
|
||||
In order to properly release any network resources, the response
|
||||
stream should *either* be consumed immediately, with a call to
|
||||
`response.stream.read()`, or else the `handle_request` call should
|
||||
be followed with a try/finally block to ensuring the stream is
|
||||
always closed.
|
||||
|
||||
Example usage:
|
||||
|
||||
with httpx.HTTPTransport() as transport:
|
||||
req = httpx.Request(
|
||||
method=b"GET",
|
||||
url=(b"https", b"www.example.com", 443, b"/"),
|
||||
headers=[(b"Host", b"www.example.com")],
|
||||
)
|
||||
resp = transport.handle_request(req)
|
||||
body = resp.stream.read()
|
||||
print(resp.status_code, resp.headers, body)
|
||||
|
||||
|
||||
Takes a `Request` instance as the only argument.
|
||||
|
||||
Returns a `Response` instance.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"The 'handle_request' method must be implemented."
|
||||
) # pragma: no cover
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class AsyncBaseTransport:
|
||||
async def __aenter__(self: A) -> A:
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: TracebackType | None = None,
|
||||
) -> None:
|
||||
await self.aclose()
|
||||
|
||||
async def handle_async_request(
|
||||
self,
|
||||
request: Request,
|
||||
) -> Response:
|
||||
raise NotImplementedError(
|
||||
"The 'handle_async_request' method must be implemented."
|
||||
) # pragma: no cover
|
||||
|
||||
async def aclose(self) -> None:
|
||||
pass
|
||||
406
httpx/_transports/default.py
Normal file
@ -0,0 +1,406 @@
|
||||
"""
|
||||
Custom transports, with nicely configured defaults.
|
||||
|
||||
The following additional keyword arguments are currently supported by httpcore...
|
||||
|
||||
* uds: str
|
||||
* local_address: str
|
||||
* retries: int
|
||||
|
||||
Example usages...
|
||||
|
||||
# Disable HTTP/2 on a single specific domain.
|
||||
mounts = {
|
||||
"all://": httpx.HTTPTransport(http2=True),
|
||||
"all://*example.org": httpx.HTTPTransport()
|
||||
}
|
||||
|
||||
# Using advanced httpcore configuration, with connection retries.
|
||||
transport = httpx.HTTPTransport(retries=1)
|
||||
client = httpx.Client(transport=transport)
|
||||
|
||||
# Using advanced httpcore configuration, with unix domain sockets.
|
||||
transport = httpx.HTTPTransport(uds="socket.uds")
|
||||
client = httpx.Client(transport=transport)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import typing
|
||||
from types import TracebackType
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import ssl # pragma: no cover
|
||||
|
||||
import httpx # pragma: no cover
|
||||
|
||||
from .._config import DEFAULT_LIMITS, Limits, Proxy, create_ssl_context
|
||||
from .._exceptions import (
|
||||
ConnectError,
|
||||
ConnectTimeout,
|
||||
LocalProtocolError,
|
||||
NetworkError,
|
||||
PoolTimeout,
|
||||
ProtocolError,
|
||||
ProxyError,
|
||||
ReadError,
|
||||
ReadTimeout,
|
||||
RemoteProtocolError,
|
||||
TimeoutException,
|
||||
UnsupportedProtocol,
|
||||
WriteError,
|
||||
WriteTimeout,
|
||||
)
|
||||
from .._models import Request, Response
|
||||
from .._types import AsyncByteStream, CertTypes, ProxyTypes, SyncByteStream
|
||||
from .._urls import URL
|
||||
from .base import AsyncBaseTransport, BaseTransport
|
||||
|
||||
T = typing.TypeVar("T", bound="HTTPTransport")
|
||||
A = typing.TypeVar("A", bound="AsyncHTTPTransport")
|
||||
|
||||
SOCKET_OPTION = typing.Union[
|
||||
typing.Tuple[int, int, int],
|
||||
typing.Tuple[int, int, typing.Union[bytes, bytearray]],
|
||||
typing.Tuple[int, int, None, int],
|
||||
]
|
||||
|
||||
__all__ = ["AsyncHTTPTransport", "HTTPTransport"]
|
||||
|
||||
HTTPCORE_EXC_MAP: dict[type[Exception], type[httpx.HTTPError]] = {}
|
||||
|
||||
|
||||
def _load_httpcore_exceptions() -> dict[type[Exception], type[httpx.HTTPError]]:
|
||||
import httpcore
|
||||
|
||||
return {
|
||||
httpcore.TimeoutException: TimeoutException,
|
||||
httpcore.ConnectTimeout: ConnectTimeout,
|
||||
httpcore.ReadTimeout: ReadTimeout,
|
||||
httpcore.WriteTimeout: WriteTimeout,
|
||||
httpcore.PoolTimeout: PoolTimeout,
|
||||
httpcore.NetworkError: NetworkError,
|
||||
httpcore.ConnectError: ConnectError,
|
||||
httpcore.ReadError: ReadError,
|
||||
httpcore.WriteError: WriteError,
|
||||
httpcore.ProxyError: ProxyError,
|
||||
httpcore.UnsupportedProtocol: UnsupportedProtocol,
|
||||
httpcore.ProtocolError: ProtocolError,
|
||||
httpcore.LocalProtocolError: LocalProtocolError,
|
||||
httpcore.RemoteProtocolError: RemoteProtocolError,
|
||||
}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def map_httpcore_exceptions() -> typing.Iterator[None]:
|
||||
global HTTPCORE_EXC_MAP
|
||||
if len(HTTPCORE_EXC_MAP) == 0:
|
||||
HTTPCORE_EXC_MAP = _load_httpcore_exceptions()
|
||||
try:
|
||||
yield
|
||||
except Exception as exc:
|
||||
mapped_exc = None
|
||||
|
||||
for from_exc, to_exc in HTTPCORE_EXC_MAP.items():
|
||||
if not isinstance(exc, from_exc):
|
||||
continue
|
||||
# We want to map to the most specific exception we can find.
|
||||
# Eg if `exc` is an `httpcore.ReadTimeout`, we want to map to
|
||||
# `httpx.ReadTimeout`, not just `httpx.TimeoutException`.
|
||||
if mapped_exc is None or issubclass(to_exc, mapped_exc):
|
||||
mapped_exc = to_exc
|
||||
|
||||
if mapped_exc is None: # pragma: no cover
|
||||
raise
|
||||
|
||||
message = str(exc)
|
||||
raise mapped_exc(message) from exc
|
||||
|
||||
|
||||
class ResponseStream(SyncByteStream):
|
||||
def __init__(self, httpcore_stream: typing.Iterable[bytes]) -> None:
|
||||
self._httpcore_stream = httpcore_stream
|
||||
|
||||
def __iter__(self) -> typing.Iterator[bytes]:
|
||||
with map_httpcore_exceptions():
|
||||
for part in self._httpcore_stream:
|
||||
yield part
|
||||
|
||||
def close(self) -> None:
|
||||
if hasattr(self._httpcore_stream, "close"):
|
||||
self._httpcore_stream.close()
|
||||
|
||||
|
||||
class HTTPTransport(BaseTransport):
|
||||
def __init__(
|
||||
self,
|
||||
verify: ssl.SSLContext | str | bool = True,
|
||||
cert: CertTypes | None = None,
|
||||
trust_env: bool = True,
|
||||
http1: bool = True,
|
||||
http2: bool = False,
|
||||
limits: Limits = DEFAULT_LIMITS,
|
||||
proxy: ProxyTypes | None = None,
|
||||
uds: str | None = None,
|
||||
local_address: str | None = None,
|
||||
retries: int = 0,
|
||||
socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
|
||||
) -> None:
|
||||
import httpcore
|
||||
|
||||
proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy
|
||||
ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
|
||||
|
||||
if proxy is None:
|
||||
self._pool = httpcore.ConnectionPool(
|
||||
ssl_context=ssl_context,
|
||||
max_connections=limits.max_connections,
|
||||
max_keepalive_connections=limits.max_keepalive_connections,
|
||||
keepalive_expiry=limits.keepalive_expiry,
|
||||
http1=http1,
|
||||
http2=http2,
|
||||
uds=uds,
|
||||
local_address=local_address,
|
||||
retries=retries,
|
||||
socket_options=socket_options,
|
||||
)
|
||||
elif proxy.url.scheme in ("http", "https"):
|
||||
self._pool = httpcore.HTTPProxy(
|
||||
proxy_url=httpcore.URL(
|
||||
scheme=proxy.url.raw_scheme,
|
||||
host=proxy.url.raw_host,
|
||||
port=proxy.url.port,
|
||||
target=proxy.url.raw_path,
|
||||
),
|
||||
proxy_auth=proxy.raw_auth,
|
||||
proxy_headers=proxy.headers.raw,
|
||||
ssl_context=ssl_context,
|
||||
proxy_ssl_context=proxy.ssl_context,
|
||||
max_connections=limits.max_connections,
|
||||
max_keepalive_connections=limits.max_keepalive_connections,
|
||||
keepalive_expiry=limits.keepalive_expiry,
|
||||
http1=http1,
|
||||
http2=http2,
|
||||
socket_options=socket_options,
|
||||
)
|
||||
elif proxy.url.scheme in ("socks5", "socks5h"):
|
||||
try:
|
||||
import socksio # noqa
|
||||
except ImportError: # pragma: no cover
|
||||
raise ImportError(
|
||||
"Using SOCKS proxy, but the 'socksio' package is not installed. "
|
||||
"Make sure to install httpx using `pip install httpx[socks]`."
|
||||
) from None
|
||||
|
||||
self._pool = httpcore.SOCKSProxy(
|
||||
proxy_url=httpcore.URL(
|
||||
scheme=proxy.url.raw_scheme,
|
||||
host=proxy.url.raw_host,
|
||||
port=proxy.url.port,
|
||||
target=proxy.url.raw_path,
|
||||
),
|
||||
proxy_auth=proxy.raw_auth,
|
||||
ssl_context=ssl_context,
|
||||
max_connections=limits.max_connections,
|
||||
max_keepalive_connections=limits.max_keepalive_connections,
|
||||
keepalive_expiry=limits.keepalive_expiry,
|
||||
http1=http1,
|
||||
http2=http2,
|
||||
)
|
||||
else: # pragma: no cover
|
||||
raise ValueError(
|
||||
"Proxy protocol must be either 'http', 'https', 'socks5', or 'socks5h',"
|
||||
f" but got {proxy.url.scheme!r}."
|
||||
)
|
||||
|
||||
def __enter__(self: T) -> T: # Use generics for subclass support.
|
||||
self._pool.__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: TracebackType | None = None,
|
||||
) -> None:
|
||||
with map_httpcore_exceptions():
|
||||
self._pool.__exit__(exc_type, exc_value, traceback)
|
||||
|
||||
def handle_request(
|
||||
self,
|
||||
request: Request,
|
||||
) -> Response:
|
||||
assert isinstance(request.stream, SyncByteStream)
|
||||
import httpcore
|
||||
|
||||
req = httpcore.Request(
|
||||
method=request.method,
|
||||
url=httpcore.URL(
|
||||
scheme=request.url.raw_scheme,
|
||||
host=request.url.raw_host,
|
||||
port=request.url.port,
|
||||
target=request.url.raw_path,
|
||||
),
|
||||
headers=request.headers.raw,
|
||||
content=request.stream,
|
||||
extensions=request.extensions,
|
||||
)
|
||||
with map_httpcore_exceptions():
|
||||
resp = self._pool.handle_request(req)
|
||||
|
||||
assert isinstance(resp.stream, typing.Iterable)
|
||||
|
||||
return Response(
|
||||
status_code=resp.status,
|
||||
headers=resp.headers,
|
||||
stream=ResponseStream(resp.stream),
|
||||
extensions=resp.extensions,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
|
||||
class AsyncResponseStream(AsyncByteStream):
|
||||
def __init__(self, httpcore_stream: typing.AsyncIterable[bytes]) -> None:
|
||||
self._httpcore_stream = httpcore_stream
|
||||
|
||||
async def __aiter__(self) -> typing.AsyncIterator[bytes]:
|
||||
with map_httpcore_exceptions():
|
||||
async for part in self._httpcore_stream:
|
||||
yield part
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if hasattr(self._httpcore_stream, "aclose"):
|
||||
await self._httpcore_stream.aclose()
|
||||
|
||||
|
||||
class AsyncHTTPTransport(AsyncBaseTransport):
|
||||
def __init__(
|
||||
self,
|
||||
verify: ssl.SSLContext | str | bool = True,
|
||||
cert: CertTypes | None = None,
|
||||
trust_env: bool = True,
|
||||
http1: bool = True,
|
||||
http2: bool = False,
|
||||
limits: Limits = DEFAULT_LIMITS,
|
||||
proxy: ProxyTypes | None = None,
|
||||
uds: str | None = None,
|
||||
local_address: str | None = None,
|
||||
retries: int = 0,
|
||||
socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
|
||||
) -> None:
|
||||
import httpcore
|
||||
|
||||
proxy = Proxy(url=proxy) if isinstance(proxy, (str, URL)) else proxy
|
||||
ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env)
|
||||
|
||||
if proxy is None:
|
||||
self._pool = httpcore.AsyncConnectionPool(
|
||||
ssl_context=ssl_context,
|
||||
max_connections=limits.max_connections,
|
||||
max_keepalive_connections=limits.max_keepalive_connections,
|
||||
keepalive_expiry=limits.keepalive_expiry,
|
||||
http1=http1,
|
||||
http2=http2,
|
||||
uds=uds,
|
||||
local_address=local_address,
|
||||
retries=retries,
|
||||
socket_options=socket_options,
|
||||
)
|
||||
elif proxy.url.scheme in ("http", "https"):
|
||||
self._pool = httpcore.AsyncHTTPProxy(
|
||||
proxy_url=httpcore.URL(
|
||||
scheme=proxy.url.raw_scheme,
|
||||
host=proxy.url.raw_host,
|
||||
port=proxy.url.port,
|
||||
target=proxy.url.raw_path,
|
||||
),
|
||||
proxy_auth=proxy.raw_auth,
|
||||
proxy_headers=proxy.headers.raw,
|
||||
proxy_ssl_context=proxy.ssl_context,
|
||||
ssl_context=ssl_context,
|
||||
max_connections=limits.max_connections,
|
||||
max_keepalive_connections=limits.max_keepalive_connections,
|
||||
keepalive_expiry=limits.keepalive_expiry,
|
||||
http1=http1,
|
||||
http2=http2,
|
||||
socket_options=socket_options,
|
||||
)
|
||||
elif proxy.url.scheme in ("socks5", "socks5h"):
|
||||
try:
|
||||
import socksio # noqa
|
||||
except ImportError: # pragma: no cover
|
||||
raise ImportError(
|
||||
"Using SOCKS proxy, but the 'socksio' package is not installed. "
|
||||
"Make sure to install httpx using `pip install httpx[socks]`."
|
||||
) from None
|
||||
|
||||
self._pool = httpcore.AsyncSOCKSProxy(
|
||||
proxy_url=httpcore.URL(
|
||||
scheme=proxy.url.raw_scheme,
|
||||
host=proxy.url.raw_host,
|
||||
port=proxy.url.port,
|
||||
target=proxy.url.raw_path,
|
||||
),
|
||||
proxy_auth=proxy.raw_auth,
|
||||
ssl_context=ssl_context,
|
||||
max_connections=limits.max_connections,
|
||||
max_keepalive_connections=limits.max_keepalive_connections,
|
||||
keepalive_expiry=limits.keepalive_expiry,
|
||||
http1=http1,
|
||||
http2=http2,
|
||||
)
|
||||
else: # pragma: no cover
|
||||
raise ValueError(
|
||||
"Proxy protocol must be either 'http', 'https', 'socks5', or 'socks5h',"
|
||||
f" but got {proxy.url.scheme!r}."
|
||||
)
|
||||
|
||||
async def __aenter__(self: A) -> A: # Use generics for subclass support.
|
||||
await self._pool.__aenter__()
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: TracebackType | None = None,
|
||||
) -> None:
|
||||
with map_httpcore_exceptions():
|
||||
await self._pool.__aexit__(exc_type, exc_value, traceback)
|
||||
|
||||
async def handle_async_request(
|
||||
self,
|
||||
request: Request,
|
||||
) -> Response:
|
||||
assert isinstance(request.stream, AsyncByteStream)
|
||||
import httpcore
|
||||
|
||||
req = httpcore.Request(
|
||||
method=request.method,
|
||||
url=httpcore.URL(
|
||||
scheme=request.url.raw_scheme,
|
||||
host=request.url.raw_host,
|
||||
port=request.url.port,
|
||||
target=request.url.raw_path,
|
||||
),
|
||||
headers=request.headers.raw,
|
||||
content=request.stream,
|
||||
extensions=request.extensions,
|
||||
)
|
||||
with map_httpcore_exceptions():
|
||||
resp = await self._pool.handle_async_request(req)
|
||||
|
||||
assert isinstance(resp.stream, typing.AsyncIterable)
|
||||
|
||||
return Response(
|
||||
status_code=resp.status,
|
||||
headers=resp.headers,
|
||||
stream=AsyncResponseStream(resp.stream),
|
||||
extensions=resp.extensions,
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._pool.aclose()
|
||||
43
httpx/_transports/mock.py
Normal file
@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from .._models import Request, Response
|
||||
from .base import AsyncBaseTransport, BaseTransport
|
||||
|
||||
SyncHandler = typing.Callable[[Request], Response]
|
||||
AsyncHandler = typing.Callable[[Request], typing.Coroutine[None, None, Response]]
|
||||
|
||||
|
||||
__all__ = ["MockTransport"]
|
||||
|
||||
|
||||
class MockTransport(AsyncBaseTransport, BaseTransport):
|
||||
def __init__(self, handler: SyncHandler | AsyncHandler) -> None:
|
||||
self.handler = handler
|
||||
|
||||
def handle_request(
|
||||
self,
|
||||
request: Request,
|
||||
) -> Response:
|
||||
request.read()
|
||||
response = self.handler(request)
|
||||
if not isinstance(response, Response): # pragma: no cover
|
||||
raise TypeError("Cannot use an async handler in a sync Client")
|
||||
return response
|
||||
|
||||
async def handle_async_request(
|
||||
self,
|
||||
request: Request,
|
||||
) -> Response:
|
||||
await request.aread()
|
||||
response = self.handler(request)
|
||||
|
||||
# Allow handler to *optionally* be an `async` function.
|
||||
# If it is, then the `response` variable need to be awaited to actually
|
||||
# return the result.
|
||||
|
||||
if not isinstance(response, Response):
|
||||
response = await response
|
||||
|
||||
return response
|
||||
149
httpx/_transports/wsgi.py
Normal file
@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import itertools
|
||||
import sys
|
||||
import typing
|
||||
|
||||
from .._models import Request, Response
|
||||
from .._types import SyncByteStream
|
||||
from .base import BaseTransport
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from _typeshed import OptExcInfo # pragma: no cover
|
||||
from _typeshed.wsgi import WSGIApplication # pragma: no cover
|
||||
|
||||
_T = typing.TypeVar("_T")
|
||||
|
||||
|
||||
__all__ = ["WSGITransport"]
|
||||
|
||||
|
||||
def _skip_leading_empty_chunks(body: typing.Iterable[_T]) -> typing.Iterable[_T]:
|
||||
body = iter(body)
|
||||
for chunk in body:
|
||||
if chunk:
|
||||
return itertools.chain([chunk], body)
|
||||
return []
|
||||
|
||||
|
||||
class WSGIByteStream(SyncByteStream):
|
||||
def __init__(self, result: typing.Iterable[bytes]) -> None:
|
||||
self._close = getattr(result, "close", None)
|
||||
self._result = _skip_leading_empty_chunks(result)
|
||||
|
||||
def __iter__(self) -> typing.Iterator[bytes]:
|
||||
for part in self._result:
|
||||
yield part
|
||||
|
||||
def close(self) -> None:
|
||||
if self._close is not None:
|
||||
self._close()
|
||||
|
||||
|
||||
class WSGITransport(BaseTransport):
|
||||
"""
|
||||
A custom transport that handles sending requests directly to an WSGI app.
|
||||
The simplest way to use this functionality is to use the `app` argument.
|
||||
|
||||
```
|
||||
client = httpx.Client(app=app)
|
||||
```
|
||||
|
||||
Alternatively, you can setup the transport instance explicitly.
|
||||
This allows you to include any additional configuration arguments specific
|
||||
to the WSGITransport class:
|
||||
|
||||
```
|
||||
transport = httpx.WSGITransport(
|
||||
app=app,
|
||||
script_name="/submount",
|
||||
remote_addr="1.2.3.4"
|
||||
)
|
||||
client = httpx.Client(transport=transport)
|
||||
```
|
||||
|
||||
Arguments:
|
||||
|
||||
* `app` - The WSGI application.
|
||||
* `raise_app_exceptions` - Boolean indicating if exceptions in the application
|
||||
should be raised. Default to `True`. Can be set to `False` for use cases
|
||||
such as testing the content of a client 500 response.
|
||||
* `script_name` - The root path on which the WSGI application should be mounted.
|
||||
* `remote_addr` - A string indicating the client IP of incoming requests.
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: WSGIApplication,
|
||||
raise_app_exceptions: bool = True,
|
||||
script_name: str = "",
|
||||
remote_addr: str = "127.0.0.1",
|
||||
wsgi_errors: typing.TextIO | None = None,
|
||||
) -> None:
|
||||
self.app = app
|
||||
self.raise_app_exceptions = raise_app_exceptions
|
||||
self.script_name = script_name
|
||||
self.remote_addr = remote_addr
|
||||
self.wsgi_errors = wsgi_errors
|
||||
|
||||
def handle_request(self, request: Request) -> Response:
|
||||
request.read()
|
||||
wsgi_input = io.BytesIO(request.content)
|
||||
|
||||
port = request.url.port or {"http": 80, "https": 443}[request.url.scheme]
|
||||
environ = {
|
||||
"wsgi.version": (1, 0),
|
||||
"wsgi.url_scheme": request.url.scheme,
|
||||
"wsgi.input": wsgi_input,
|
||||
"wsgi.errors": self.wsgi_errors or sys.stderr,
|
||||
"wsgi.multithread": True,
|
||||
"wsgi.multiprocess": False,
|
||||
"wsgi.run_once": False,
|
||||
"REQUEST_METHOD": request.method,
|
||||
"SCRIPT_NAME": self.script_name,
|
||||
"PATH_INFO": request.url.path,
|
||||
"QUERY_STRING": request.url.query.decode("ascii"),
|
||||
"SERVER_NAME": request.url.host,
|
||||
"SERVER_PORT": str(port),
|
||||
"SERVER_PROTOCOL": "HTTP/1.1",
|
||||
"REMOTE_ADDR": self.remote_addr,
|
||||
}
|
||||
for header_key, header_value in request.headers.raw:
|
||||
key = header_key.decode("ascii").upper().replace("-", "_")
|
||||
if key not in ("CONTENT_TYPE", "CONTENT_LENGTH"):
|
||||
key = "HTTP_" + key
|
||||
environ[key] = header_value.decode("ascii")
|
||||
|
||||
seen_status = None
|
||||
seen_response_headers = None
|
||||
seen_exc_info = None
|
||||
|
||||
def start_response(
|
||||
status: str,
|
||||
response_headers: list[tuple[str, str]],
|
||||
exc_info: OptExcInfo | None = None,
|
||||
) -> typing.Callable[[bytes], typing.Any]:
|
||||
nonlocal seen_status, seen_response_headers, seen_exc_info
|
||||
seen_status = status
|
||||
seen_response_headers = response_headers
|
||||
seen_exc_info = exc_info
|
||||
return lambda _: None
|
||||
|
||||
result = self.app(environ, start_response)
|
||||
|
||||
stream = WSGIByteStream(result)
|
||||
|
||||
assert seen_status is not None
|
||||
assert seen_response_headers is not None
|
||||
if seen_exc_info and seen_exc_info[0] and self.raise_app_exceptions:
|
||||
raise seen_exc_info[1]
|
||||
|
||||
status_code = int(seen_status.split()[0])
|
||||
headers = [
|
||||
(key.encode("ascii"), value.encode("ascii"))
|
||||
for key, value in seen_response_headers
|
||||
]
|
||||
|
||||
return Response(status_code, headers=headers, stream=stream)
|
||||
114
httpx/_types.py
Normal file
@ -0,0 +1,114 @@
|
||||
"""
|
||||
Type definitions for type checking purposes.
|
||||
"""
|
||||
|
||||
from http.cookiejar import CookieJar
|
||||
from typing import (
|
||||
IO,
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AsyncIterable,
|
||||
AsyncIterator,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
Iterator,
|
||||
List,
|
||||
Mapping,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ._auth import Auth # noqa: F401
|
||||
from ._config import Proxy, Timeout # noqa: F401
|
||||
from ._models import Cookies, Headers, Request # noqa: F401
|
||||
from ._urls import URL, QueryParams # noqa: F401
|
||||
|
||||
|
||||
PrimitiveData = Optional[Union[str, int, float, bool]]
|
||||
|
||||
URLTypes = Union["URL", str]
|
||||
|
||||
QueryParamTypes = Union[
|
||||
"QueryParams",
|
||||
Mapping[str, Union[PrimitiveData, Sequence[PrimitiveData]]],
|
||||
List[Tuple[str, PrimitiveData]],
|
||||
Tuple[Tuple[str, PrimitiveData], ...],
|
||||
str,
|
||||
bytes,
|
||||
]
|
||||
|
||||
HeaderTypes = Union[
|
||||
"Headers",
|
||||
Mapping[str, str],
|
||||
Mapping[bytes, bytes],
|
||||
Sequence[Tuple[str, str]],
|
||||
Sequence[Tuple[bytes, bytes]],
|
||||
]
|
||||
|
||||
CookieTypes = Union["Cookies", CookieJar, Dict[str, str], List[Tuple[str, str]]]
|
||||
|
||||
TimeoutTypes = Union[
|
||||
Optional[float],
|
||||
Tuple[Optional[float], Optional[float], Optional[float], Optional[float]],
|
||||
"Timeout",
|
||||
]
|
||||
ProxyTypes = Union["URL", str, "Proxy"]
|
||||
CertTypes = Union[str, Tuple[str, str], Tuple[str, str, str]]
|
||||
|
||||
AuthTypes = Union[
|
||||
Tuple[Union[str, bytes], Union[str, bytes]],
|
||||
Callable[["Request"], "Request"],
|
||||
"Auth",
|
||||
]
|
||||
|
||||
RequestContent = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]]
|
||||
ResponseContent = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]]
|
||||
ResponseExtensions = Mapping[str, Any]
|
||||
|
||||
RequestData = Mapping[str, Any]
|
||||
|
||||
FileContent = Union[IO[bytes], bytes, str]
|
||||
FileTypes = Union[
|
||||
# file (or bytes)
|
||||
FileContent,
|
||||
# (filename, file (or bytes))
|
||||
Tuple[Optional[str], FileContent],
|
||||
# (filename, file (or bytes), content_type)
|
||||
Tuple[Optional[str], FileContent, Optional[str]],
|
||||
# (filename, file (or bytes), content_type, headers)
|
||||
Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]],
|
||||
]
|
||||
RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]]
|
||||
|
||||
RequestExtensions = Mapping[str, Any]
|
||||
|
||||
__all__ = ["AsyncByteStream", "SyncByteStream"]
|
||||
|
||||
|
||||
class SyncByteStream:
|
||||
def __iter__(self) -> Iterator[bytes]:
|
||||
raise NotImplementedError(
|
||||
"The '__iter__' method must be implemented."
|
||||
) # pragma: no cover
|
||||
yield b"" # pragma: no cover
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Subclasses can override this method to release any network resources
|
||||
after a request/response cycle is complete.
|
||||
"""
|
||||
|
||||
|
||||
class AsyncByteStream:
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
raise NotImplementedError(
|
||||
"The '__aiter__' method must be implemented."
|
||||
) # pragma: no cover
|
||||
yield b"" # pragma: no cover
|
||||
|
||||
async def aclose(self) -> None:
|
||||
pass
|
||||
527
httpx/_urlparse.py
Normal file
@ -0,0 +1,527 @@
|
||||
"""
|
||||
An implementation of `urlparse` that provides URL validation and normalization
|
||||
as described by RFC3986.
|
||||
|
||||
We rely on this implementation rather than the one in Python's stdlib, because:
|
||||
|
||||
* It provides more complete URL validation.
|
||||
* It properly differentiates between an empty querystring and an absent querystring,
|
||||
to distinguish URLs with a trailing '?'.
|
||||
* It handles scheme, hostname, port, and path normalization.
|
||||
* It supports IDNA hostnames, normalizing them to their encoded form.
|
||||
* The API supports passing individual components, as well as the complete URL string.
|
||||
|
||||
Previously we relied on the excellent `rfc3986` package to handle URL parsing and
|
||||
validation, but this module provides a simpler alternative, with less indirection
|
||||
required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import typing
|
||||
|
||||
import idna
|
||||
|
||||
from ._exceptions import InvalidURL
|
||||
|
||||
MAX_URL_LENGTH = 65536
|
||||
|
||||
# https://datatracker.ietf.org/doc/html/rfc3986.html#section-2.3
|
||||
UNRESERVED_CHARACTERS = (
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
)
|
||||
SUB_DELIMS = "!$&'()*+,;="
|
||||
|
||||
PERCENT_ENCODED_REGEX = re.compile("%[A-Fa-f0-9]{2}")
|
||||
|
||||
# https://url.spec.whatwg.org/#percent-encoded-bytes
|
||||
|
||||
# The fragment percent-encode set is the C0 control percent-encode set
|
||||
# and U+0020 SPACE, U+0022 ("), U+003C (<), U+003E (>), and U+0060 (`).
|
||||
FRAG_SAFE = "".join(
|
||||
[chr(i) for i in range(0x20, 0x7F) if i not in (0x20, 0x22, 0x3C, 0x3E, 0x60)]
|
||||
)
|
||||
|
||||
# The query percent-encode set is the C0 control percent-encode set
|
||||
# and U+0020 SPACE, U+0022 ("), U+0023 (#), U+003C (<), and U+003E (>).
|
||||
QUERY_SAFE = "".join(
|
||||
[chr(i) for i in range(0x20, 0x7F) if i not in (0x20, 0x22, 0x23, 0x3C, 0x3E)]
|
||||
)
|
||||
|
||||
# The path percent-encode set is the query percent-encode set
|
||||
# and U+003F (?), U+0060 (`), U+007B ({), and U+007D (}).
|
||||
PATH_SAFE = "".join(
|
||||
[
|
||||
chr(i)
|
||||
for i in range(0x20, 0x7F)
|
||||
if i not in (0x20, 0x22, 0x23, 0x3C, 0x3E) + (0x3F, 0x60, 0x7B, 0x7D)
|
||||
]
|
||||
)
|
||||
|
||||
# The userinfo percent-encode set is the path percent-encode set
|
||||
# and U+002F (/), U+003A (:), U+003B (;), U+003D (=), U+0040 (@),
|
||||
# U+005B ([) to U+005E (^), inclusive, and U+007C (|).
|
||||
USERNAME_SAFE = "".join(
|
||||
[
|
||||
chr(i)
|
||||
for i in range(0x20, 0x7F)
|
||||
if i
|
||||
not in (0x20, 0x22, 0x23, 0x3C, 0x3E)
|
||||
+ (0x3F, 0x60, 0x7B, 0x7D)
|
||||
+ (0x2F, 0x3A, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C)
|
||||
]
|
||||
)
|
||||
PASSWORD_SAFE = "".join(
|
||||
[
|
||||
chr(i)
|
||||
for i in range(0x20, 0x7F)
|
||||
if i
|
||||
not in (0x20, 0x22, 0x23, 0x3C, 0x3E)
|
||||
+ (0x3F, 0x60, 0x7B, 0x7D)
|
||||
+ (0x2F, 0x3A, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C)
|
||||
]
|
||||
)
|
||||
# Note... The terminology 'userinfo' percent-encode set in the WHATWG document
|
||||
# is used for the username and password quoting. For the joint userinfo component
|
||||
# we remove U+003A (:) from the safe set.
|
||||
USERINFO_SAFE = "".join(
|
||||
[
|
||||
chr(i)
|
||||
for i in range(0x20, 0x7F)
|
||||
if i
|
||||
not in (0x20, 0x22, 0x23, 0x3C, 0x3E)
|
||||
+ (0x3F, 0x60, 0x7B, 0x7D)
|
||||
+ (0x2F, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# {scheme}: (optional)
|
||||
# //{authority} (optional)
|
||||
# {path}
|
||||
# ?{query} (optional)
|
||||
# #{fragment} (optional)
|
||||
URL_REGEX = re.compile(
|
||||
(
|
||||
r"(?:(?P<scheme>{scheme}):)?"
|
||||
r"(?://(?P<authority>{authority}))?"
|
||||
r"(?P<path>{path})"
|
||||
r"(?:\?(?P<query>{query}))?"
|
||||
r"(?:#(?P<fragment>{fragment}))?"
|
||||
).format(
|
||||
scheme="([a-zA-Z][a-zA-Z0-9+.-]*)?",
|
||||
authority="[^/?#]*",
|
||||
path="[^?#]*",
|
||||
query="[^#]*",
|
||||
fragment=".*",
|
||||
)
|
||||
)
|
||||
|
||||
# {userinfo}@ (optional)
|
||||
# {host}
|
||||
# :{port} (optional)
|
||||
AUTHORITY_REGEX = re.compile(
|
||||
(
|
||||
r"(?:(?P<userinfo>{userinfo})@)?" r"(?P<host>{host})" r":?(?P<port>{port})?"
|
||||
).format(
|
||||
userinfo=".*", # Any character sequence.
|
||||
host="(\\[.*\\]|[^:@]*)", # Either any character sequence excluding ':' or '@',
|
||||
# or an IPv6 address enclosed within square brackets.
|
||||
port=".*", # Any character sequence.
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# If we call urlparse with an individual component, then we need to regex
|
||||
# validate that component individually.
|
||||
# Note that we're duplicating the same strings as above. Shock! Horror!!
|
||||
COMPONENT_REGEX = {
|
||||
"scheme": re.compile("([a-zA-Z][a-zA-Z0-9+.-]*)?"),
|
||||
"authority": re.compile("[^/?#]*"),
|
||||
"path": re.compile("[^?#]*"),
|
||||
"query": re.compile("[^#]*"),
|
||||
"fragment": re.compile(".*"),
|
||||
"userinfo": re.compile("[^@]*"),
|
||||
"host": re.compile("(\\[.*\\]|[^:]*)"),
|
||||
"port": re.compile(".*"),
|
||||
}
|
||||
|
||||
|
||||
# We use these simple regexs as a first pass before handing off to
|
||||
# the stdlib 'ipaddress' module for IP address validation.
|
||||
IPv4_STYLE_HOSTNAME = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$")
|
||||
IPv6_STYLE_HOSTNAME = re.compile(r"^\[.*\]$")
|
||||
|
||||
|
||||
class ParseResult(typing.NamedTuple):
|
||||
scheme: str
|
||||
userinfo: str
|
||||
host: str
|
||||
port: int | None
|
||||
path: str
|
||||
query: str | None
|
||||
fragment: str | None
|
||||
|
||||
@property
|
||||
def authority(self) -> str:
|
||||
return "".join(
|
||||
[
|
||||
f"{self.userinfo}@" if self.userinfo else "",
|
||||
f"[{self.host}]" if ":" in self.host else self.host,
|
||||
f":{self.port}" if self.port is not None else "",
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def netloc(self) -> str:
|
||||
return "".join(
|
||||
[
|
||||
f"[{self.host}]" if ":" in self.host else self.host,
|
||||
f":{self.port}" if self.port is not None else "",
|
||||
]
|
||||
)
|
||||
|
||||
def copy_with(self, **kwargs: str | None) -> ParseResult:
|
||||
if not kwargs:
|
||||
return self
|
||||
|
||||
defaults = {
|
||||
"scheme": self.scheme,
|
||||
"authority": self.authority,
|
||||
"path": self.path,
|
||||
"query": self.query,
|
||||
"fragment": self.fragment,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return urlparse("", **defaults)
|
||||
|
||||
def __str__(self) -> str:
|
||||
authority = self.authority
|
||||
return "".join(
|
||||
[
|
||||
f"{self.scheme}:" if self.scheme else "",
|
||||
f"//{authority}" if authority else "",
|
||||
self.path,
|
||||
f"?{self.query}" if self.query is not None else "",
|
||||
f"#{self.fragment}" if self.fragment is not None else "",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def urlparse(url: str = "", **kwargs: str | None) -> ParseResult:
|
||||
# Initial basic checks on allowable URLs.
|
||||
# ---------------------------------------
|
||||
|
||||
# Hard limit the maximum allowable URL length.
|
||||
if len(url) > MAX_URL_LENGTH:
|
||||
raise InvalidURL("URL too long")
|
||||
|
||||
# If a URL includes any ASCII control characters including \t, \r, \n,
|
||||
# then treat it as invalid.
|
||||
if any(char.isascii() and not char.isprintable() for char in url):
|
||||
char = next(char for char in url if char.isascii() and not char.isprintable())
|
||||
idx = url.find(char)
|
||||
error = (
|
||||
f"Invalid non-printable ASCII character in URL, {char!r} at position {idx}."
|
||||
)
|
||||
raise InvalidURL(error)
|
||||
|
||||
# Some keyword arguments require special handling.
|
||||
# ------------------------------------------------
|
||||
|
||||
# Coerce "port" to a string, if it is provided as an integer.
|
||||
if "port" in kwargs:
|
||||
port = kwargs["port"]
|
||||
kwargs["port"] = str(port) if isinstance(port, int) else port
|
||||
|
||||
# Replace "netloc" with "host and "port".
|
||||
if "netloc" in kwargs:
|
||||
netloc = kwargs.pop("netloc") or ""
|
||||
kwargs["host"], _, kwargs["port"] = netloc.partition(":")
|
||||
|
||||
# Replace "username" and/or "password" with "userinfo".
|
||||
if "username" in kwargs or "password" in kwargs:
|
||||
username = quote(kwargs.pop("username", "") or "", safe=USERNAME_SAFE)
|
||||
password = quote(kwargs.pop("password", "") or "", safe=PASSWORD_SAFE)
|
||||
kwargs["userinfo"] = f"{username}:{password}" if password else username
|
||||
|
||||
# Replace "raw_path" with "path" and "query".
|
||||
if "raw_path" in kwargs:
|
||||
raw_path = kwargs.pop("raw_path") or ""
|
||||
kwargs["path"], seperator, kwargs["query"] = raw_path.partition("?")
|
||||
if not seperator:
|
||||
kwargs["query"] = None
|
||||
|
||||
# Ensure that IPv6 "host" addresses are always escaped with "[...]".
|
||||
if "host" in kwargs:
|
||||
host = kwargs.get("host") or ""
|
||||
if ":" in host and not (host.startswith("[") and host.endswith("]")):
|
||||
kwargs["host"] = f"[{host}]"
|
||||
|
||||
# If any keyword arguments are provided, ensure they are valid.
|
||||
# -------------------------------------------------------------
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if value is not None:
|
||||
if len(value) > MAX_URL_LENGTH:
|
||||
raise InvalidURL(f"URL component '{key}' too long")
|
||||
|
||||
# If a component includes any ASCII control characters including \t, \r, \n,
|
||||
# then treat it as invalid.
|
||||
if any(char.isascii() and not char.isprintable() for char in value):
|
||||
char = next(
|
||||
char for char in value if char.isascii() and not char.isprintable()
|
||||
)
|
||||
idx = value.find(char)
|
||||
error = (
|
||||
f"Invalid non-printable ASCII character in URL {key} component, "
|
||||
f"{char!r} at position {idx}."
|
||||
)
|
||||
raise InvalidURL(error)
|
||||
|
||||
# Ensure that keyword arguments match as a valid regex.
|
||||
if not COMPONENT_REGEX[key].fullmatch(value):
|
||||
raise InvalidURL(f"Invalid URL component '{key}'")
|
||||
|
||||
# The URL_REGEX will always match, but may have empty components.
|
||||
url_match = URL_REGEX.match(url)
|
||||
assert url_match is not None
|
||||
url_dict = url_match.groupdict()
|
||||
|
||||
# * 'scheme', 'authority', and 'path' may be empty strings.
|
||||
# * 'query' may be 'None', indicating no trailing "?" portion.
|
||||
# Any string including the empty string, indicates a trailing "?".
|
||||
# * 'fragment' may be 'None', indicating no trailing "#" portion.
|
||||
# Any string including the empty string, indicates a trailing "#".
|
||||
scheme = kwargs.get("scheme", url_dict["scheme"]) or ""
|
||||
authority = kwargs.get("authority", url_dict["authority"]) or ""
|
||||
path = kwargs.get("path", url_dict["path"]) or ""
|
||||
query = kwargs.get("query", url_dict["query"])
|
||||
frag = kwargs.get("fragment", url_dict["fragment"])
|
||||
|
||||
# The AUTHORITY_REGEX will always match, but may have empty components.
|
||||
authority_match = AUTHORITY_REGEX.match(authority)
|
||||
assert authority_match is not None
|
||||
authority_dict = authority_match.groupdict()
|
||||
|
||||
# * 'userinfo' and 'host' may be empty strings.
|
||||
# * 'port' may be 'None'.
|
||||
userinfo = kwargs.get("userinfo", authority_dict["userinfo"]) or ""
|
||||
host = kwargs.get("host", authority_dict["host"]) or ""
|
||||
port = kwargs.get("port", authority_dict["port"])
|
||||
|
||||
# Normalize and validate each component.
|
||||
# We end up with a parsed representation of the URL,
|
||||
# with components that are plain ASCII bytestrings.
|
||||
parsed_scheme: str = scheme.lower()
|
||||
parsed_userinfo: str = quote(userinfo, safe=USERINFO_SAFE)
|
||||
parsed_host: str = encode_host(host)
|
||||
parsed_port: int | None = normalize_port(port, scheme)
|
||||
|
||||
has_scheme = parsed_scheme != ""
|
||||
has_authority = (
|
||||
parsed_userinfo != "" or parsed_host != "" or parsed_port is not None
|
||||
)
|
||||
validate_path(path, has_scheme=has_scheme, has_authority=has_authority)
|
||||
if has_scheme or has_authority:
|
||||
path = normalize_path(path)
|
||||
|
||||
parsed_path: str = quote(path, safe=PATH_SAFE)
|
||||
parsed_query: str | None = None if query is None else quote(query, safe=QUERY_SAFE)
|
||||
parsed_frag: str | None = None if frag is None else quote(frag, safe=FRAG_SAFE)
|
||||
|
||||
# The parsed ASCII bytestrings are our canonical form.
|
||||
# All properties of the URL are derived from these.
|
||||
return ParseResult(
|
||||
parsed_scheme,
|
||||
parsed_userinfo,
|
||||
parsed_host,
|
||||
parsed_port,
|
||||
parsed_path,
|
||||
parsed_query,
|
||||
parsed_frag,
|
||||
)
|
||||
|
||||
|
||||
def encode_host(host: str) -> str:
|
||||
if not host:
|
||||
return ""
|
||||
|
||||
elif IPv4_STYLE_HOSTNAME.match(host):
|
||||
# Validate IPv4 hostnames like #.#.#.#
|
||||
#
|
||||
# From https://datatracker.ietf.org/doc/html/rfc3986/#section-3.2.2
|
||||
#
|
||||
# IPv4address = dec-octet "." dec-octet "." dec-octet "." dec-octet
|
||||
try:
|
||||
ipaddress.IPv4Address(host)
|
||||
except ipaddress.AddressValueError:
|
||||
raise InvalidURL(f"Invalid IPv4 address: {host!r}")
|
||||
return host
|
||||
|
||||
elif IPv6_STYLE_HOSTNAME.match(host):
|
||||
# Validate IPv6 hostnames like [...]
|
||||
#
|
||||
# From https://datatracker.ietf.org/doc/html/rfc3986/#section-3.2.2
|
||||
#
|
||||
# "A host identified by an Internet Protocol literal address, version 6
|
||||
# [RFC3513] or later, is distinguished by enclosing the IP literal
|
||||
# within square brackets ("[" and "]"). This is the only place where
|
||||
# square bracket characters are allowed in the URI syntax."
|
||||
try:
|
||||
ipaddress.IPv6Address(host[1:-1])
|
||||
except ipaddress.AddressValueError:
|
||||
raise InvalidURL(f"Invalid IPv6 address: {host!r}")
|
||||
return host[1:-1]
|
||||
|
||||
elif host.isascii():
|
||||
# Regular ASCII hostnames
|
||||
#
|
||||
# From https://datatracker.ietf.org/doc/html/rfc3986/#section-3.2.2
|
||||
#
|
||||
# reg-name = *( unreserved / pct-encoded / sub-delims )
|
||||
WHATWG_SAFE = '"`{}%|\\'
|
||||
return quote(host.lower(), safe=SUB_DELIMS + WHATWG_SAFE)
|
||||
|
||||
# IDNA hostnames
|
||||
try:
|
||||
return idna.encode(host.lower()).decode("ascii")
|
||||
except idna.IDNAError:
|
||||
raise InvalidURL(f"Invalid IDNA hostname: {host!r}")
|
||||
|
||||
|
||||
def normalize_port(port: str | int | None, scheme: str) -> int | None:
|
||||
# From https://tools.ietf.org/html/rfc3986#section-3.2.3
|
||||
#
|
||||
# "A scheme may define a default port. For example, the "http" scheme
|
||||
# defines a default port of "80", corresponding to its reserved TCP
|
||||
# port number. The type of port designated by the port number (e.g.,
|
||||
# TCP, UDP, SCTP) is defined by the URI scheme. URI producers and
|
||||
# normalizers should omit the port component and its ":" delimiter if
|
||||
# port is empty or if its value would be the same as that of the
|
||||
# scheme's default."
|
||||
if port is None or port == "":
|
||||
return None
|
||||
|
||||
try:
|
||||
port_as_int = int(port)
|
||||
except ValueError:
|
||||
raise InvalidURL(f"Invalid port: {port!r}")
|
||||
|
||||
# See https://url.spec.whatwg.org/#url-miscellaneous
|
||||
default_port = {"ftp": 21, "http": 80, "https": 443, "ws": 80, "wss": 443}.get(
|
||||
scheme
|
||||
)
|
||||
if port_as_int == default_port:
|
||||
return None
|
||||
return port_as_int
|
||||
|
||||
|
||||
def validate_path(path: str, has_scheme: bool, has_authority: bool) -> None:
|
||||
"""
|
||||
Path validation rules that depend on if the URL contains
|
||||
a scheme or authority component.
|
||||
|
||||
See https://datatracker.ietf.org/doc/html/rfc3986.html#section-3.3
|
||||
"""
|
||||
if has_authority:
|
||||
# If a URI contains an authority component, then the path component
|
||||
# must either be empty or begin with a slash ("/") character."
|
||||
if path and not path.startswith("/"):
|
||||
raise InvalidURL("For absolute URLs, path must be empty or begin with '/'")
|
||||
|
||||
if not has_scheme and not has_authority:
|
||||
# If a URI does not contain an authority component, then the path cannot begin
|
||||
# with two slash characters ("//").
|
||||
if path.startswith("//"):
|
||||
raise InvalidURL("Relative URLs cannot have a path starting with '//'")
|
||||
|
||||
# In addition, a URI reference (Section 4.1) may be a relative-path reference,
|
||||
# in which case the first path segment cannot contain a colon (":") character.
|
||||
if path.startswith(":"):
|
||||
raise InvalidURL("Relative URLs cannot have a path starting with ':'")
|
||||
|
||||
|
||||
def normalize_path(path: str) -> str:
|
||||
"""
|
||||
Drop "." and ".." segments from a URL path.
|
||||
|
||||
For example:
|
||||
|
||||
normalize_path("/path/./to/somewhere/..") == "/path/to"
|
||||
"""
|
||||
# Fast return when no '.' characters in the path.
|
||||
if "." not in path:
|
||||
return path
|
||||
|
||||
components = path.split("/")
|
||||
|
||||
# Fast return when no '.' or '..' components in the path.
|
||||
if "." not in components and ".." not in components:
|
||||
return path
|
||||
|
||||
# https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4
|
||||
output: list[str] = []
|
||||
for component in components:
|
||||
if component == ".":
|
||||
pass
|
||||
elif component == "..":
|
||||
if output and output != [""]:
|
||||
output.pop()
|
||||
else:
|
||||
output.append(component)
|
||||
return "/".join(output)
|
||||
|
||||
|
||||
def PERCENT(string: str) -> str:
|
||||
return "".join([f"%{byte:02X}" for byte in string.encode("utf-8")])
|
||||
|
||||
|
||||
def percent_encoded(string: str, safe: str) -> str:
|
||||
"""
|
||||
Use percent-encoding to quote a string.
|
||||
"""
|
||||
NON_ESCAPED_CHARS = UNRESERVED_CHARACTERS + safe
|
||||
|
||||
# Fast path for strings that don't need escaping.
|
||||
if not string.rstrip(NON_ESCAPED_CHARS):
|
||||
return string
|
||||
|
||||
return "".join(
|
||||
[char if char in NON_ESCAPED_CHARS else PERCENT(char) for char in string]
|
||||
)
|
||||
|
||||
|
||||
def quote(string: str, safe: str) -> str:
|
||||
"""
|
||||
Use percent-encoding to quote a string, omitting existing '%xx' escape sequences.
|
||||
|
||||
See: https://www.rfc-editor.org/rfc/rfc3986#section-2.1
|
||||
|
||||
* `string`: The string to be percent-escaped.
|
||||
* `safe`: A string containing characters that may be treated as safe, and do not
|
||||
need to be escaped. Unreserved characters are always treated as safe.
|
||||
See: https://www.rfc-editor.org/rfc/rfc3986#section-2.3
|
||||
"""
|
||||
parts = []
|
||||
current_position = 0
|
||||
for match in re.finditer(PERCENT_ENCODED_REGEX, string):
|
||||
start_position, end_position = match.start(), match.end()
|
||||
matched_text = match.group(0)
|
||||
# Add any text up to the '%xx' escape sequence.
|
||||
if start_position != current_position:
|
||||
leading_text = string[current_position:start_position]
|
||||
parts.append(percent_encoded(leading_text, safe=safe))
|
||||
|
||||
# Add the '%xx' escape sequence.
|
||||
parts.append(matched_text)
|
||||
current_position = end_position
|
||||
|
||||
# Add any text after the final '%xx' escape sequence.
|
||||
if current_position != len(string):
|
||||
trailing_text = string[current_position:]
|
||||
parts.append(percent_encoded(trailing_text, safe=safe))
|
||||
|
||||
return "".join(parts)
|
||||
641
httpx/_urls.py
Normal file
@ -0,0 +1,641 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from urllib.parse import parse_qs, unquote, urlencode
|
||||
|
||||
import idna
|
||||
|
||||
from ._types import QueryParamTypes
|
||||
from ._urlparse import urlparse
|
||||
from ._utils import primitive_value_to_str
|
||||
|
||||
__all__ = ["URL", "QueryParams"]
|
||||
|
||||
|
||||
class URL:
|
||||
"""
|
||||
url = httpx.URL("HTTPS://jo%40email.com:a%20secret@müller.de:1234/pa%20th?search=ab#anchorlink")
|
||||
|
||||
assert url.scheme == "https"
|
||||
assert url.username == "jo@email.com"
|
||||
assert url.password == "a secret"
|
||||
assert url.userinfo == b"jo%40email.com:a%20secret"
|
||||
assert url.host == "müller.de"
|
||||
assert url.raw_host == b"xn--mller-kva.de"
|
||||
assert url.port == 1234
|
||||
assert url.netloc == b"xn--mller-kva.de:1234"
|
||||
assert url.path == "/pa th"
|
||||
assert url.query == b"?search=ab"
|
||||
assert url.raw_path == b"/pa%20th?search=ab"
|
||||
assert url.fragment == "anchorlink"
|
||||
|
||||
The components of a URL are broken down like this:
|
||||
|
||||
https://jo%40email.com:a%20secret@müller.de:1234/pa%20th?search=ab#anchorlink
|
||||
[scheme] [ username ] [password] [ host ][port][ path ] [ query ] [fragment]
|
||||
[ userinfo ] [ netloc ][ raw_path ]
|
||||
|
||||
Note that:
|
||||
|
||||
* `url.scheme` is normalized to always be lowercased.
|
||||
|
||||
* `url.host` is normalized to always be lowercased. Internationalized domain
|
||||
names are represented in unicode, without IDNA encoding applied. For instance:
|
||||
|
||||
url = httpx.URL("http://中国.icom.museum")
|
||||
assert url.host == "中国.icom.museum"
|
||||
url = httpx.URL("http://xn--fiqs8s.icom.museum")
|
||||
assert url.host == "中国.icom.museum"
|
||||
|
||||
* `url.raw_host` is normalized to always be lowercased, and is IDNA encoded.
|
||||
|
||||
url = httpx.URL("http://中国.icom.museum")
|
||||
assert url.raw_host == b"xn--fiqs8s.icom.museum"
|
||||
url = httpx.URL("http://xn--fiqs8s.icom.museum")
|
||||
assert url.raw_host == b"xn--fiqs8s.icom.museum"
|
||||
|
||||
* `url.port` is either None or an integer. URLs that include the default port for
|
||||
"http", "https", "ws", "wss", and "ftp" schemes have their port
|
||||
normalized to `None`.
|
||||
|
||||
assert httpx.URL("http://example.com") == httpx.URL("http://example.com:80")
|
||||
assert httpx.URL("http://example.com").port is None
|
||||
assert httpx.URL("http://example.com:80").port is None
|
||||
|
||||
* `url.userinfo` is raw bytes, without URL escaping. Usually you'll want to work
|
||||
with `url.username` and `url.password` instead, which handle the URL escaping.
|
||||
|
||||
* `url.raw_path` is raw bytes of both the path and query, without URL escaping.
|
||||
This portion is used as the target when constructing HTTP requests. Usually you'll
|
||||
want to work with `url.path` instead.
|
||||
|
||||
* `url.query` is raw bytes, without URL escaping. A URL query string portion can
|
||||
only be properly URL escaped when decoding the parameter names and values
|
||||
themselves.
|
||||
"""
|
||||
|
||||
def __init__(self, url: URL | str = "", **kwargs: typing.Any) -> None:
|
||||
if kwargs:
|
||||
allowed = {
|
||||
"scheme": str,
|
||||
"username": str,
|
||||
"password": str,
|
||||
"userinfo": bytes,
|
||||
"host": str,
|
||||
"port": int,
|
||||
"netloc": bytes,
|
||||
"path": str,
|
||||
"query": bytes,
|
||||
"raw_path": bytes,
|
||||
"fragment": str,
|
||||
"params": object,
|
||||
}
|
||||
|
||||
# Perform type checking for all supported keyword arguments.
|
||||
for key, value in kwargs.items():
|
||||
if key not in allowed:
|
||||
message = f"{key!r} is an invalid keyword argument for URL()"
|
||||
raise TypeError(message)
|
||||
if value is not None and not isinstance(value, allowed[key]):
|
||||
expected = allowed[key].__name__
|
||||
seen = type(value).__name__
|
||||
message = f"Argument {key!r} must be {expected} but got {seen}"
|
||||
raise TypeError(message)
|
||||
if isinstance(value, bytes):
|
||||
kwargs[key] = value.decode("ascii")
|
||||
|
||||
if "params" in kwargs:
|
||||
# Replace any "params" keyword with the raw "query" instead.
|
||||
#
|
||||
# Ensure that empty params use `kwargs["query"] = None` rather
|
||||
# than `kwargs["query"] = ""`, so that generated URLs do not
|
||||
# include an empty trailing "?".
|
||||
params = kwargs.pop("params")
|
||||
kwargs["query"] = None if not params else str(QueryParams(params))
|
||||
|
||||
if isinstance(url, str):
|
||||
self._uri_reference = urlparse(url, **kwargs)
|
||||
elif isinstance(url, URL):
|
||||
self._uri_reference = url._uri_reference.copy_with(**kwargs)
|
||||
else:
|
||||
raise TypeError(
|
||||
"Invalid type for url. Expected str or httpx.URL,"
|
||||
f" got {type(url)}: {url!r}"
|
||||
)
|
||||
|
||||
@property
|
||||
def scheme(self) -> str:
|
||||
"""
|
||||
The URL scheme, such as "http", "https".
|
||||
Always normalised to lowercase.
|
||||
"""
|
||||
return self._uri_reference.scheme
|
||||
|
||||
@property
|
||||
def raw_scheme(self) -> bytes:
|
||||
"""
|
||||
The raw bytes representation of the URL scheme, such as b"http", b"https".
|
||||
Always normalised to lowercase.
|
||||
"""
|
||||
return self._uri_reference.scheme.encode("ascii")
|
||||
|
||||
@property
|
||||
def userinfo(self) -> bytes:
|
||||
"""
|
||||
The URL userinfo as a raw bytestring.
|
||||
For example: b"jo%40email.com:a%20secret".
|
||||
"""
|
||||
return self._uri_reference.userinfo.encode("ascii")
|
||||
|
||||
@property
|
||||
def username(self) -> str:
|
||||
"""
|
||||
The URL username as a string, with URL decoding applied.
|
||||
For example: "jo@email.com"
|
||||
"""
|
||||
userinfo = self._uri_reference.userinfo
|
||||
return unquote(userinfo.partition(":")[0])
|
||||
|
||||
@property
|
||||
def password(self) -> str:
|
||||
"""
|
||||
The URL password as a string, with URL decoding applied.
|
||||
For example: "a secret"
|
||||
"""
|
||||
userinfo = self._uri_reference.userinfo
|
||||
return unquote(userinfo.partition(":")[2])
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
"""
|
||||
The URL host as a string.
|
||||
Always normalized to lowercase, with IDNA hosts decoded into unicode.
|
||||
|
||||
Examples:
|
||||
|
||||
url = httpx.URL("http://www.EXAMPLE.org")
|
||||
assert url.host == "www.example.org"
|
||||
|
||||
url = httpx.URL("http://中国.icom.museum")
|
||||
assert url.host == "中国.icom.museum"
|
||||
|
||||
url = httpx.URL("http://xn--fiqs8s.icom.museum")
|
||||
assert url.host == "中国.icom.museum"
|
||||
|
||||
url = httpx.URL("https://[::ffff:192.168.0.1]")
|
||||
assert url.host == "::ffff:192.168.0.1"
|
||||
"""
|
||||
host: str = self._uri_reference.host
|
||||
|
||||
if host.startswith("xn--"):
|
||||
host = idna.decode(host)
|
||||
|
||||
return host
|
||||
|
||||
@property
|
||||
def raw_host(self) -> bytes:
|
||||
"""
|
||||
The raw bytes representation of the URL host.
|
||||
Always normalized to lowercase, and IDNA encoded.
|
||||
|
||||
Examples:
|
||||
|
||||
url = httpx.URL("http://www.EXAMPLE.org")
|
||||
assert url.raw_host == b"www.example.org"
|
||||
|
||||
url = httpx.URL("http://中国.icom.museum")
|
||||
assert url.raw_host == b"xn--fiqs8s.icom.museum"
|
||||
|
||||
url = httpx.URL("http://xn--fiqs8s.icom.museum")
|
||||
assert url.raw_host == b"xn--fiqs8s.icom.museum"
|
||||
|
||||
url = httpx.URL("https://[::ffff:192.168.0.1]")
|
||||
assert url.raw_host == b"::ffff:192.168.0.1"
|
||||
"""
|
||||
return self._uri_reference.host.encode("ascii")
|
||||
|
||||
@property
|
||||
def port(self) -> int | None:
|
||||
"""
|
||||
The URL port as an integer.
|
||||
|
||||
Note that the URL class performs port normalization as per the WHATWG spec.
|
||||
Default ports for "http", "https", "ws", "wss", and "ftp" schemes are always
|
||||
treated as `None`.
|
||||
|
||||
For example:
|
||||
|
||||
assert httpx.URL("http://www.example.com") == httpx.URL("http://www.example.com:80")
|
||||
assert httpx.URL("http://www.example.com:80").port is None
|
||||
"""
|
||||
return self._uri_reference.port
|
||||
|
||||
@property
|
||||
def netloc(self) -> bytes:
|
||||
"""
|
||||
Either `<host>` or `<host>:<port>` as bytes.
|
||||
Always normalized to lowercase, and IDNA encoded.
|
||||
|
||||
This property may be used for generating the value of a request
|
||||
"Host" header.
|
||||
"""
|
||||
return self._uri_reference.netloc.encode("ascii")
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
"""
|
||||
The URL path as a string. Excluding the query string, and URL decoded.
|
||||
|
||||
For example:
|
||||
|
||||
url = httpx.URL("https://example.com/pa%20th")
|
||||
assert url.path == "/pa th"
|
||||
"""
|
||||
path = self._uri_reference.path or "/"
|
||||
return unquote(path)
|
||||
|
||||
@property
|
||||
def query(self) -> bytes:
|
||||
"""
|
||||
The URL query string, as raw bytes, excluding the leading b"?".
|
||||
|
||||
This is necessarily a bytewise interface, because we cannot
|
||||
perform URL decoding of this representation until we've parsed
|
||||
the keys and values into a QueryParams instance.
|
||||
|
||||
For example:
|
||||
|
||||
url = httpx.URL("https://example.com/?filter=some%20search%20terms")
|
||||
assert url.query == b"filter=some%20search%20terms"
|
||||
"""
|
||||
query = self._uri_reference.query or ""
|
||||
return query.encode("ascii")
|
||||
|
||||
@property
|
||||
def params(self) -> QueryParams:
|
||||
"""
|
||||
The URL query parameters, neatly parsed and packaged into an immutable
|
||||
multidict representation.
|
||||
"""
|
||||
return QueryParams(self._uri_reference.query)
|
||||
|
||||
@property
|
||||
def raw_path(self) -> bytes:
|
||||
"""
|
||||
The complete URL path and query string as raw bytes.
|
||||
Used as the target when constructing HTTP requests.
|
||||
|
||||
For example:
|
||||
|
||||
GET /users?search=some%20text HTTP/1.1
|
||||
Host: www.example.org
|
||||
Connection: close
|
||||
"""
|
||||
path = self._uri_reference.path or "/"
|
||||
if self._uri_reference.query is not None:
|
||||
path += "?" + self._uri_reference.query
|
||||
return path.encode("ascii")
|
||||
|
||||
@property
|
||||
def fragment(self) -> str:
|
||||
"""
|
||||
The URL fragments, as used in HTML anchors.
|
||||
As a string, without the leading '#'.
|
||||
"""
|
||||
return unquote(self._uri_reference.fragment or "")
|
||||
|
||||
@property
|
||||
def is_absolute_url(self) -> bool:
|
||||
"""
|
||||
Return `True` for absolute URLs such as 'http://example.com/path',
|
||||
and `False` for relative URLs such as '/path'.
|
||||
"""
|
||||
# We don't use `.is_absolute` from `rfc3986` because it treats
|
||||
# URLs with a fragment portion as not absolute.
|
||||
# What we actually care about is if the URL provides
|
||||
# a scheme and hostname to which connections should be made.
|
||||
return bool(self._uri_reference.scheme and self._uri_reference.host)
|
||||
|
||||
@property
|
||||
def is_relative_url(self) -> bool:
|
||||
"""
|
||||
Return `False` for absolute URLs such as 'http://example.com/path',
|
||||
and `True` for relative URLs such as '/path'.
|
||||
"""
|
||||
return not self.is_absolute_url
|
||||
|
||||
def copy_with(self, **kwargs: typing.Any) -> URL:
|
||||
"""
|
||||
Copy this URL, returning a new URL with some components altered.
|
||||
Accepts the same set of parameters as the components that are made
|
||||
available via properties on the `URL` class.
|
||||
|
||||
For example:
|
||||
|
||||
url = httpx.URL("https://www.example.com").copy_with(
|
||||
username="jo@gmail.com", password="a secret"
|
||||
)
|
||||
assert url == "https://jo%40email.com:a%20secret@www.example.com"
|
||||
"""
|
||||
return URL(self, **kwargs)
|
||||
|
||||
def copy_set_param(self, key: str, value: typing.Any = None) -> URL:
|
||||
return self.copy_with(params=self.params.set(key, value))
|
||||
|
||||
def copy_add_param(self, key: str, value: typing.Any = None) -> URL:
|
||||
return self.copy_with(params=self.params.add(key, value))
|
||||
|
||||
def copy_remove_param(self, key: str) -> URL:
|
||||
return self.copy_with(params=self.params.remove(key))
|
||||
|
||||
def copy_merge_params(self, params: QueryParamTypes) -> URL:
|
||||
return self.copy_with(params=self.params.merge(params))
|
||||
|
||||
def join(self, url: URL | str) -> URL:
|
||||
"""
|
||||
Return an absolute URL, using this URL as the base.
|
||||
|
||||
Eg.
|
||||
|
||||
url = httpx.URL("https://www.example.com/test")
|
||||
url = url.join("/new/path")
|
||||
assert url == "https://www.example.com/new/path"
|
||||
"""
|
||||
from urllib.parse import urljoin
|
||||
|
||||
return URL(urljoin(str(self), str(URL(url))))
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(str(self))
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
return isinstance(other, (URL, str)) and str(self) == str(URL(other))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self._uri_reference)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
scheme, userinfo, host, port, path, query, fragment = self._uri_reference
|
||||
|
||||
if ":" in userinfo:
|
||||
# Mask any password component.
|
||||
userinfo = f"{userinfo.split(':')[0]}:[secure]"
|
||||
|
||||
authority = "".join(
|
||||
[
|
||||
f"{userinfo}@" if userinfo else "",
|
||||
f"[{host}]" if ":" in host else host,
|
||||
f":{port}" if port is not None else "",
|
||||
]
|
||||
)
|
||||
url = "".join(
|
||||
[
|
||||
f"{self.scheme}:" if scheme else "",
|
||||
f"//{authority}" if authority else "",
|
||||
path,
|
||||
f"?{query}" if query is not None else "",
|
||||
f"#{fragment}" if fragment is not None else "",
|
||||
]
|
||||
)
|
||||
|
||||
return f"{self.__class__.__name__}({url!r})"
|
||||
|
||||
@property
|
||||
def raw(self) -> tuple[bytes, bytes, int, bytes]: # pragma: nocover
|
||||
import collections
|
||||
import warnings
|
||||
|
||||
warnings.warn("URL.raw is deprecated.")
|
||||
RawURL = collections.namedtuple(
|
||||
"RawURL", ["raw_scheme", "raw_host", "port", "raw_path"]
|
||||
)
|
||||
return RawURL(
|
||||
raw_scheme=self.raw_scheme,
|
||||
raw_host=self.raw_host,
|
||||
port=self.port,
|
||||
raw_path=self.raw_path,
|
||||
)
|
||||
|
||||
|
||||
class QueryParams(typing.Mapping[str, str]):
|
||||
"""
|
||||
URL query parameters, as a multi-dict.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: QueryParamTypes | None, **kwargs: typing.Any) -> None:
|
||||
assert len(args) < 2, "Too many arguments."
|
||||
assert not (args and kwargs), "Cannot mix named and unnamed arguments."
|
||||
|
||||
value = args[0] if args else kwargs
|
||||
|
||||
if value is None or isinstance(value, (str, bytes)):
|
||||
value = value.decode("ascii") if isinstance(value, bytes) else value
|
||||
self._dict = parse_qs(value, keep_blank_values=True)
|
||||
elif isinstance(value, QueryParams):
|
||||
self._dict = {k: list(v) for k, v in value._dict.items()}
|
||||
else:
|
||||
dict_value: dict[typing.Any, list[typing.Any]] = {}
|
||||
if isinstance(value, (list, tuple)):
|
||||
# Convert list inputs like:
|
||||
# [("a", "123"), ("a", "456"), ("b", "789")]
|
||||
# To a dict representation, like:
|
||||
# {"a": ["123", "456"], "b": ["789"]}
|
||||
for item in value:
|
||||
dict_value.setdefault(item[0], []).append(item[1])
|
||||
else:
|
||||
# Convert dict inputs like:
|
||||
# {"a": "123", "b": ["456", "789"]}
|
||||
# To dict inputs where values are always lists, like:
|
||||
# {"a": ["123"], "b": ["456", "789"]}
|
||||
dict_value = {
|
||||
k: list(v) if isinstance(v, (list, tuple)) else [v]
|
||||
for k, v in value.items()
|
||||
}
|
||||
|
||||
# Ensure that keys and values are neatly coerced to strings.
|
||||
# We coerce values `True` and `False` to JSON-like "true" and "false"
|
||||
# representations, and coerce `None` values to the empty string.
|
||||
self._dict = {
|
||||
str(k): [primitive_value_to_str(item) for item in v]
|
||||
for k, v in dict_value.items()
|
||||
}
|
||||
|
||||
def keys(self) -> typing.KeysView[str]:
|
||||
"""
|
||||
Return all the keys in the query params.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert list(q.keys()) == ["a", "b"]
|
||||
"""
|
||||
return self._dict.keys()
|
||||
|
||||
def values(self) -> typing.ValuesView[str]:
|
||||
"""
|
||||
Return all the values in the query params. If a key occurs more than once
|
||||
only the first item for that key is returned.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert list(q.values()) == ["123", "789"]
|
||||
"""
|
||||
return {k: v[0] for k, v in self._dict.items()}.values()
|
||||
|
||||
def items(self) -> typing.ItemsView[str, str]:
|
||||
"""
|
||||
Return all items in the query params. If a key occurs more than once
|
||||
only the first item for that key is returned.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert list(q.items()) == [("a", "123"), ("b", "789")]
|
||||
"""
|
||||
return {k: v[0] for k, v in self._dict.items()}.items()
|
||||
|
||||
def multi_items(self) -> list[tuple[str, str]]:
|
||||
"""
|
||||
Return all items in the query params. Allow duplicate keys to occur.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert list(q.multi_items()) == [("a", "123"), ("a", "456"), ("b", "789")]
|
||||
"""
|
||||
multi_items: list[tuple[str, str]] = []
|
||||
for k, v in self._dict.items():
|
||||
multi_items.extend([(k, i) for i in v])
|
||||
return multi_items
|
||||
|
||||
def get(self, key: typing.Any, default: typing.Any = None) -> typing.Any:
|
||||
"""
|
||||
Get a value from the query param for a given key. If the key occurs
|
||||
more than once, then only the first value is returned.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert q.get("a") == "123"
|
||||
"""
|
||||
if key in self._dict:
|
||||
return self._dict[str(key)][0]
|
||||
return default
|
||||
|
||||
def get_list(self, key: str) -> list[str]:
|
||||
"""
|
||||
Get all values from the query param for a given key.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123&a=456&b=789")
|
||||
assert q.get_list("a") == ["123", "456"]
|
||||
"""
|
||||
return list(self._dict.get(str(key), []))
|
||||
|
||||
def set(self, key: str, value: typing.Any = None) -> QueryParams:
|
||||
"""
|
||||
Return a new QueryParams instance, setting the value of a key.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.set("a", "456")
|
||||
assert q == httpx.QueryParams("a=456")
|
||||
"""
|
||||
q = QueryParams()
|
||||
q._dict = dict(self._dict)
|
||||
q._dict[str(key)] = [primitive_value_to_str(value)]
|
||||
return q
|
||||
|
||||
def add(self, key: str, value: typing.Any = None) -> QueryParams:
|
||||
"""
|
||||
Return a new QueryParams instance, setting or appending the value of a key.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.add("a", "456")
|
||||
assert q == httpx.QueryParams("a=123&a=456")
|
||||
"""
|
||||
q = QueryParams()
|
||||
q._dict = dict(self._dict)
|
||||
q._dict[str(key)] = q.get_list(key) + [primitive_value_to_str(value)]
|
||||
return q
|
||||
|
||||
def remove(self, key: str) -> QueryParams:
|
||||
"""
|
||||
Return a new QueryParams instance, removing the value of a key.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.remove("a")
|
||||
assert q == httpx.QueryParams("")
|
||||
"""
|
||||
q = QueryParams()
|
||||
q._dict = dict(self._dict)
|
||||
q._dict.pop(str(key), None)
|
||||
return q
|
||||
|
||||
def merge(self, params: QueryParamTypes | None = None) -> QueryParams:
|
||||
"""
|
||||
Return a new QueryParams instance, updated with.
|
||||
|
||||
Usage:
|
||||
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.merge({"b": "456"})
|
||||
assert q == httpx.QueryParams("a=123&b=456")
|
||||
|
||||
q = httpx.QueryParams("a=123")
|
||||
q = q.merge({"a": "456", "b": "789"})
|
||||
assert q == httpx.QueryParams("a=456&b=789")
|
||||
"""
|
||||
q = QueryParams(params)
|
||||
q._dict = {**self._dict, **q._dict}
|
||||
return q
|
||||
|
||||
def __getitem__(self, key: typing.Any) -> str:
|
||||
return self._dict[key][0]
|
||||
|
||||
def __contains__(self, key: typing.Any) -> bool:
|
||||
return key in self._dict
|
||||
|
||||
def __iter__(self) -> typing.Iterator[typing.Any]:
|
||||
return iter(self.keys())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._dict)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._dict)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(str(self))
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
if not isinstance(other, self.__class__):
|
||||
return False
|
||||
return sorted(self.multi_items()) == sorted(other.multi_items())
|
||||
|
||||
def __str__(self) -> str:
|
||||
return urlencode(self.multi_items())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
class_name = self.__class__.__name__
|
||||
query_string = str(self)
|
||||
return f"{class_name}({query_string!r})"
|
||||
|
||||
def update(self, params: QueryParamTypes | None = None) -> None:
|
||||
raise RuntimeError(
|
||||
"QueryParams are immutable since 0.18.0. "
|
||||
"Use `q = q.merge(...)` to create an updated copy."
|
||||
)
|
||||
|
||||
def __setitem__(self, key: str, value: str) -> None:
|
||||
raise RuntimeError(
|
||||
"QueryParams are immutable since 0.18.0. "
|
||||
"Use `q = q.set(key, value)` to create an updated copy."
|
||||
)
|
||||
242
httpx/_utils.py
Normal file
@ -0,0 +1,242 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import typing
|
||||
from urllib.request import getproxies
|
||||
|
||||
from ._types import PrimitiveData
|
||||
|
||||
if typing.TYPE_CHECKING: # pragma: no cover
|
||||
from ._urls import URL
|
||||
|
||||
|
||||
def primitive_value_to_str(value: PrimitiveData) -> str:
|
||||
"""
|
||||
Coerce a primitive data type into a string value.
|
||||
|
||||
Note that we prefer JSON-style 'true'/'false' for boolean values here.
|
||||
"""
|
||||
if value is True:
|
||||
return "true"
|
||||
elif value is False:
|
||||
return "false"
|
||||
elif value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
|
||||
def get_environment_proxies() -> dict[str, str | None]:
|
||||
"""Gets proxy information from the environment"""
|
||||
|
||||
# urllib.request.getproxies() falls back on System
|
||||
# Registry and Config for proxies on Windows and macOS.
|
||||
# We don't want to propagate non-HTTP proxies into
|
||||
# our configuration such as 'TRAVIS_APT_PROXY'.
|
||||
proxy_info = getproxies()
|
||||
mounts: dict[str, str | None] = {}
|
||||
|
||||
for scheme in ("http", "https", "all"):
|
||||
if proxy_info.get(scheme):
|
||||
hostname = proxy_info[scheme]
|
||||
mounts[f"{scheme}://"] = (
|
||||
hostname if "://" in hostname else f"http://{hostname}"
|
||||
)
|
||||
|
||||
no_proxy_hosts = [host.strip() for host in proxy_info.get("no", "").split(",")]
|
||||
for hostname in no_proxy_hosts:
|
||||
# See https://curl.haxx.se/libcurl/c/CURLOPT_NOPROXY.html for details
|
||||
# on how names in `NO_PROXY` are handled.
|
||||
if hostname == "*":
|
||||
# If NO_PROXY=* is used or if "*" occurs as any one of the comma
|
||||
# separated hostnames, then we should just bypass any information
|
||||
# from HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, and always ignore
|
||||
# proxies.
|
||||
return {}
|
||||
elif hostname:
|
||||
# NO_PROXY=.google.com is marked as "all://*.google.com,
|
||||
# which disables "www.google.com" but not "google.com"
|
||||
# NO_PROXY=google.com is marked as "all://*google.com,
|
||||
# which disables "www.google.com" and "google.com".
|
||||
# (But not "wwwgoogle.com")
|
||||
# NO_PROXY can include domains, IPv6, IPv4 addresses and "localhost"
|
||||
# NO_PROXY=example.com,::1,localhost,192.168.0.0/16
|
||||
if "://" in hostname:
|
||||
mounts[hostname] = None
|
||||
elif is_ipv4_hostname(hostname):
|
||||
mounts[f"all://{hostname}"] = None
|
||||
elif is_ipv6_hostname(hostname):
|
||||
mounts[f"all://[{hostname}]"] = None
|
||||
elif hostname.lower() == "localhost":
|
||||
mounts[f"all://{hostname}"] = None
|
||||
else:
|
||||
mounts[f"all://*{hostname}"] = None
|
||||
|
||||
return mounts
|
||||
|
||||
|
||||
def to_bytes(value: str | bytes, encoding: str = "utf-8") -> bytes:
|
||||
return value.encode(encoding) if isinstance(value, str) else value
|
||||
|
||||
|
||||
def to_str(value: str | bytes, encoding: str = "utf-8") -> str:
|
||||
return value if isinstance(value, str) else value.decode(encoding)
|
||||
|
||||
|
||||
def to_bytes_or_str(value: str, match_type_of: typing.AnyStr) -> typing.AnyStr:
|
||||
return value if isinstance(match_type_of, str) else value.encode()
|
||||
|
||||
|
||||
def unquote(value: str) -> str:
|
||||
return value[1:-1] if value[0] == value[-1] == '"' else value
|
||||
|
||||
|
||||
def peek_filelike_length(stream: typing.Any) -> int | None:
|
||||
"""
|
||||
Given a file-like stream object, return its length in number of bytes
|
||||
without reading it into memory.
|
||||
"""
|
||||
try:
|
||||
# Is it an actual file?
|
||||
fd = stream.fileno()
|
||||
# Yup, seems to be an actual file.
|
||||
length = os.fstat(fd).st_size
|
||||
except (AttributeError, OSError):
|
||||
# No... Maybe it's something that supports random access, like `io.BytesIO`?
|
||||
try:
|
||||
# Assuming so, go to end of stream to figure out its length,
|
||||
# then put it back in place.
|
||||
offset = stream.tell()
|
||||
length = stream.seek(0, os.SEEK_END)
|
||||
stream.seek(offset)
|
||||
except (AttributeError, OSError):
|
||||
# Not even that? Sorry, we're doomed...
|
||||
return None
|
||||
|
||||
return length
|
||||
|
||||
|
||||
class URLPattern:
|
||||
"""
|
||||
A utility class currently used for making lookups against proxy keys...
|
||||
|
||||
# Wildcard matching...
|
||||
>>> pattern = URLPattern("all://")
|
||||
>>> pattern.matches(httpx.URL("http://example.com"))
|
||||
True
|
||||
|
||||
# Witch scheme matching...
|
||||
>>> pattern = URLPattern("https://")
|
||||
>>> pattern.matches(httpx.URL("https://example.com"))
|
||||
True
|
||||
>>> pattern.matches(httpx.URL("http://example.com"))
|
||||
False
|
||||
|
||||
# With domain matching...
|
||||
>>> pattern = URLPattern("https://example.com")
|
||||
>>> pattern.matches(httpx.URL("https://example.com"))
|
||||
True
|
||||
>>> pattern.matches(httpx.URL("http://example.com"))
|
||||
False
|
||||
>>> pattern.matches(httpx.URL("https://other.com"))
|
||||
False
|
||||
|
||||
# Wildcard scheme, with domain matching...
|
||||
>>> pattern = URLPattern("all://example.com")
|
||||
>>> pattern.matches(httpx.URL("https://example.com"))
|
||||
True
|
||||
>>> pattern.matches(httpx.URL("http://example.com"))
|
||||
True
|
||||
>>> pattern.matches(httpx.URL("https://other.com"))
|
||||
False
|
||||
|
||||
# With port matching...
|
||||
>>> pattern = URLPattern("https://example.com:1234")
|
||||
>>> pattern.matches(httpx.URL("https://example.com:1234"))
|
||||
True
|
||||
>>> pattern.matches(httpx.URL("https://example.com"))
|
||||
False
|
||||
"""
|
||||
|
||||
def __init__(self, pattern: str) -> None:
|
||||
from ._urls import URL
|
||||
|
||||
if pattern and ":" not in pattern:
|
||||
raise ValueError(
|
||||
f"Proxy keys should use proper URL forms rather "
|
||||
f"than plain scheme strings. "
|
||||
f'Instead of "{pattern}", use "{pattern}://"'
|
||||
)
|
||||
|
||||
url = URL(pattern)
|
||||
self.pattern = pattern
|
||||
self.scheme = "" if url.scheme == "all" else url.scheme
|
||||
self.host = "" if url.host == "*" else url.host
|
||||
self.port = url.port
|
||||
if not url.host or url.host == "*":
|
||||
self.host_regex: typing.Pattern[str] | None = None
|
||||
elif url.host.startswith("*."):
|
||||
# *.example.com should match "www.example.com", but not "example.com"
|
||||
domain = re.escape(url.host[2:])
|
||||
self.host_regex = re.compile(f"^.+\\.{domain}$")
|
||||
elif url.host.startswith("*"):
|
||||
# *example.com should match "www.example.com" and "example.com"
|
||||
domain = re.escape(url.host[1:])
|
||||
self.host_regex = re.compile(f"^(.+\\.)?{domain}$")
|
||||
else:
|
||||
# example.com should match "example.com" but not "www.example.com"
|
||||
domain = re.escape(url.host)
|
||||
self.host_regex = re.compile(f"^{domain}$")
|
||||
|
||||
def matches(self, other: URL) -> bool:
|
||||
if self.scheme and self.scheme != other.scheme:
|
||||
return False
|
||||
if (
|
||||
self.host
|
||||
and self.host_regex is not None
|
||||
and not self.host_regex.match(other.host)
|
||||
):
|
||||
return False
|
||||
if self.port is not None and self.port != other.port:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def priority(self) -> tuple[int, int, int]:
|
||||
"""
|
||||
The priority allows URLPattern instances to be sortable, so that
|
||||
we can match from most specific to least specific.
|
||||
"""
|
||||
# URLs with a port should take priority over URLs without a port.
|
||||
port_priority = 0 if self.port is not None else 1
|
||||
# Longer hostnames should match first.
|
||||
host_priority = -len(self.host)
|
||||
# Longer schemes should match first.
|
||||
scheme_priority = -len(self.scheme)
|
||||
return (port_priority, host_priority, scheme_priority)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.pattern)
|
||||
|
||||
def __lt__(self, other: URLPattern) -> bool:
|
||||
return self.priority < other.priority
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
return isinstance(other, URLPattern) and self.pattern == other.pattern
|
||||
|
||||
|
||||
def is_ipv4_hostname(hostname: str) -> bool:
|
||||
try:
|
||||
ipaddress.IPv4Address(hostname.split("/")[0])
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_ipv6_hostname(hostname: str) -> bool:
|
||||
try:
|
||||
ipaddress.IPv6Address(hostname.split("/")[0])
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
295
httpx/api.py
@ -1,295 +0,0 @@
|
||||
import typing
|
||||
|
||||
from .client import Client
|
||||
from .config import CertTypes, TimeoutTypes, VerifyTypes
|
||||
from .models import (
|
||||
AuthTypes,
|
||||
CookieTypes,
|
||||
HeaderTypes,
|
||||
ProxiesTypes,
|
||||
QueryParamTypes,
|
||||
RequestData,
|
||||
RequestFiles,
|
||||
Response,
|
||||
URLTypes,
|
||||
)
|
||||
|
||||
|
||||
def request(
|
||||
method: str,
|
||||
url: URLTypes,
|
||||
*,
|
||||
params: QueryParamTypes = None,
|
||||
data: RequestData = None,
|
||||
files: RequestFiles = None,
|
||||
json: typing.Any = None,
|
||||
headers: HeaderTypes = None,
|
||||
cookies: CookieTypes = None,
|
||||
auth: AuthTypes = None,
|
||||
timeout: TimeoutTypes = None,
|
||||
allow_redirects: bool = True,
|
||||
cert: CertTypes = None,
|
||||
verify: VerifyTypes = True,
|
||||
stream: bool = False,
|
||||
trust_env: bool = None,
|
||||
proxies: ProxiesTypes = None,
|
||||
) -> Response:
|
||||
with Client(http_versions=["HTTP/1.1"]) as client:
|
||||
return client.request(
|
||||
method=method,
|
||||
url=url,
|
||||
data=data,
|
||||
files=files,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
stream=stream,
|
||||
auth=auth,
|
||||
allow_redirects=allow_redirects,
|
||||
cert=cert,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
trust_env=trust_env,
|
||||
)
|
||||
|
||||
|
||||
def get(
|
||||
url: URLTypes,
|
||||
*,
|
||||
params: QueryParamTypes = None,
|
||||
headers: HeaderTypes = None,
|
||||
cookies: CookieTypes = None,
|
||||
stream: bool = False,
|
||||
auth: AuthTypes = None,
|
||||
allow_redirects: bool = True,
|
||||
cert: CertTypes = None,
|
||||
verify: VerifyTypes = True,
|
||||
timeout: TimeoutTypes = None,
|
||||
trust_env: bool = None,
|
||||
proxies: ProxiesTypes = None,
|
||||
) -> Response:
|
||||
return request(
|
||||
"GET",
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
stream=stream,
|
||||
auth=auth,
|
||||
allow_redirects=allow_redirects,
|
||||
cert=cert,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
trust_env=trust_env,
|
||||
)
|
||||
|
||||
|
||||
def options(
|
||||
url: URLTypes,
|
||||
*,
|
||||
params: QueryParamTypes = None,
|
||||
headers: HeaderTypes = None,
|
||||
cookies: CookieTypes = None,
|
||||
stream: bool = False,
|
||||
auth: AuthTypes = None,
|
||||
allow_redirects: bool = True,
|
||||
cert: CertTypes = None,
|
||||
verify: VerifyTypes = True,
|
||||
timeout: TimeoutTypes = None,
|
||||
trust_env: bool = None,
|
||||
proxies: ProxiesTypes = None,
|
||||
) -> Response:
|
||||
return request(
|
||||
"OPTIONS",
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
stream=stream,
|
||||
auth=auth,
|
||||
allow_redirects=allow_redirects,
|
||||
cert=cert,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
trust_env=trust_env,
|
||||
)
|
||||
|
||||
|
||||
def head(
|
||||
url: URLTypes,
|
||||
*,
|
||||
params: QueryParamTypes = None,
|
||||
headers: HeaderTypes = None,
|
||||
cookies: CookieTypes = None,
|
||||
stream: bool = False,
|
||||
auth: AuthTypes = None,
|
||||
allow_redirects: bool = False, # Note: Differs to usual default.
|
||||
cert: CertTypes = None,
|
||||
verify: VerifyTypes = True,
|
||||
timeout: TimeoutTypes = None,
|
||||
trust_env: bool = None,
|
||||
proxies: ProxiesTypes = None,
|
||||
) -> Response:
|
||||
return request(
|
||||
"HEAD",
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
stream=stream,
|
||||
auth=auth,
|
||||
allow_redirects=allow_redirects,
|
||||
cert=cert,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
trust_env=trust_env,
|
||||
)
|
||||
|
||||
|
||||
def post(
|
||||
url: URLTypes,
|
||||
*,
|
||||
data: RequestData = None,
|
||||
files: RequestFiles = None,
|
||||
json: typing.Any = None,
|
||||
params: QueryParamTypes = None,
|
||||
headers: HeaderTypes = None,
|
||||
cookies: CookieTypes = None,
|
||||
stream: bool = False,
|
||||
auth: AuthTypes = None,
|
||||
allow_redirects: bool = True,
|
||||
cert: CertTypes = None,
|
||||
verify: VerifyTypes = True,
|
||||
timeout: TimeoutTypes = None,
|
||||
trust_env: bool = None,
|
||||
proxies: ProxiesTypes = None,
|
||||
) -> Response:
|
||||
return request(
|
||||
"POST",
|
||||
url,
|
||||
data=data,
|
||||
files=files,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
stream=stream,
|
||||
auth=auth,
|
||||
allow_redirects=allow_redirects,
|
||||
cert=cert,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
trust_env=trust_env,
|
||||
)
|
||||
|
||||
|
||||
def put(
|
||||
url: URLTypes,
|
||||
*,
|
||||
data: RequestData = None,
|
||||
files: RequestFiles = None,
|
||||
json: typing.Any = None,
|
||||
params: QueryParamTypes = None,
|
||||
headers: HeaderTypes = None,
|
||||
cookies: CookieTypes = None,
|
||||
stream: bool = False,
|
||||
auth: AuthTypes = None,
|
||||
allow_redirects: bool = True,
|
||||
cert: CertTypes = None,
|
||||
verify: VerifyTypes = True,
|
||||
timeout: TimeoutTypes = None,
|
||||
trust_env: bool = None,
|
||||
proxies: ProxiesTypes = None,
|
||||
) -> Response:
|
||||
return request(
|
||||
"PUT",
|
||||
url,
|
||||
data=data,
|
||||
files=files,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
stream=stream,
|
||||
auth=auth,
|
||||
allow_redirects=allow_redirects,
|
||||
cert=cert,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
trust_env=trust_env,
|
||||
)
|
||||
|
||||
|
||||
def patch(
|
||||
url: URLTypes,
|
||||
*,
|
||||
data: RequestData = None,
|
||||
files: RequestFiles = None,
|
||||
json: typing.Any = None,
|
||||
params: QueryParamTypes = None,
|
||||
headers: HeaderTypes = None,
|
||||
cookies: CookieTypes = None,
|
||||
stream: bool = False,
|
||||
auth: AuthTypes = None,
|
||||
allow_redirects: bool = True,
|
||||
cert: CertTypes = None,
|
||||
verify: VerifyTypes = True,
|
||||
timeout: TimeoutTypes = None,
|
||||
trust_env: bool = None,
|
||||
proxies: ProxiesTypes = None,
|
||||
) -> Response:
|
||||
return request(
|
||||
"PATCH",
|
||||
url,
|
||||
data=data,
|
||||
files=files,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
stream=stream,
|
||||
auth=auth,
|
||||
allow_redirects=allow_redirects,
|
||||
cert=cert,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
trust_env=trust_env,
|
||||
)
|
||||
|
||||
|
||||
def delete(
|
||||
url: URLTypes,
|
||||
*,
|
||||
data: RequestData = None,
|
||||
files: RequestFiles = None,
|
||||
json: typing.Any = None,
|
||||
params: QueryParamTypes = None,
|
||||
headers: HeaderTypes = None,
|
||||
cookies: CookieTypes = None,
|
||||
stream: bool = False,
|
||||
auth: AuthTypes = None,
|
||||
allow_redirects: bool = True,
|
||||
cert: CertTypes = None,
|
||||
verify: VerifyTypes = True,
|
||||
timeout: TimeoutTypes = None,
|
||||
trust_env: bool = None,
|
||||
proxies: ProxiesTypes = None,
|
||||
) -> Response:
|
||||
return request(
|
||||
"DELETE",
|
||||
url,
|
||||
data=data,
|
||||
files=files,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
stream=stream,
|
||||
auth=auth,
|
||||
allow_redirects=allow_redirects,
|
||||
cert=cert,
|
||||
verify=verify,
|
||||
timeout=timeout,
|
||||
trust_env=trust_env,
|
||||
)
|
||||
1082
httpx/client.py
@ -1,298 +0,0 @@
|
||||
import asyncio
|
||||
import functools
|
||||
import ssl
|
||||
import typing
|
||||
from types import TracebackType
|
||||
|
||||
from ..config import PoolLimits, TimeoutConfig
|
||||
from ..exceptions import ConnectTimeout, PoolTimeout, ReadTimeout, WriteTimeout
|
||||
from .base import (
|
||||
BaseBackgroundManager,
|
||||
BaseEvent,
|
||||
BasePoolSemaphore,
|
||||
BaseQueue,
|
||||
BaseTCPStream,
|
||||
ConcurrencyBackend,
|
||||
TimeoutFlag,
|
||||
)
|
||||
|
||||
SSL_MONKEY_PATCH_APPLIED = False
|
||||
|
||||
|
||||
def ssl_monkey_patch() -> None:
|
||||
"""
|
||||
Monkey-patch for https://bugs.python.org/issue36709
|
||||
|
||||
This prevents console errors when outstanding HTTPS connections
|
||||
still exist at the point of exiting.
|
||||
|
||||
Clients which have been opened using a `with` block, or which have
|
||||
had `close()` closed, will not exhibit this issue in the first place.
|
||||
"""
|
||||
MonkeyPatch = asyncio.selector_events._SelectorSocketTransport # type: ignore
|
||||
|
||||
_write = MonkeyPatch.write
|
||||
|
||||
def _fixed_write(self, data: bytes) -> None: # type: ignore
|
||||
if self._loop and not self._loop.is_closed():
|
||||
_write(self, data)
|
||||
|
||||
MonkeyPatch.write = _fixed_write
|
||||
|
||||
|
||||
class TCPStream(BaseTCPStream):
|
||||
def __init__(
|
||||
self,
|
||||
stream_reader: asyncio.StreamReader,
|
||||
stream_writer: asyncio.StreamWriter,
|
||||
timeout: TimeoutConfig,
|
||||
):
|
||||
self.stream_reader = stream_reader
|
||||
self.stream_writer = stream_writer
|
||||
self.timeout = timeout
|
||||
|
||||
def get_http_version(self) -> str:
|
||||
ssl_object = self.stream_writer.get_extra_info("ssl_object")
|
||||
|
||||
if ssl_object is None:
|
||||
return "HTTP/1.1"
|
||||
|
||||
ident = ssl_object.selected_alpn_protocol()
|
||||
|
||||
if ident is None:
|
||||
return "HTTP/1.1"
|
||||
|
||||
return "HTTP/2" if ident == "h2" else "HTTP/1.1"
|
||||
|
||||
async def read(
|
||||
self, n: int, timeout: TimeoutConfig = None, flag: TimeoutFlag = None
|
||||
) -> bytes:
|
||||
if timeout is None:
|
||||
timeout = self.timeout
|
||||
|
||||
while True:
|
||||
# Check our flag at the first possible moment, and use a fine
|
||||
# grained retry loop if we're not yet in read-timeout mode.
|
||||
should_raise = flag is None or flag.raise_on_read_timeout
|
||||
read_timeout = timeout.read_timeout if should_raise else 0.01
|
||||
try:
|
||||
data = await asyncio.wait_for(self.stream_reader.read(n), read_timeout)
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
if should_raise:
|
||||
raise ReadTimeout() from None
|
||||
# FIX(py3.6): yield control back to the event loop to give it a chance
|
||||
# to cancel `.read(n)` before we retry.
|
||||
# This prevents concurrent `.read()` calls, which asyncio
|
||||
# doesn't seem to allow on 3.6.
|
||||
# See: https://github.com/encode/httpx/issues/382
|
||||
await asyncio.sleep(0)
|
||||
|
||||
return data
|
||||
|
||||
def write_no_block(self, data: bytes) -> None:
|
||||
self.stream_writer.write(data) # pragma: nocover
|
||||
|
||||
async def write(
|
||||
self, data: bytes, timeout: TimeoutConfig = None, flag: TimeoutFlag = None
|
||||
) -> None:
|
||||
if not data:
|
||||
return
|
||||
|
||||
if timeout is None:
|
||||
timeout = self.timeout
|
||||
|
||||
self.stream_writer.write(data)
|
||||
while True:
|
||||
try:
|
||||
await asyncio.wait_for( # type: ignore
|
||||
self.stream_writer.drain(), timeout.write_timeout
|
||||
)
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
# We check our flag at the first possible moment, in order to
|
||||
# allow us to suppress write timeouts, if we've since
|
||||
# switched over to read-timeout mode.
|
||||
should_raise = flag is None or flag.raise_on_write_timeout
|
||||
if should_raise:
|
||||
raise WriteTimeout() from None
|
||||
|
||||
def is_connection_dropped(self) -> bool:
|
||||
# Counter-intuitively, what we really want to know here is whether the socket is
|
||||
# *readable*, i.e. whether it would return immediately with empty bytes if we
|
||||
# called `.recv()` on it, indicating that the other end has closed the socket.
|
||||
# See: https://github.com/encode/httpx/pull/143#issuecomment-515181778
|
||||
#
|
||||
# As it turns out, asyncio checks for readability in the background
|
||||
# (see: https://github.com/encode/httpx/pull/276#discussion_r322000402),
|
||||
# so checking for EOF or readability here would yield the same result.
|
||||
#
|
||||
# At the cost of rigour, we check for EOF instead of readability because asyncio
|
||||
# does not expose any public API to check for readability.
|
||||
# (For a solution that uses private asyncio APIs, see:
|
||||
# https://github.com/encode/httpx/pull/143#issuecomment-515202982)
|
||||
|
||||
return self.stream_reader.at_eof()
|
||||
|
||||
async def close(self) -> None:
|
||||
self.stream_writer.close()
|
||||
|
||||
|
||||
class PoolSemaphore(BasePoolSemaphore):
|
||||
def __init__(self, pool_limits: PoolLimits):
|
||||
self.pool_limits = pool_limits
|
||||
|
||||
@property
|
||||
def semaphore(self) -> typing.Optional[asyncio.BoundedSemaphore]:
|
||||
if not hasattr(self, "_semaphore"):
|
||||
max_connections = self.pool_limits.hard_limit
|
||||
if max_connections is None:
|
||||
self._semaphore = None
|
||||
else:
|
||||
self._semaphore = asyncio.BoundedSemaphore(value=max_connections)
|
||||
return self._semaphore
|
||||
|
||||
async def acquire(self) -> None:
|
||||
if self.semaphore is None:
|
||||
return
|
||||
|
||||
timeout = self.pool_limits.pool_timeout
|
||||
try:
|
||||
await asyncio.wait_for(self.semaphore.acquire(), timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise PoolTimeout()
|
||||
|
||||
def release(self) -> None:
|
||||
if self.semaphore is None:
|
||||
return
|
||||
|
||||
self.semaphore.release()
|
||||
|
||||
|
||||
class AsyncioBackend(ConcurrencyBackend):
|
||||
def __init__(self) -> None:
|
||||
global SSL_MONKEY_PATCH_APPLIED
|
||||
|
||||
if not SSL_MONKEY_PATCH_APPLIED:
|
||||
ssl_monkey_patch()
|
||||
SSL_MONKEY_PATCH_APPLIED = True
|
||||
|
||||
@property
|
||||
def loop(self) -> asyncio.AbstractEventLoop:
|
||||
if not hasattr(self, "_loop"):
|
||||
try:
|
||||
self._loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
self._loop = asyncio.new_event_loop()
|
||||
return self._loop
|
||||
|
||||
async def open_tcp_stream(
|
||||
self,
|
||||
hostname: str,
|
||||
port: int,
|
||||
ssl_context: typing.Optional[ssl.SSLContext],
|
||||
timeout: TimeoutConfig,
|
||||
) -> BaseTCPStream:
|
||||
try:
|
||||
stream_reader, stream_writer = await asyncio.wait_for( # type: ignore
|
||||
asyncio.open_connection(hostname, port, ssl=ssl_context),
|
||||
timeout.connect_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise ConnectTimeout()
|
||||
|
||||
return TCPStream(
|
||||
stream_reader=stream_reader, stream_writer=stream_writer, timeout=timeout
|
||||
)
|
||||
|
||||
async def start_tls(
|
||||
self,
|
||||
stream: BaseTCPStream,
|
||||
hostname: str,
|
||||
ssl_context: ssl.SSLContext,
|
||||
timeout: TimeoutConfig,
|
||||
) -> BaseTCPStream:
|
||||
|
||||
loop = self.loop
|
||||
if not hasattr(loop, "start_tls"): # pragma: no cover
|
||||
raise NotImplementedError(
|
||||
"asyncio.AbstractEventLoop.start_tls() is only available in Python 3.7+"
|
||||
)
|
||||
|
||||
assert isinstance(stream, TCPStream)
|
||||
|
||||
stream_reader = asyncio.StreamReader()
|
||||
protocol = asyncio.StreamReaderProtocol(stream_reader)
|
||||
transport = stream.stream_writer.transport
|
||||
|
||||
loop_start_tls = loop.start_tls # type: ignore
|
||||
transport = await asyncio.wait_for(
|
||||
loop_start_tls(
|
||||
transport=transport,
|
||||
protocol=protocol,
|
||||
sslcontext=ssl_context,
|
||||
server_hostname=hostname,
|
||||
),
|
||||
timeout=timeout.connect_timeout,
|
||||
)
|
||||
|
||||
stream_reader.set_transport(transport)
|
||||
stream.stream_reader = stream_reader
|
||||
stream.stream_writer = asyncio.StreamWriter(
|
||||
transport=transport, protocol=protocol, reader=stream_reader, loop=loop
|
||||
)
|
||||
return stream
|
||||
|
||||
async def run_in_threadpool(
|
||||
self, func: typing.Callable, *args: typing.Any, **kwargs: typing.Any
|
||||
) -> typing.Any:
|
||||
if kwargs:
|
||||
# loop.run_in_executor doesn't accept 'kwargs', so bind them in here
|
||||
func = functools.partial(func, **kwargs)
|
||||
return await self.loop.run_in_executor(None, func, *args)
|
||||
|
||||
def run(
|
||||
self, coroutine: typing.Callable, *args: typing.Any, **kwargs: typing.Any
|
||||
) -> typing.Any:
|
||||
loop = self.loop
|
||||
if loop.is_running():
|
||||
self._loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return self.loop.run_until_complete(coroutine(*args, **kwargs))
|
||||
finally:
|
||||
self._loop = loop
|
||||
|
||||
def get_semaphore(self, limits: PoolLimits) -> BasePoolSemaphore:
|
||||
return PoolSemaphore(limits)
|
||||
|
||||
def create_queue(self, max_size: int) -> BaseQueue:
|
||||
return typing.cast(BaseQueue, asyncio.Queue(maxsize=max_size))
|
||||
|
||||
def create_event(self) -> BaseEvent:
|
||||
return typing.cast(BaseEvent, asyncio.Event())
|
||||
|
||||
def background_manager(
|
||||
self, coroutine: typing.Callable, *args: typing.Any
|
||||
) -> "BackgroundManager":
|
||||
return BackgroundManager(coroutine, args)
|
||||
|
||||
|
||||
class BackgroundManager(BaseBackgroundManager):
|
||||
def __init__(self, coroutine: typing.Callable, args: typing.Any) -> None:
|
||||
self.coroutine = coroutine
|
||||
self.args = args
|
||||
|
||||
async def __aenter__(self) -> "BackgroundManager":
|
||||
loop = asyncio.get_event_loop()
|
||||
self.task = loop.create_task(self.coroutine(*self.args))
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: typing.Type[BaseException] = None,
|
||||
exc_value: BaseException = None,
|
||||
traceback: TracebackType = None,
|
||||
) -> None:
|
||||
await self.task
|
||||
if exc_type is None:
|
||||
self.task.result()
|
||||
@ -1,196 +0,0 @@
|
||||
import ssl
|
||||
import typing
|
||||
from types import TracebackType
|
||||
|
||||
from ..config import PoolLimits, TimeoutConfig
|
||||
|
||||
|
||||
class TimeoutFlag:
|
||||
"""
|
||||
A timeout flag holds a state of either read-timeout or write-timeout mode.
|
||||
|
||||
We use this so that we can attempt both reads and writes concurrently, while
|
||||
only enforcing timeouts in one direction.
|
||||
|
||||
During a request/response cycle we start in write-timeout mode.
|
||||
|
||||
Once we've sent a request fully, or once we start seeing a response,
|
||||
then we switch to read-timeout mode instead.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.raise_on_read_timeout = False
|
||||
self.raise_on_write_timeout = True
|
||||
|
||||
def set_read_timeouts(self) -> None:
|
||||
"""
|
||||
Set the flag to read-timeout mode.
|
||||
"""
|
||||
self.raise_on_read_timeout = True
|
||||
self.raise_on_write_timeout = False
|
||||
|
||||
def set_write_timeouts(self) -> None:
|
||||
"""
|
||||
Set the flag to write-timeout mode.
|
||||
"""
|
||||
self.raise_on_read_timeout = False
|
||||
self.raise_on_write_timeout = True
|
||||
|
||||
|
||||
class BaseTCPStream:
|
||||
"""
|
||||
A TCP stream with read/write operations. Abstracts away any asyncio-specific
|
||||
interfaces into a more generic base class, that we can use with alternate
|
||||
backends, or for stand-alone test cases.
|
||||
"""
|
||||
|
||||
def get_http_version(self) -> str:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
async def read(
|
||||
self, n: int, timeout: TimeoutConfig = None, flag: typing.Any = None
|
||||
) -> bytes:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
def write_no_block(self, data: bytes) -> None:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
async def write(self, data: bytes, timeout: TimeoutConfig = None) -> None:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
async def close(self) -> None:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
def is_connection_dropped(self) -> bool:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
|
||||
class BaseQueue:
|
||||
"""
|
||||
A FIFO queue. Abstracts away any asyncio-specific interfaces.
|
||||
"""
|
||||
|
||||
async def get(self) -> typing.Any:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
async def put(self, value: typing.Any) -> None:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
|
||||
class BaseEvent:
|
||||
"""
|
||||
An event object. Abstracts away any asyncio-specific interfaces.
|
||||
"""
|
||||
|
||||
def set(self) -> None:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
def is_set(self) -> bool:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
def clear(self) -> None:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
async def wait(self) -> None:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
|
||||
class BasePoolSemaphore:
|
||||
"""
|
||||
A semaphore for use with connection pooling.
|
||||
|
||||
Abstracts away any asyncio-specific interfaces.
|
||||
"""
|
||||
|
||||
async def acquire(self) -> None:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
def release(self) -> None:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
|
||||
class ConcurrencyBackend:
|
||||
async def open_tcp_stream(
|
||||
self,
|
||||
hostname: str,
|
||||
port: int,
|
||||
ssl_context: typing.Optional[ssl.SSLContext],
|
||||
timeout: TimeoutConfig,
|
||||
) -> BaseTCPStream:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
async def start_tls(
|
||||
self,
|
||||
stream: BaseTCPStream,
|
||||
hostname: str,
|
||||
ssl_context: ssl.SSLContext,
|
||||
timeout: TimeoutConfig,
|
||||
) -> BaseTCPStream:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
def get_semaphore(self, limits: PoolLimits) -> BasePoolSemaphore:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
async def run_in_threadpool(
|
||||
self, func: typing.Callable, *args: typing.Any, **kwargs: typing.Any
|
||||
) -> typing.Any:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
async def iterate_in_threadpool(self, iterator): # type: ignore
|
||||
class IterationComplete(Exception):
|
||||
pass
|
||||
|
||||
def next_wrapper(iterator): # type: ignore
|
||||
try:
|
||||
return next(iterator)
|
||||
except StopIteration:
|
||||
raise IterationComplete()
|
||||
|
||||
while True:
|
||||
try:
|
||||
yield await self.run_in_threadpool(next_wrapper, iterator)
|
||||
except IterationComplete:
|
||||
break
|
||||
|
||||
def run(
|
||||
self, coroutine: typing.Callable, *args: typing.Any, **kwargs: typing.Any
|
||||
) -> typing.Any:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
def iterate(self, async_iterator): # type: ignore
|
||||
while True:
|
||||
try:
|
||||
yield self.run(async_iterator.__anext__)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
|
||||
def create_queue(self, max_size: int) -> BaseQueue:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
def create_event(self) -> BaseEvent:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
def background_manager(
|
||||
self, coroutine: typing.Callable, *args: typing.Any
|
||||
) -> "BaseBackgroundManager":
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
|
||||
class BaseBackgroundManager:
|
||||
async def __aenter__(self) -> "BaseBackgroundManager":
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: typing.Type[BaseException] = None,
|
||||
exc_value: BaseException = None,
|
||||
traceback: TracebackType = None,
|
||||
) -> None:
|
||||
raise NotImplementedError() # pragma: no cover
|
||||
|
||||
async def close(self, exception: BaseException = None) -> None:
|
||||
if exception is None:
|
||||
await self.__aexit__(None, None, None)
|
||||
else:
|
||||
traceback = exception.__traceback__ # type: ignore
|
||||
await self.__aexit__(type(exception), exception, traceback)
|
||||
@ -1,255 +0,0 @@
|
||||
import functools
|
||||
import math
|
||||
import ssl
|
||||
import typing
|
||||
from types import TracebackType
|
||||
|
||||
import trio
|
||||
|
||||
from ..config import PoolLimits, TimeoutConfig
|
||||
from ..exceptions import ConnectTimeout, PoolTimeout, ReadTimeout, WriteTimeout
|
||||
from .base import (
|
||||
BaseBackgroundManager,
|
||||
BaseEvent,
|
||||
BasePoolSemaphore,
|
||||
BaseQueue,
|
||||
BaseTCPStream,
|
||||
ConcurrencyBackend,
|
||||
TimeoutFlag,
|
||||
)
|
||||
|
||||
|
||||
def _or_inf(value: typing.Optional[float]) -> float:
|
||||
return value if value is not None else float("inf")
|
||||
|
||||
|
||||
class TCPStream(BaseTCPStream):
|
||||
def __init__(
|
||||
self,
|
||||
stream: typing.Union[trio.SocketStream, trio.SSLStream],
|
||||
timeout: TimeoutConfig,
|
||||
) -> None:
|
||||
self.stream = stream
|
||||
self.timeout = timeout
|
||||
self.write_buffer = b""
|
||||
self.write_lock = trio.Lock()
|
||||
|
||||
def get_http_version(self) -> str:
|
||||
if not isinstance(self.stream, trio.SSLStream):
|
||||
return "HTTP/1.1"
|
||||
|
||||
ident = self.stream.selected_alpn_protocol()
|
||||
if ident is None:
|
||||
return "HTTP/1.1"
|
||||
|
||||
return "HTTP/2" if ident == "h2" else "HTTP/1.1"
|
||||
|
||||
async def read(
|
||||
self, n: int, timeout: TimeoutConfig = None, flag: TimeoutFlag = None
|
||||
) -> bytes:
|
||||
if timeout is None:
|
||||
timeout = self.timeout
|
||||
|
||||
while True:
|
||||
# Check our flag at the first possible moment, and use a fine
|
||||
# grained retry loop if we're not yet in read-timeout mode.
|
||||
should_raise = flag is None or flag.raise_on_read_timeout
|
||||
read_timeout = _or_inf(timeout.read_timeout if should_raise else 0.01)
|
||||
|
||||
with trio.move_on_after(read_timeout):
|
||||
return await self.stream.receive_some(max_bytes=n)
|
||||
|
||||
if should_raise:
|
||||
raise ReadTimeout() from None
|
||||
|
||||
def is_connection_dropped(self) -> bool:
|
||||
# Adapted from: https://github.com/encode/httpx/pull/143#issuecomment-515202982
|
||||
stream = self.stream
|
||||
|
||||
# Peek through any SSLStream wrappers to get the underlying SocketStream.
|
||||
while hasattr(stream, "transport_stream"):
|
||||
stream = stream.transport_stream
|
||||
assert isinstance(stream, trio.SocketStream)
|
||||
|
||||
# Counter-intuitively, what we really want to know here is whether the socket is
|
||||
# *readable*, i.e. whether it would return immediately with empty bytes if we
|
||||
# called `.recv()` on it, indicating that the other end has closed the socket.
|
||||
# See: https://github.com/encode/httpx/pull/143#issuecomment-515181778
|
||||
return stream.socket.is_readable()
|
||||
|
||||
def write_no_block(self, data: bytes) -> None:
|
||||
self.write_buffer += data
|
||||
|
||||
async def write(
|
||||
self, data: bytes, timeout: TimeoutConfig = None, flag: TimeoutFlag = None
|
||||
) -> None:
|
||||
if self.write_buffer:
|
||||
previous_data = self.write_buffer
|
||||
# Reset before recursive call, otherwise we'll go through
|
||||
# this branch indefinitely.
|
||||
self.write_buffer = b""
|
||||
try:
|
||||
await self.write(previous_data, timeout=timeout, flag=flag)
|
||||
except WriteTimeout:
|
||||
self.writer_buffer = previous_data
|
||||
raise
|
||||
|
||||
if not data:
|
||||
return
|
||||
|
||||
if timeout is None:
|
||||
timeout = self.timeout
|
||||
|
||||
write_timeout = _or_inf(timeout.write_timeout)
|
||||
|
||||
while True:
|
||||
with trio.move_on_after(write_timeout):
|
||||
async with self.write_lock:
|
||||
await self.stream.send_all(data)
|
||||
break
|
||||
# We check our flag at the first possible moment, in order to
|
||||
# allow us to suppress write timeouts, if we've since
|
||||
# switched over to read-timeout mode.
|
||||
should_raise = flag is None or flag.raise_on_write_timeout
|
||||
if should_raise:
|
||||
raise WriteTimeout() from None
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.stream.aclose()
|
||||
|
||||
|
||||
class PoolSemaphore(BasePoolSemaphore):
|
||||
def __init__(self, pool_limits: PoolLimits):
|
||||
self.pool_limits = pool_limits
|
||||
|
||||
@property
|
||||
def semaphore(self) -> typing.Optional[trio.Semaphore]:
|
||||
if not hasattr(self, "_semaphore"):
|
||||
max_connections = self.pool_limits.hard_limit
|
||||
if max_connections is None:
|
||||
self._semaphore = None
|
||||
else:
|
||||
self._semaphore = trio.Semaphore(
|
||||
max_connections, max_value=max_connections
|
||||
)
|
||||
return self._semaphore
|
||||
|
||||
async def acquire(self) -> None:
|
||||
if self.semaphore is None:
|
||||
return
|
||||
|
||||
timeout = _or_inf(self.pool_limits.pool_timeout)
|
||||
|
||||
with trio.move_on_after(timeout):
|
||||
await self.semaphore.acquire()
|
||||
return
|
||||
|
||||
raise PoolTimeout()
|
||||
|
||||
def release(self) -> None:
|
||||
if self.semaphore is None:
|
||||
return
|
||||
|
||||
self.semaphore.release()
|
||||
|
||||
|
||||
class TrioBackend(ConcurrencyBackend):
|
||||
async def open_tcp_stream(
|
||||
self,
|
||||
hostname: str,
|
||||
port: int,
|
||||
ssl_context: typing.Optional[ssl.SSLContext],
|
||||
timeout: TimeoutConfig,
|
||||
) -> TCPStream:
|
||||
connect_timeout = _or_inf(timeout.connect_timeout)
|
||||
|
||||
with trio.move_on_after(connect_timeout) as cancel_scope:
|
||||
stream: trio.SocketStream = await trio.open_tcp_stream(hostname, port)
|
||||
if ssl_context is not None:
|
||||
stream = trio.SSLStream(stream, ssl_context, server_hostname=hostname)
|
||||
await stream.do_handshake()
|
||||
|
||||
if cancel_scope.cancelled_caught:
|
||||
raise ConnectTimeout()
|
||||
|
||||
return TCPStream(stream=stream, timeout=timeout)
|
||||
|
||||
async def run_in_threadpool(
|
||||
self, func: typing.Callable, *args: typing.Any, **kwargs: typing.Any
|
||||
) -> typing.Any:
|
||||
return await trio.to_thread.run_sync(
|
||||
functools.partial(func, **kwargs) if kwargs else func, *args
|
||||
)
|
||||
|
||||
def run(
|
||||
self, coroutine: typing.Callable, *args: typing.Any, **kwargs: typing.Any
|
||||
) -> typing.Any:
|
||||
return trio.run(
|
||||
functools.partial(coroutine, **kwargs) if kwargs else coroutine, *args
|
||||
)
|
||||
|
||||
def get_semaphore(self, limits: PoolLimits) -> BasePoolSemaphore:
|
||||
return PoolSemaphore(limits)
|
||||
|
||||
def create_queue(self, max_size: int) -> BaseQueue:
|
||||
return Queue(max_size=max_size)
|
||||
|
||||
def create_event(self) -> BaseEvent:
|
||||
return Event()
|
||||
|
||||
def background_manager(
|
||||
self, coroutine: typing.Callable, *args: typing.Any
|
||||
) -> "BackgroundManager":
|
||||
return BackgroundManager(coroutine, *args)
|
||||
|
||||
|
||||
class Queue(BaseQueue):
|
||||
def __init__(self, max_size: int) -> None:
|
||||
self.send_channel, self.receive_channel = trio.open_memory_channel(math.inf)
|
||||
|
||||
async def get(self) -> typing.Any:
|
||||
return await self.receive_channel.receive()
|
||||
|
||||
async def put(self, value: typing.Any) -> None:
|
||||
await self.send_channel.send(value)
|
||||
|
||||
|
||||
class Event(BaseEvent):
|
||||
def __init__(self) -> None:
|
||||
self._event = trio.Event()
|
||||
|
||||
def set(self) -> None:
|
||||
self._event.set()
|
||||
|
||||
def is_set(self) -> bool:
|
||||
return self._event.is_set()
|
||||
|
||||
async def wait(self) -> None:
|
||||
await self._event.wait()
|
||||
|
||||
def clear(self) -> None:
|
||||
# trio.Event.clear() was deprecated in Trio 0.12.
|
||||
# https://github.com/python-trio/trio/issues/637
|
||||
self._event = trio.Event()
|
||||
|
||||
|
||||
class BackgroundManager(BaseBackgroundManager):
|
||||
def __init__(self, coroutine: typing.Callable, *args: typing.Any) -> None:
|
||||
self.coroutine = coroutine
|
||||
self.args = args
|
||||
self.nursery_manager = trio.open_nursery()
|
||||
self.nursery: typing.Optional[trio.Nursery] = None
|
||||
|
||||
async def __aenter__(self) -> "BackgroundManager":
|
||||
self.nursery = await self.nursery_manager.__aenter__()
|
||||
self.nursery.start_soon(self.coroutine, *self.args)
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: typing.Type[BaseException] = None,
|
||||
exc_value: BaseException = None,
|
||||
traceback: TracebackType = None,
|
||||
) -> None:
|
||||
assert self.nursery is not None
|
||||
await self.nursery_manager.__aexit__(exc_type, exc_value, traceback)
|
||||
353
httpx/config.py
@ -1,353 +0,0 @@
|
||||
import os
|
||||
import ssl
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
import certifi
|
||||
|
||||
from .__version__ import __version__
|
||||
from .utils import get_ca_bundle_from_env, get_logger
|
||||
|
||||
CertTypes = typing.Union[str, typing.Tuple[str, str], typing.Tuple[str, str, str]]
|
||||
VerifyTypes = typing.Union[str, bool, ssl.SSLContext]
|
||||
TimeoutTypes = typing.Union[float, typing.Tuple[float, float, float], "TimeoutConfig"]
|
||||
HTTPVersionTypes = typing.Union[
|
||||
str, typing.List[str], typing.Tuple[str], "HTTPVersionConfig"
|
||||
]
|
||||
|
||||
|
||||
USER_AGENT = f"python-httpx/{__version__}"
|
||||
|
||||
HTTP_VERSIONS_TO_ALPN_IDENTIFIERS = {"HTTP/1.1": "http/1.1", "HTTP/2": "h2"}
|
||||
|
||||
DEFAULT_CIPHERS = ":".join(
|
||||
[
|
||||
"ECDHE+AESGCM",
|
||||
"ECDHE+CHACHA20",
|
||||
"DHE+AESGCM",
|
||||
"DHE+CHACHA20",
|
||||
"ECDH+AESGCM",
|
||||
"DH+AESGCM",
|
||||
"ECDH+AES",
|
||||
"DH+AES",
|
||||
"RSA+AESGCM",
|
||||
"RSA+AES",
|
||||
"!aNULL",
|
||||
"!eNULL",
|
||||
"!MD5",
|
||||
"!DSS",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SSLConfig:
|
||||
"""
|
||||
SSL Configuration.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
cert: CertTypes = None,
|
||||
verify: VerifyTypes = True,
|
||||
trust_env: bool = None,
|
||||
):
|
||||
self.cert = cert
|
||||
|
||||
# Allow passing in our own SSLContext object that's pre-configured.
|
||||
# If you do this we assume that you want verify=True as well.
|
||||
ssl_context = None
|
||||
if isinstance(verify, ssl.SSLContext):
|
||||
ssl_context = verify
|
||||
verify = True
|
||||
self._load_client_certs(ssl_context)
|
||||
|
||||
self.ssl_context: typing.Optional[ssl.SSLContext] = ssl_context
|
||||
self.verify: typing.Union[str, bool] = verify
|
||||
self.trust_env = trust_env
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
return (
|
||||
isinstance(other, self.__class__)
|
||||
and self.cert == other.cert
|
||||
and self.verify == other.verify
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
class_name = self.__class__.__name__
|
||||
return f"{class_name}(cert={self.cert}, verify={self.verify})"
|
||||
|
||||
def with_overrides(
|
||||
self, cert: CertTypes = None, verify: VerifyTypes = None
|
||||
) -> "SSLConfig":
|
||||
cert = self.cert if cert is None else cert
|
||||
verify = self.verify if verify is None else verify
|
||||
if (cert == self.cert) and (verify == self.verify):
|
||||
return self
|
||||
return SSLConfig(cert=cert, verify=verify)
|
||||
|
||||
def load_ssl_context(
|
||||
self, http_versions: "HTTPVersionConfig" = None
|
||||
) -> ssl.SSLContext:
|
||||
http_versions = HTTPVersionConfig() if http_versions is None else http_versions
|
||||
|
||||
logger.debug(
|
||||
f"load_ssl_context "
|
||||
f"verify={self.verify!r} "
|
||||
f"cert={self.cert!r} "
|
||||
f"trust_env={self.trust_env!r} "
|
||||
f"http_versions={http_versions!r}"
|
||||
)
|
||||
|
||||
if self.ssl_context is None:
|
||||
self.ssl_context = (
|
||||
self.load_ssl_context_verify(http_versions=http_versions)
|
||||
if self.verify
|
||||
else self.load_ssl_context_no_verify(http_versions=http_versions)
|
||||
)
|
||||
|
||||
assert self.ssl_context is not None
|
||||
return self.ssl_context
|
||||
|
||||
def load_ssl_context_no_verify(
|
||||
self, http_versions: "HTTPVersionConfig"
|
||||
) -> ssl.SSLContext:
|
||||
"""
|
||||
Return an SSL context for unverified connections.
|
||||
"""
|
||||
context = self._create_default_ssl_context(http_versions=http_versions)
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
context.check_hostname = False
|
||||
return context
|
||||
|
||||
def load_ssl_context_verify(
|
||||
self, http_versions: "HTTPVersionConfig"
|
||||
) -> ssl.SSLContext:
|
||||
"""
|
||||
Return an SSL context for verified connections.
|
||||
"""
|
||||
if self.trust_env and self.verify is True:
|
||||
ca_bundle = get_ca_bundle_from_env()
|
||||
if ca_bundle is not None:
|
||||
self.verify = ca_bundle # type: ignore
|
||||
|
||||
if isinstance(self.verify, bool):
|
||||
ca_bundle_path = DEFAULT_CA_BUNDLE_PATH
|
||||
elif Path(self.verify).exists():
|
||||
ca_bundle_path = Path(self.verify)
|
||||
else:
|
||||
raise IOError(
|
||||
"Could not find a suitable TLS CA certificate bundle, "
|
||||
"invalid path: {}".format(self.verify)
|
||||
)
|
||||
|
||||
context = self._create_default_ssl_context(http_versions=http_versions)
|
||||
context.verify_mode = ssl.CERT_REQUIRED
|
||||
context.check_hostname = True
|
||||
|
||||
# Signal to server support for PHA in TLS 1.3. Raises an
|
||||
# AttributeError if only read-only access is implemented.
|
||||
try:
|
||||
context.post_handshake_auth = True # type: ignore
|
||||
except AttributeError: # pragma: nocover
|
||||
pass
|
||||
|
||||
# Disable using 'commonName' for SSLContext.check_hostname
|
||||
# when the 'subjectAltName' extension isn't available.
|
||||
try:
|
||||
context.hostname_checks_common_name = False # type: ignore
|
||||
except AttributeError: # pragma: nocover
|
||||
pass
|
||||
|
||||
if ca_bundle_path.is_file():
|
||||
logger.debug(f"load_verify_locations cafile={ca_bundle_path!s}")
|
||||
context.load_verify_locations(cafile=str(ca_bundle_path))
|
||||
elif ca_bundle_path.is_dir():
|
||||
logger.debug(f"load_verify_locations capath={ca_bundle_path!s}")
|
||||
context.load_verify_locations(capath=str(ca_bundle_path))
|
||||
|
||||
self._load_client_certs(context)
|
||||
|
||||
return context
|
||||
|
||||
def _create_default_ssl_context(
|
||||
self, http_versions: "HTTPVersionConfig"
|
||||
) -> ssl.SSLContext:
|
||||
"""
|
||||
Creates the default SSLContext object that's used for both verified
|
||||
and unverified connections.
|
||||
"""
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS)
|
||||
context.options |= ssl.OP_NO_SSLv2
|
||||
context.options |= ssl.OP_NO_SSLv3
|
||||
context.options |= ssl.OP_NO_TLSv1
|
||||
context.options |= ssl.OP_NO_TLSv1_1
|
||||
context.options |= ssl.OP_NO_COMPRESSION
|
||||
context.set_ciphers(DEFAULT_CIPHERS)
|
||||
|
||||
if ssl.HAS_ALPN:
|
||||
context.set_alpn_protocols(http_versions.alpn_identifiers)
|
||||
|
||||
if hasattr(context, "keylog_filename"):
|
||||
keylogfile = os.environ.get("SSLKEYLOGFILE")
|
||||
if keylogfile and self.trust_env:
|
||||
context.keylog_filename = keylogfile # type: ignore
|
||||
|
||||
return context
|
||||
|
||||
def _load_client_certs(self, ssl_context: ssl.SSLContext) -> None:
|
||||
"""
|
||||
Loads client certificates into our SSLContext object
|
||||
"""
|
||||
if self.cert is not None:
|
||||
if isinstance(self.cert, str):
|
||||
ssl_context.load_cert_chain(certfile=self.cert)
|
||||
elif isinstance(self.cert, tuple) and len(self.cert) == 2:
|
||||
ssl_context.load_cert_chain(certfile=self.cert[0], keyfile=self.cert[1])
|
||||
elif isinstance(self.cert, tuple) and len(self.cert) == 3:
|
||||
ssl_context.load_cert_chain(
|
||||
certfile=self.cert[0],
|
||||
keyfile=self.cert[1],
|
||||
password=self.cert[2], # type: ignore
|
||||
)
|
||||
|
||||
|
||||
class TimeoutConfig:
|
||||
"""
|
||||
Timeout values.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
timeout: TimeoutTypes = None,
|
||||
*,
|
||||
connect_timeout: float = None,
|
||||
read_timeout: float = None,
|
||||
write_timeout: float = None,
|
||||
):
|
||||
if timeout is None:
|
||||
self.connect_timeout = connect_timeout
|
||||
self.read_timeout = read_timeout
|
||||
self.write_timeout = write_timeout
|
||||
else:
|
||||
# Specified as a single timeout value
|
||||
assert connect_timeout is None
|
||||
assert read_timeout is None
|
||||
assert write_timeout is None
|
||||
if isinstance(timeout, TimeoutConfig):
|
||||
self.connect_timeout = timeout.connect_timeout
|
||||
self.read_timeout = timeout.read_timeout
|
||||
self.write_timeout = timeout.write_timeout
|
||||
elif isinstance(timeout, tuple):
|
||||
self.connect_timeout = timeout[0]
|
||||
self.read_timeout = timeout[1]
|
||||
self.write_timeout = timeout[2]
|
||||
else:
|
||||
self.connect_timeout = timeout
|
||||
self.read_timeout = timeout
|
||||
self.write_timeout = timeout
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
return (
|
||||
isinstance(other, self.__class__)
|
||||
and self.connect_timeout == other.connect_timeout
|
||||
and self.read_timeout == other.read_timeout
|
||||
and self.write_timeout == other.write_timeout
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
class_name = self.__class__.__name__
|
||||
if len({self.connect_timeout, self.read_timeout, self.write_timeout}) == 1:
|
||||
return f"{class_name}(timeout={self.connect_timeout})"
|
||||
return (
|
||||
f"{class_name}(connect_timeout={self.connect_timeout}, "
|
||||
f"read_timeout={self.read_timeout}, write_timeout={self.write_timeout})"
|
||||
)
|
||||
|
||||
|
||||
class HTTPVersionConfig:
|
||||
"""
|
||||
Configure which HTTP protocol versions are supported.
|
||||
"""
|
||||
|
||||
def __init__(self, http_versions: HTTPVersionTypes = None):
|
||||
if http_versions is None:
|
||||
http_versions = ["HTTP/1.1", "HTTP/2"]
|
||||
|
||||
if isinstance(http_versions, str):
|
||||
self.http_versions = {http_versions.upper()}
|
||||
elif isinstance(http_versions, HTTPVersionConfig):
|
||||
self.http_versions = http_versions.http_versions
|
||||
elif isinstance(http_versions, typing.Iterable):
|
||||
self.http_versions = {
|
||||
version.upper() if isinstance(version, str) else version
|
||||
for version in http_versions
|
||||
}
|
||||
else:
|
||||
raise TypeError(
|
||||
"HTTP version should be a string or list of strings, "
|
||||
f"but got {type(http_versions)}"
|
||||
)
|
||||
|
||||
for version in self.http_versions:
|
||||
if version not in ("HTTP/1.1", "HTTP/2"):
|
||||
raise ValueError(f"Unsupported HTTP version {version!r}.")
|
||||
|
||||
if not self.http_versions:
|
||||
raise ValueError("HTTP versions cannot be an empty list.")
|
||||
|
||||
@property
|
||||
def alpn_identifiers(self) -> typing.List[str]:
|
||||
"""
|
||||
Returns a list of supported ALPN identifiers. (One or more of "http/1.1", "h2").
|
||||
"""
|
||||
return [
|
||||
HTTP_VERSIONS_TO_ALPN_IDENTIFIERS[version] for version in self.http_versions
|
||||
]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
class_name = self.__class__.__name__
|
||||
value = sorted(list(self.http_versions))
|
||||
return f"{class_name}({value!r})"
|
||||
|
||||
|
||||
class PoolLimits:
|
||||
"""
|
||||
Limits on the number of connections in a connection pool.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
soft_limit: int = None,
|
||||
hard_limit: int = None,
|
||||
pool_timeout: float = None,
|
||||
):
|
||||
self.soft_limit = soft_limit
|
||||
self.hard_limit = hard_limit
|
||||
self.pool_timeout = pool_timeout
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
return (
|
||||
isinstance(other, self.__class__)
|
||||
and self.soft_limit == other.soft_limit
|
||||
and self.hard_limit == other.hard_limit
|
||||
and self.pool_timeout == other.pool_timeout
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
class_name = self.__class__.__name__
|
||||
return (
|
||||
f"{class_name}(soft_limit={self.soft_limit}, "
|
||||
f"hard_limit={self.hard_limit}, pool_timeout={self.pool_timeout})"
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_SSL_CONFIG = SSLConfig(cert=None, verify=True)
|
||||
DEFAULT_TIMEOUT_CONFIG = TimeoutConfig(timeout=5.0)
|
||||
DEFAULT_POOL_LIMITS = PoolLimits(soft_limit=10, hard_limit=100, pool_timeout=5.0)
|
||||
DEFAULT_CA_BUNDLE_PATH = Path(certifi.where())
|
||||
DEFAULT_MAX_REDIRECTS = 20
|
||||
@ -1,228 +0,0 @@
|
||||
"""
|
||||
Handlers for Content-Encoding.
|
||||
|
||||
See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding
|
||||
"""
|
||||
import codecs
|
||||
import typing
|
||||
import zlib
|
||||
|
||||
import chardet
|
||||
|
||||
from .exceptions import DecodingError
|
||||
|
||||
try:
|
||||
import brotli
|
||||
except ImportError: # pragma: nocover
|
||||
brotli = None
|
||||
|
||||
|
||||
class Decoder:
|
||||
def decode(self, data: bytes) -> bytes:
|
||||
raise NotImplementedError() # pragma: nocover
|
||||
|
||||
def flush(self) -> bytes:
|
||||
raise NotImplementedError() # pragma: nocover
|
||||
|
||||
|
||||
class IdentityDecoder(Decoder):
|
||||
"""
|
||||
Handle unencoded data.
|
||||
"""
|
||||
|
||||
def decode(self, data: bytes) -> bytes:
|
||||
return data
|
||||
|
||||
def flush(self) -> bytes:
|
||||
return b""
|
||||
|
||||
|
||||
class DeflateDecoder(Decoder):
|
||||
"""
|
||||
Handle 'deflate' decoding.
|
||||
|
||||
See: https://stackoverflow.com/questions/1838699
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.decompressor = zlib.decompressobj(-zlib.MAX_WBITS)
|
||||
|
||||
def decode(self, data: bytes) -> bytes:
|
||||
try:
|
||||
return self.decompressor.decompress(data)
|
||||
except zlib.error as exc:
|
||||
raise DecodingError from exc
|
||||
|
||||
def flush(self) -> bytes:
|
||||
try:
|
||||
return self.decompressor.flush()
|
||||
except zlib.error as exc: # pragma: nocover
|
||||
raise DecodingError from exc
|
||||
|
||||
|
||||
class GZipDecoder(Decoder):
|
||||
"""
|
||||
Handle 'gzip' decoding.
|
||||
|
||||
See: https://stackoverflow.com/questions/1838699
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.decompressor = zlib.decompressobj(zlib.MAX_WBITS | 16)
|
||||
|
||||
def decode(self, data: bytes) -> bytes:
|
||||
try:
|
||||
return self.decompressor.decompress(data)
|
||||
except zlib.error as exc:
|
||||
raise DecodingError from exc
|
||||
|
||||
def flush(self) -> bytes:
|
||||
try:
|
||||
return self.decompressor.flush()
|
||||
except zlib.error as exc: # pragma: nocover
|
||||
raise DecodingError from exc
|
||||
|
||||
|
||||
class BrotliDecoder(Decoder):
|
||||
"""
|
||||
Handle 'brotli' decoding.
|
||||
|
||||
Requires `pip install brotlipy`. See: https://brotlipy.readthedocs.io/
|
||||
or `pip install brotli`. See https://github.com/google/brotli
|
||||
Supports both 'brotlipy' and 'Brotli' packages since they share an import
|
||||
name. The top branches are for 'brotlipy' and bottom branches for 'Brotli'
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
assert (
|
||||
brotli is not None
|
||||
), "The 'brotlipy' or 'brotli' library must be installed to use 'BrotliDecoder'"
|
||||
self.decompressor = brotli.Decompressor()
|
||||
self.seen_data = False
|
||||
|
||||
def decode(self, data: bytes) -> bytes:
|
||||
if not data:
|
||||
return b""
|
||||
self.seen_data = True
|
||||
try:
|
||||
if hasattr(self.decompressor, "decompress"):
|
||||
return self.decompressor.decompress(data)
|
||||
return self.decompressor.process(data) # pragma: nocover
|
||||
except brotli.error as exc:
|
||||
raise DecodingError from exc
|
||||
|
||||
def flush(self) -> bytes:
|
||||
if not self.seen_data:
|
||||
return b""
|
||||
try:
|
||||
if hasattr(self.decompressor, "finish"):
|
||||
self.decompressor.finish()
|
||||
return b""
|
||||
except brotli.error as exc: # pragma: nocover
|
||||
raise DecodingError from exc
|
||||
|
||||
|
||||
class MultiDecoder(Decoder):
|
||||
"""
|
||||
Handle the case where multiple encodings have been applied.
|
||||
"""
|
||||
|
||||
def __init__(self, children: typing.Sequence[Decoder]) -> None:
|
||||
"""
|
||||
'children' should be a sequence of decoders in the order in which
|
||||
each was applied.
|
||||
"""
|
||||
# Note that we reverse the order for decoding.
|
||||
self.children = list(reversed(children))
|
||||
|
||||
def decode(self, data: bytes) -> bytes:
|
||||
for child in self.children:
|
||||
data = child.decode(data)
|
||||
return data
|
||||
|
||||
def flush(self) -> bytes:
|
||||
data = b""
|
||||
for child in self.children:
|
||||
data = child.decode(data) + child.flush()
|
||||
return data
|
||||
|
||||
|
||||
class TextDecoder:
|
||||
"""
|
||||
Handles incrementally decoding bytes into text
|
||||
"""
|
||||
|
||||
def __init__(self, encoding: typing.Optional[str] = None):
|
||||
self.decoder: typing.Optional[codecs.IncrementalDecoder] = (
|
||||
None if encoding is None else codecs.getincrementaldecoder(encoding)()
|
||||
)
|
||||
self.detector = chardet.universaldetector.UniversalDetector()
|
||||
|
||||
# This buffer is only needed if 'decoder' is 'None'
|
||||
# we want to trigger errors if data is getting added to
|
||||
# our internal buffer for some silly reason while
|
||||
# a decoder is discovered.
|
||||
self.buffer: typing.Optional[bytearray] = None if self.decoder else bytearray()
|
||||
|
||||
def decode(self, data: bytes) -> str:
|
||||
try:
|
||||
if self.decoder is not None:
|
||||
text = self.decoder.decode(data)
|
||||
else:
|
||||
assert self.buffer is not None
|
||||
text = ""
|
||||
self.detector.feed(data)
|
||||
self.buffer += data
|
||||
|
||||
# Should be more than enough data to process, we don't
|
||||
# want to buffer too long as chardet will wait until
|
||||
# detector.close() is used to give back common
|
||||
# encodings like 'utf-8'.
|
||||
if len(self.buffer) >= 4096:
|
||||
self.decoder = codecs.getincrementaldecoder(
|
||||
self._detector_result()
|
||||
)()
|
||||
text = self.decoder.decode(bytes(self.buffer), False)
|
||||
self.buffer = None
|
||||
|
||||
return text
|
||||
except UnicodeDecodeError: # pragma: nocover
|
||||
raise DecodingError() from None
|
||||
|
||||
def flush(self) -> str:
|
||||
try:
|
||||
if self.decoder is None:
|
||||
# Empty string case as chardet is guaranteed to not have a guess.
|
||||
assert self.buffer is not None
|
||||
if len(self.buffer) == 0:
|
||||
return ""
|
||||
return bytes(self.buffer).decode(self._detector_result())
|
||||
|
||||
return self.decoder.decode(b"", True)
|
||||
except UnicodeDecodeError: # pragma: nocover
|
||||
raise DecodingError() from None
|
||||
|
||||
def _detector_result(self) -> str:
|
||||
self.detector.close()
|
||||
result = self.detector.result["encoding"]
|
||||
if not result: # pragma: nocover
|
||||
raise DecodingError("Unable to determine encoding of content")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
SUPPORTED_DECODERS = {
|
||||
"identity": IdentityDecoder,
|
||||
"gzip": GZipDecoder,
|
||||
"deflate": DeflateDecoder,
|
||||
"br": BrotliDecoder,
|
||||
}
|
||||
|
||||
|
||||
if brotli is None:
|
||||
SUPPORTED_DECODERS.pop("br") # pragma: nocover
|
||||
|
||||
|
||||
ACCEPT_ENCODING = ", ".join(
|
||||
[key for key in SUPPORTED_DECODERS.keys() if key != "identity"]
|
||||
)
|
||||
@ -1,4 +0,0 @@
|
||||
"""
|
||||
Dispatch classes handle the raw network connections and the implementation
|
||||
details of making the HTTP request and receiving the response.
|
||||
"""
|
||||
@ -1,198 +0,0 @@
|
||||
import typing
|
||||
|
||||
from ..concurrency.asyncio import AsyncioBackend
|
||||
from ..concurrency.base import ConcurrencyBackend
|
||||
from ..config import CertTypes, TimeoutTypes, VerifyTypes
|
||||
from ..models import AsyncRequest, AsyncResponse
|
||||
from ..utils import MessageLoggerASGIMiddleware, get_logger
|
||||
from .base import AsyncDispatcher
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ASGIDispatch(AsyncDispatcher):
|
||||
"""
|
||||
A custom dispatcher that handles sending requests directly to an ASGI app.
|
||||
|
||||
The simplest way to use this functionality is to use the `app` argument.
|
||||
This will automatically infer if 'app' is a WSGI or an ASGI application,
|
||||
and will setup an appropriate dispatch class:
|
||||
|
||||
```
|
||||
client = httpx.Client(app=app)
|
||||
```
|
||||
|
||||
Alternatively, you can setup the dispatch instance explicitly.
|
||||
This allows you to include any additional configuration arguments specific
|
||||
to the ASGIDispatch class:
|
||||
|
||||
```
|
||||
dispatch = httpx.ASGIDispatch(
|
||||
app=app,
|
||||
root_path="/submount",
|
||||
client=("1.2.3.4", 123)
|
||||
)
|
||||
client = httpx.Client(dispatch=dispatch)
|
||||
```
|
||||
|
||||
Arguments:
|
||||
|
||||
* `app` - The ASGI application.
|
||||
* `raise_app_exceptions` - Boolean indicating if exceptions in the application
|
||||
should be raised. Default to `True`. Can be set to `False` for use cases
|
||||
such as testing the content of a client 500 response.
|
||||
* `root_path` - The root path on which the ASGI application should be mounted.
|
||||
* `client` - A two-tuple indicating the client IP and port of incoming requests.
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: typing.Callable,
|
||||
raise_app_exceptions: bool = True,
|
||||
root_path: str = "",
|
||||
client: typing.Tuple[str, int] = ("127.0.0.1", 123),
|
||||
backend: ConcurrencyBackend = None,
|
||||
) -> None:
|
||||
self.app = app
|
||||
self.raise_app_exceptions = raise_app_exceptions
|
||||
self.root_path = root_path
|
||||
self.client = client
|
||||
self.backend = AsyncioBackend() if backend is None else backend
|
||||
|
||||
async def send(
|
||||
self,
|
||||
request: AsyncRequest,
|
||||
verify: VerifyTypes = None,
|
||||
cert: CertTypes = None,
|
||||
timeout: TimeoutTypes = None,
|
||||
) -> AsyncResponse:
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": request.method,
|
||||
"headers": request.headers.raw,
|
||||
"scheme": request.url.scheme,
|
||||
"path": request.url.path,
|
||||
"query_string": request.url.query.encode("ascii"),
|
||||
"server": request.url.host,
|
||||
"client": self.client,
|
||||
"root_path": self.root_path,
|
||||
}
|
||||
app = MessageLoggerASGIMiddleware(self.app, logger=logger)
|
||||
app_exc = None
|
||||
status_code = None
|
||||
headers = None
|
||||
response_started_or_failed = self.backend.create_event()
|
||||
response_body = BodyIterator(self.backend)
|
||||
request_stream = request.stream()
|
||||
|
||||
async def receive() -> dict:
|
||||
try:
|
||||
body = await request_stream.__anext__()
|
||||
except StopAsyncIteration:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
return {"type": "http.request", "body": body, "more_body": True}
|
||||
|
||||
async def send(message: dict) -> None:
|
||||
nonlocal status_code, headers
|
||||
|
||||
if message["type"] == "http.response.start":
|
||||
status_code = message["status"]
|
||||
headers = message.get("headers", [])
|
||||
response_started_or_failed.set()
|
||||
|
||||
elif message["type"] == "http.response.body":
|
||||
body = message.get("body", b"")
|
||||
more_body = message.get("more_body", False)
|
||||
|
||||
if body and request.method != "HEAD":
|
||||
await response_body.put(body)
|
||||
|
||||
if not more_body:
|
||||
await response_body.mark_as_done()
|
||||
|
||||
async def run_app() -> None:
|
||||
nonlocal app_exc
|
||||
try:
|
||||
await app(scope, receive, send)
|
||||
except Exception as exc:
|
||||
app_exc = exc
|
||||
finally:
|
||||
await response_body.mark_as_done()
|
||||
response_started_or_failed.set()
|
||||
|
||||
# Using the background manager here *works*, but it is weak design because
|
||||
# the background task isn't strictly context-managed.
|
||||
# We could consider refactoring the other uses of this abstraction
|
||||
# (mainly sending/receiving request/response data in h11 and h2 dispatchers),
|
||||
# and see if that allows us to come back here and refactor things out.
|
||||
background = await self.backend.background_manager(run_app).__aenter__()
|
||||
|
||||
await response_started_or_failed.wait()
|
||||
|
||||
if app_exc is not None and self.raise_app_exceptions:
|
||||
await background.close(app_exc)
|
||||
raise app_exc
|
||||
|
||||
assert status_code is not None, "application did not return a response."
|
||||
assert headers is not None
|
||||
|
||||
async def on_close() -> None:
|
||||
await response_body.drain()
|
||||
await background.close(app_exc)
|
||||
if app_exc is not None and self.raise_app_exceptions:
|
||||
raise app_exc
|
||||
|
||||
return AsyncResponse(
|
||||
status_code=status_code,
|
||||
http_version="HTTP/1.1",
|
||||
headers=headers,
|
||||
content=response_body.iterate(),
|
||||
on_close=on_close,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
class BodyIterator:
|
||||
"""
|
||||
Provides a byte-iterator interface that the client can use to
|
||||
ingest the response content from.
|
||||
"""
|
||||
|
||||
def __init__(self, backend: ConcurrencyBackend) -> None:
|
||||
self._queue = backend.create_queue(max_size=1)
|
||||
self._done = object()
|
||||
|
||||
async def iterate(self) -> typing.AsyncIterator[bytes]:
|
||||
"""
|
||||
A byte-iterator, used by the client to consume the response body.
|
||||
"""
|
||||
while True:
|
||||
data = await self._queue.get()
|
||||
if data is self._done:
|
||||
break
|
||||
assert isinstance(data, bytes)
|
||||
yield data
|
||||
|
||||
async def drain(self) -> None:
|
||||
"""
|
||||
Drain any remaining body, in order to allow any blocked `put()` calls
|
||||
to complete.
|
||||
"""
|
||||
async for chunk in self.iterate():
|
||||
pass # pragma: no cover
|
||||
|
||||
async def put(self, data: bytes) -> None:
|
||||
"""
|
||||
Used by the server to add data to the response body.
|
||||
"""
|
||||
await self._queue.put(data)
|
||||
|
||||
async def mark_as_done(self) -> None:
|
||||
"""
|
||||
Used by the server to signal the end of the response body.
|
||||
"""
|
||||
await self._queue.put(self._done)
|
||||
@ -1,111 +0,0 @@
|
||||
import typing
|
||||
from types import TracebackType
|
||||
|
||||
from ..config import CertTypes, TimeoutTypes, VerifyTypes
|
||||
from ..models import (
|
||||
AsyncRequest,
|
||||
AsyncRequestData,
|
||||
AsyncResponse,
|
||||
HeaderTypes,
|
||||
QueryParamTypes,
|
||||
Request,
|
||||
RequestData,
|
||||
Response,
|
||||
URLTypes,
|
||||
)
|
||||
|
||||
|
||||
class AsyncDispatcher:
|
||||
"""
|
||||
Base class for async dispatcher classes, that handle sending the request.
|
||||
|
||||
Stubs out the interface, as well as providing a `.request()` convenience
|
||||
implementation, to make it easy to use or test stand-alone dispatchers,
|
||||
without requiring a complete `Client` instance.
|
||||
"""
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
url: URLTypes,
|
||||
*,
|
||||
data: AsyncRequestData = b"",
|
||||
params: QueryParamTypes = None,
|
||||
headers: HeaderTypes = None,
|
||||
verify: VerifyTypes = None,
|
||||
cert: CertTypes = None,
|
||||
timeout: TimeoutTypes = None,
|
||||
) -> AsyncResponse:
|
||||
request = AsyncRequest(method, url, data=data, params=params, headers=headers)
|
||||
return await self.send(request, verify=verify, cert=cert, timeout=timeout)
|
||||
|
||||
async def send(
|
||||
self,
|
||||
request: AsyncRequest,
|
||||
verify: VerifyTypes = None,
|
||||
cert: CertTypes = None,
|
||||
timeout: TimeoutTypes = None,
|
||||
) -> AsyncResponse:
|
||||
raise NotImplementedError() # pragma: nocover
|
||||
|
||||
async def close(self) -> None:
|
||||
pass # pragma: nocover
|
||||
|
||||
async def __aenter__(self) -> "AsyncDispatcher":
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: typing.Type[BaseException] = None,
|
||||
exc_value: BaseException = None,
|
||||
traceback: TracebackType = None,
|
||||
) -> None:
|
||||
await self.close()
|
||||
|
||||
|
||||
class Dispatcher:
|
||||
"""
|
||||
Base class for synchronous dispatcher classes, that handle sending the request.
|
||||
|
||||
Stubs out the interface, as well as providing a `.request()` convenience
|
||||
implementation, to make it easy to use or test stand-alone dispatchers,
|
||||
without requiring a complete `Client` instance.
|
||||
"""
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
url: URLTypes,
|
||||
*,
|
||||
data: RequestData = b"",
|
||||
params: QueryParamTypes = None,
|
||||
headers: HeaderTypes = None,
|
||||
verify: VerifyTypes = None,
|
||||
cert: CertTypes = None,
|
||||
timeout: TimeoutTypes = None,
|
||||
) -> Response:
|
||||
request = Request(method, url, data=data, params=params, headers=headers)
|
||||
return self.send(request, verify=verify, cert=cert, timeout=timeout)
|
||||
|
||||
def send(
|
||||
self,
|
||||
request: Request,
|
||||
verify: VerifyTypes = None,
|
||||
cert: CertTypes = None,
|
||||
timeout: TimeoutTypes = None,
|
||||
) -> Response:
|
||||
raise NotImplementedError() # pragma: nocover
|
||||
|
||||
def close(self) -> None:
|
||||
pass # pragma: nocover
|
||||
|
||||
def __enter__(self) -> "Dispatcher":
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: typing.Type[BaseException] = None,
|
||||
exc_value: BaseException = None,
|
||||
traceback: TracebackType = None,
|
||||
) -> None:
|
||||
self.close()
|
||||
@ -1,139 +0,0 @@
|
||||
import functools
|
||||
import ssl
|
||||
import typing
|
||||
|
||||
from ..concurrency.asyncio import AsyncioBackend
|
||||
from ..concurrency.base import ConcurrencyBackend
|
||||
from ..config import (
|
||||
DEFAULT_TIMEOUT_CONFIG,
|
||||
CertTypes,
|
||||
HTTPVersionConfig,
|
||||
HTTPVersionTypes,
|
||||
SSLConfig,
|
||||
TimeoutConfig,
|
||||
TimeoutTypes,
|
||||
VerifyTypes,
|
||||
)
|
||||
from ..models import AsyncRequest, AsyncResponse, Origin
|
||||
from ..utils import get_logger
|
||||
from .base import AsyncDispatcher
|
||||
from .http2 import HTTP2Connection
|
||||
from .http11 import HTTP11Connection
|
||||
|
||||
# Callback signature: async def callback(conn: HTTPConnection) -> None
|
||||
ReleaseCallback = typing.Callable[["HTTPConnection"], typing.Awaitable[None]]
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class HTTPConnection(AsyncDispatcher):
|
||||
def __init__(
|
||||
self,
|
||||
origin: typing.Union[str, Origin],
|
||||
verify: VerifyTypes = True,
|
||||
cert: CertTypes = None,
|
||||
trust_env: bool = None,
|
||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||
http_versions: HTTPVersionTypes = None,
|
||||
backend: ConcurrencyBackend = None,
|
||||
release_func: typing.Optional[ReleaseCallback] = None,
|
||||
):
|
||||
self.origin = Origin(origin) if isinstance(origin, str) else origin
|
||||
self.ssl = SSLConfig(cert=cert, verify=verify, trust_env=trust_env)
|
||||
self.timeout = TimeoutConfig(timeout)
|
||||
self.http_versions = HTTPVersionConfig(http_versions)
|
||||
self.backend = AsyncioBackend() if backend is None else backend
|
||||
self.release_func = release_func
|
||||
self.h11_connection = None # type: typing.Optional[HTTP11Connection]
|
||||
self.h2_connection = None # type: typing.Optional[HTTP2Connection]
|
||||
|
||||
async def send(
|
||||
self,
|
||||
request: AsyncRequest,
|
||||
verify: VerifyTypes = None,
|
||||
cert: CertTypes = None,
|
||||
timeout: TimeoutTypes = None,
|
||||
) -> AsyncResponse:
|
||||
if self.h11_connection is None and self.h2_connection is None:
|
||||
await self.connect(verify=verify, cert=cert, timeout=timeout)
|
||||
|
||||
if self.h2_connection is not None:
|
||||
response = await self.h2_connection.send(request, timeout=timeout)
|
||||
else:
|
||||
assert self.h11_connection is not None
|
||||
response = await self.h11_connection.send(request, timeout=timeout)
|
||||
|
||||
return response
|
||||
|
||||
async def connect(
|
||||
self,
|
||||
verify: VerifyTypes = None,
|
||||
cert: CertTypes = None,
|
||||
timeout: TimeoutTypes = None,
|
||||
) -> None:
|
||||
ssl = self.ssl.with_overrides(verify=verify, cert=cert)
|
||||
timeout = self.timeout if timeout is None else TimeoutConfig(timeout)
|
||||
|
||||
host = self.origin.host
|
||||
port = self.origin.port
|
||||
ssl_context = await self.get_ssl_context(ssl)
|
||||
|
||||
if self.release_func is None:
|
||||
on_release = None
|
||||
else:
|
||||
on_release = functools.partial(self.release_func, self)
|
||||
|
||||
logger.debug(f"start_connect host={host!r} port={port!r} timeout={timeout!r}")
|
||||
stream = await self.backend.open_tcp_stream(host, port, ssl_context, timeout)
|
||||
http_version = stream.get_http_version()
|
||||
logger.debug(f"connected http_version={http_version!r}")
|
||||
|
||||
if http_version == "HTTP/2":
|
||||
self.h2_connection = HTTP2Connection(
|
||||
stream, self.backend, on_release=on_release
|
||||
)
|
||||
else:
|
||||
assert http_version == "HTTP/1.1"
|
||||
self.h11_connection = HTTP11Connection(
|
||||
stream, self.backend, on_release=on_release
|
||||
)
|
||||
|
||||
async def get_ssl_context(self, ssl: SSLConfig) -> typing.Optional[ssl.SSLContext]:
|
||||
if not self.origin.is_ssl:
|
||||
return None
|
||||
|
||||
# Run the SSL loading in a threadpool, since it may make disk accesses.
|
||||
return await self.backend.run_in_threadpool(
|
||||
ssl.load_ssl_context, self.http_versions
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
logger.debug("close_connection")
|
||||
if self.h2_connection is not None:
|
||||
await self.h2_connection.close()
|
||||
elif self.h11_connection is not None:
|
||||
await self.h11_connection.close()
|
||||
|
||||
@property
|
||||
def is_http2(self) -> bool:
|
||||
return self.h2_connection is not None
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
if self.h2_connection is not None:
|
||||
return self.h2_connection.is_closed
|
||||
else:
|
||||
assert self.h11_connection is not None
|
||||
return self.h11_connection.is_closed
|
||||
|
||||
def is_connection_dropped(self) -> bool:
|
||||
if self.h2_connection is not None:
|
||||
return self.h2_connection.is_connection_dropped()
|
||||
else:
|
||||
assert self.h11_connection is not None
|
||||
return self.h11_connection.is_connection_dropped()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
class_name = self.__class__.__name__
|
||||
return f"{class_name}(origin={self.origin!r})"
|
||||
@ -1,186 +0,0 @@
|
||||
import typing
|
||||
|
||||
from ..concurrency.asyncio import AsyncioBackend
|
||||
from ..concurrency.base import ConcurrencyBackend
|
||||
from ..config import (
|
||||
DEFAULT_POOL_LIMITS,
|
||||
DEFAULT_TIMEOUT_CONFIG,
|
||||
CertTypes,
|
||||
HTTPVersionTypes,
|
||||
PoolLimits,
|
||||
TimeoutTypes,
|
||||
VerifyTypes,
|
||||
)
|
||||
from ..models import AsyncRequest, AsyncResponse, Origin
|
||||
from ..utils import get_logger
|
||||
from .base import AsyncDispatcher
|
||||
from .connection import HTTPConnection
|
||||
|
||||
CONNECTIONS_DICT = typing.Dict[Origin, typing.List[HTTPConnection]]
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ConnectionStore:
|
||||
"""
|
||||
We need to maintain collections of connections in a way that allows us to:
|
||||
|
||||
* Lookup connections by origin.
|
||||
* Iterate over connections by insertion time.
|
||||
* Return the total number of connections.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.all: typing.Dict[HTTPConnection, float] = {}
|
||||
self.by_origin: typing.Dict[Origin, typing.Dict[HTTPConnection, float]] = {}
|
||||
|
||||
def pop_by_origin(
|
||||
self, origin: Origin, http2_only: bool = False
|
||||
) -> typing.Optional[HTTPConnection]:
|
||||
try:
|
||||
connections = self.by_origin[origin]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
connection = next(reversed(list(connections.keys())))
|
||||
if http2_only and not connection.is_http2:
|
||||
return None
|
||||
|
||||
del connections[connection]
|
||||
if not connections:
|
||||
del self.by_origin[origin]
|
||||
del self.all[connection]
|
||||
|
||||
return connection
|
||||
|
||||
def add(self, connection: HTTPConnection) -> None:
|
||||
self.all[connection] = 0.0
|
||||
try:
|
||||
self.by_origin[connection.origin][connection] = 0.0
|
||||
except KeyError:
|
||||
self.by_origin[connection.origin] = {connection: 0.0}
|
||||
|
||||
def remove(self, connection: HTTPConnection) -> None:
|
||||
del self.all[connection]
|
||||
del self.by_origin[connection.origin][connection]
|
||||
if not self.by_origin[connection.origin]:
|
||||
del self.by_origin[connection.origin]
|
||||
|
||||
def clear(self) -> None:
|
||||
self.all.clear()
|
||||
self.by_origin.clear()
|
||||
|
||||
def __iter__(self) -> typing.Iterator[HTTPConnection]:
|
||||
return iter(self.all.keys())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.all)
|
||||
|
||||
|
||||
class ConnectionPool(AsyncDispatcher):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
verify: VerifyTypes = True,
|
||||
cert: CertTypes = None,
|
||||
trust_env: bool = None,
|
||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||
pool_limits: PoolLimits = DEFAULT_POOL_LIMITS,
|
||||
http_versions: HTTPVersionTypes = None,
|
||||
backend: ConcurrencyBackend = None,
|
||||
):
|
||||
self.verify = verify
|
||||
self.cert = cert
|
||||
self.timeout = timeout
|
||||
self.pool_limits = pool_limits
|
||||
self.http_versions = http_versions
|
||||
self.is_closed = False
|
||||
self.trust_env = trust_env
|
||||
|
||||
self.keepalive_connections = ConnectionStore()
|
||||
self.active_connections = ConnectionStore()
|
||||
|
||||
self.backend = AsyncioBackend() if backend is None else backend
|
||||
self.max_connections = self.backend.get_semaphore(pool_limits)
|
||||
|
||||
@property
|
||||
def num_connections(self) -> int:
|
||||
return len(self.keepalive_connections) + len(self.active_connections)
|
||||
|
||||
async def send(
|
||||
self,
|
||||
request: AsyncRequest,
|
||||
verify: VerifyTypes = None,
|
||||
cert: CertTypes = None,
|
||||
timeout: TimeoutTypes = None,
|
||||
) -> AsyncResponse:
|
||||
connection = await self.acquire_connection(origin=request.url.origin)
|
||||
try:
|
||||
response = await connection.send(
|
||||
request, verify=verify, cert=cert, timeout=timeout
|
||||
)
|
||||
except BaseException as exc:
|
||||
self.active_connections.remove(connection)
|
||||
self.max_connections.release()
|
||||
raise exc
|
||||
|
||||
return response
|
||||
|
||||
async def acquire_connection(self, origin: Origin) -> HTTPConnection:
|
||||
logger.debug(f"acquire_connection origin={origin!r}")
|
||||
connection = self.pop_connection(origin)
|
||||
|
||||
if connection is None:
|
||||
await self.max_connections.acquire()
|
||||
connection = HTTPConnection(
|
||||
origin,
|
||||
verify=self.verify,
|
||||
cert=self.cert,
|
||||
timeout=self.timeout,
|
||||
http_versions=self.http_versions,
|
||||
backend=self.backend,
|
||||
release_func=self.release_connection,
|
||||
trust_env=self.trust_env,
|
||||
)
|
||||
logger.debug(f"new_connection connection={connection!r}")
|
||||
else:
|
||||
logger.debug(f"reuse_connection connection={connection!r}")
|
||||
|
||||
self.active_connections.add(connection)
|
||||
|
||||
return connection
|
||||
|
||||
async def release_connection(self, connection: HTTPConnection) -> None:
|
||||
logger.debug(f"release_connection connection={connection!r}")
|
||||
if connection.is_closed:
|
||||
self.active_connections.remove(connection)
|
||||
self.max_connections.release()
|
||||
elif (
|
||||
self.pool_limits.soft_limit is not None
|
||||
and self.num_connections > self.pool_limits.soft_limit
|
||||
):
|
||||
self.active_connections.remove(connection)
|
||||
self.max_connections.release()
|
||||
await connection.close()
|
||||
else:
|
||||
self.active_connections.remove(connection)
|
||||
self.keepalive_connections.add(connection)
|
||||
|
||||
async def close(self) -> None:
|
||||
self.is_closed = True
|
||||
connections = list(self.keepalive_connections)
|
||||
self.keepalive_connections.clear()
|
||||
for connection in connections:
|
||||
await connection.close()
|
||||
|
||||
def pop_connection(self, origin: Origin) -> typing.Optional[HTTPConnection]:
|
||||
connection = self.active_connections.pop_by_origin(origin, http2_only=True)
|
||||
if connection is None:
|
||||
connection = self.keepalive_connections.pop_by_origin(origin)
|
||||
|
||||
if connection is not None and connection.is_connection_dropped():
|
||||
self.max_connections.release()
|
||||
connection = None
|
||||
|
||||
return connection
|
||||
@ -1,208 +0,0 @@
|
||||
import typing
|
||||
|
||||
import h11
|
||||
|
||||
from ..concurrency.base import BaseTCPStream, ConcurrencyBackend, TimeoutFlag
|
||||
from ..config import TimeoutConfig, TimeoutTypes
|
||||
from ..models import AsyncRequest, AsyncResponse
|
||||
from ..utils import get_logger
|
||||
|
||||
H11Event = typing.Union[
|
||||
h11.Request,
|
||||
h11.Response,
|
||||
h11.InformationalResponse,
|
||||
h11.Data,
|
||||
h11.EndOfMessage,
|
||||
h11.ConnectionClosed,
|
||||
]
|
||||
|
||||
|
||||
# Callback signature: async def callback() -> None
|
||||
# In practice the callback will be a functools partial, which binds
|
||||
# the `ConnectionPool.release_connection(conn: HTTPConnection)` method.
|
||||
OnReleaseCallback = typing.Callable[[], typing.Awaitable[None]]
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class HTTP11Connection:
|
||||
READ_NUM_BYTES = 4096
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stream: BaseTCPStream,
|
||||
backend: ConcurrencyBackend,
|
||||
on_release: typing.Optional[OnReleaseCallback] = None,
|
||||
):
|
||||
self.stream = stream
|
||||
self.backend = backend
|
||||
self.on_release = on_release
|
||||
self.h11_state = h11.Connection(our_role=h11.CLIENT)
|
||||
self.timeout_flag = TimeoutFlag()
|
||||
|
||||
async def send(
|
||||
self, request: AsyncRequest, timeout: TimeoutTypes = None
|
||||
) -> AsyncResponse:
|
||||
timeout = None if timeout is None else TimeoutConfig(timeout)
|
||||
|
||||
await self._send_request(request, timeout)
|
||||
|
||||
task, args = self._send_request_data, [request.stream(), timeout]
|
||||
async with self.backend.background_manager(task, *args):
|
||||
http_version, status_code, headers = await self._receive_response(timeout)
|
||||
content = self._receive_response_data(timeout)
|
||||
|
||||
return AsyncResponse(
|
||||
status_code=status_code,
|
||||
http_version=http_version,
|
||||
headers=headers,
|
||||
content=content,
|
||||
on_close=self.response_closed,
|
||||
request=request,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
event = h11.ConnectionClosed()
|
||||
try:
|
||||
logger.debug(f"send_event event={event!r}")
|
||||
self.h11_state.send(event)
|
||||
except h11.LocalProtocolError: # pragma: no cover
|
||||
# Premature client disconnect
|
||||
pass
|
||||
await self.stream.close()
|
||||
|
||||
async def _send_request(
|
||||
self, request: AsyncRequest, timeout: TimeoutConfig = None
|
||||
) -> None:
|
||||
"""
|
||||
Send the request method, URL, and headers to the network.
|
||||
"""
|
||||
logger.debug(
|
||||
f"send_headers method={request.method!r} "
|
||||
f"target={request.url.full_path!r} "
|
||||
f"headers={request.headers!r}"
|
||||
)
|
||||
|
||||
method = request.method.encode("ascii")
|
||||
target = request.url.full_path.encode("ascii")
|
||||
headers = request.headers.raw
|
||||
event = h11.Request(method=method, target=target, headers=headers)
|
||||
await self._send_event(event, timeout)
|
||||
|
||||
async def _send_request_data(
|
||||
self, data: typing.AsyncIterator[bytes], timeout: TimeoutConfig = None
|
||||
) -> None:
|
||||
"""
|
||||
Send the request body to the network.
|
||||
"""
|
||||
try:
|
||||
# Send the request body.
|
||||
async for chunk in data:
|
||||
logger.debug(f"send_data data=Data(<{len(chunk)} bytes>)")
|
||||
event = h11.Data(data=chunk)
|
||||
await self._send_event(event, timeout)
|
||||
|
||||
# Finalize sending the request.
|
||||
event = h11.EndOfMessage()
|
||||
await self._send_event(event, timeout)
|
||||
except OSError: # pragma: nocover
|
||||
# Once we've sent the initial part of the request we don't actually
|
||||
# care about connection errors that occur when sending the body.
|
||||
# Ignore these, and defer to any exceptions on reading the response.
|
||||
self.h11_state.send_failed()
|
||||
finally:
|
||||
# Once we've sent the request, we enable read timeouts.
|
||||
self.timeout_flag.set_read_timeouts()
|
||||
|
||||
async def _send_event(self, event: H11Event, timeout: TimeoutConfig = None) -> None:
|
||||
"""
|
||||
Send a single `h11` event to the network, waiting for the data to
|
||||
drain before returning.
|
||||
"""
|
||||
bytes_to_send = self.h11_state.send(event)
|
||||
await self.stream.write(bytes_to_send, timeout)
|
||||
|
||||
async def _receive_response(
|
||||
self, timeout: TimeoutConfig = None
|
||||
) -> typing.Tuple[str, int, typing.List[typing.Tuple[bytes, bytes]]]:
|
||||
"""
|
||||
Read the response status and headers from the network.
|
||||
"""
|
||||
while True:
|
||||
event = await self._receive_event(timeout)
|
||||
# As soon as we start seeing response events, we should enable
|
||||
# read timeouts, if we haven't already.
|
||||
self.timeout_flag.set_read_timeouts()
|
||||
if isinstance(event, h11.InformationalResponse):
|
||||
continue
|
||||
else:
|
||||
assert isinstance(event, h11.Response)
|
||||
break # pragma: no cover
|
||||
http_version = "HTTP/%s" % event.http_version.decode("latin-1", errors="ignore")
|
||||
return http_version, event.status_code, event.headers
|
||||
|
||||
async def _receive_response_data(
|
||||
self, timeout: TimeoutConfig = None
|
||||
) -> typing.AsyncIterator[bytes]:
|
||||
"""
|
||||
Read the response data from the network.
|
||||
"""
|
||||
while True:
|
||||
event = await self._receive_event(timeout)
|
||||
if isinstance(event, h11.Data):
|
||||
yield bytes(event.data)
|
||||
else:
|
||||
assert isinstance(event, h11.EndOfMessage) or event is h11.PAUSED
|
||||
break # pragma: no cover
|
||||
|
||||
async def _receive_event(self, timeout: TimeoutConfig = None) -> H11Event:
|
||||
"""
|
||||
Read a single `h11` event, reading more data from the network if needed.
|
||||
"""
|
||||
while True:
|
||||
event = self.h11_state.next_event()
|
||||
|
||||
if isinstance(event, h11.Data):
|
||||
logger.debug(f"receive_event event=Data(<{len(event.data)} bytes>)")
|
||||
else:
|
||||
logger.debug(f"receive_event event={event!r}")
|
||||
|
||||
if event is h11.NEED_DATA:
|
||||
try:
|
||||
data = await self.stream.read(
|
||||
self.READ_NUM_BYTES, timeout, flag=self.timeout_flag
|
||||
)
|
||||
except OSError: # pragma: nocover
|
||||
data = b""
|
||||
self.h11_state.receive_data(data)
|
||||
else:
|
||||
assert event is not h11.NEED_DATA
|
||||
break # pragma: no cover
|
||||
return event
|
||||
|
||||
async def response_closed(self) -> None:
|
||||
logger.debug(
|
||||
f"response_closed "
|
||||
f"our_state={self.h11_state.our_state!r} "
|
||||
f"their_state={self.h11_state.their_state}"
|
||||
)
|
||||
if (
|
||||
self.h11_state.our_state is h11.DONE
|
||||
and self.h11_state.their_state is h11.DONE
|
||||
):
|
||||
# Get ready for another request/response cycle.
|
||||
self.h11_state.start_next_cycle()
|
||||
self.timeout_flag.set_write_timeouts()
|
||||
else:
|
||||
await self.close()
|
||||
|
||||
if self.on_release is not None:
|
||||
await self.on_release()
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
return self.h11_state.our_state in (h11.CLOSED, h11.ERROR)
|
||||
|
||||
def is_connection_dropped(self) -> bool:
|
||||
return self.stream.is_connection_dropped()
|
||||
@ -1,251 +0,0 @@
|
||||
import functools
|
||||
import typing
|
||||
|
||||
import h2.connection
|
||||
import h2.events
|
||||
from h2.settings import SettingCodes, Settings
|
||||
|
||||
from ..concurrency.base import BaseEvent, BaseTCPStream, ConcurrencyBackend, TimeoutFlag
|
||||
from ..config import TimeoutConfig, TimeoutTypes
|
||||
from ..exceptions import ProtocolError
|
||||
from ..models import AsyncRequest, AsyncResponse
|
||||
from ..utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class HTTP2Connection:
|
||||
READ_NUM_BYTES = 4096
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stream: BaseTCPStream,
|
||||
backend: ConcurrencyBackend,
|
||||
on_release: typing.Callable = None,
|
||||
):
|
||||
self.stream = stream
|
||||
self.backend = backend
|
||||
self.on_release = on_release
|
||||
self.h2_state = h2.connection.H2Connection()
|
||||
self.events = {} # type: typing.Dict[int, typing.List[h2.events.Event]]
|
||||
self.timeout_flags = {} # type: typing.Dict[int, TimeoutFlag]
|
||||
self.initialized = False
|
||||
self.window_update_received = {} # type: typing.Dict[int, BaseEvent]
|
||||
|
||||
async def send(
|
||||
self, request: AsyncRequest, timeout: TimeoutTypes = None
|
||||
) -> AsyncResponse:
|
||||
timeout = None if timeout is None else TimeoutConfig(timeout)
|
||||
|
||||
# Start sending the request.
|
||||
if not self.initialized:
|
||||
self.initiate_connection()
|
||||
|
||||
stream_id = await self.send_headers(request, timeout)
|
||||
|
||||
self.events[stream_id] = []
|
||||
self.timeout_flags[stream_id] = TimeoutFlag()
|
||||
self.window_update_received[stream_id] = self.backend.create_event()
|
||||
|
||||
task, args = self.send_request_data, [stream_id, request.stream(), timeout]
|
||||
async with self.backend.background_manager(task, *args):
|
||||
status_code, headers = await self.receive_response(stream_id, timeout)
|
||||
content = self.body_iter(stream_id, timeout)
|
||||
on_close = functools.partial(self.response_closed, stream_id=stream_id)
|
||||
|
||||
return AsyncResponse(
|
||||
status_code=status_code,
|
||||
http_version="HTTP/2",
|
||||
headers=headers,
|
||||
content=content,
|
||||
on_close=on_close,
|
||||
request=request,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.stream.close()
|
||||
|
||||
def initiate_connection(self) -> None:
|
||||
# Need to set these manually here instead of manipulating via
|
||||
# __setitem__() otherwise the H2Connection will emit SettingsUpdate
|
||||
# frames in addition to sending the undesired defaults.
|
||||
self.h2_state.local_settings = Settings(
|
||||
client=True,
|
||||
initial_values={
|
||||
# Disable PUSH_PROMISE frames from the server since we don't do anything
|
||||
# with them for now. Maybe when we support caching?
|
||||
SettingCodes.ENABLE_PUSH: 0,
|
||||
# These two are taken from h2 for safe defaults
|
||||
SettingCodes.MAX_CONCURRENT_STREAMS: 100,
|
||||
SettingCodes.MAX_HEADER_LIST_SIZE: 65536,
|
||||
},
|
||||
)
|
||||
|
||||
# Some websites (*cough* Yahoo *cough*) balk at this setting being
|
||||
# present in the initial handshake since it's not defined in the original
|
||||
# RFC despite the RFC mandating ignoring settings you don't know about.
|
||||
del self.h2_state.local_settings[
|
||||
h2.settings.SettingCodes.ENABLE_CONNECT_PROTOCOL
|
||||
]
|
||||
|
||||
self.h2_state.initiate_connection()
|
||||
data_to_send = self.h2_state.data_to_send()
|
||||
self.stream.write_no_block(data_to_send)
|
||||
self.initialized = True
|
||||
|
||||
async def send_headers(
|
||||
self, request: AsyncRequest, timeout: TimeoutConfig = None
|
||||
) -> int:
|
||||
stream_id = self.h2_state.get_next_available_stream_id()
|
||||
headers = [
|
||||
(b":method", request.method.encode("ascii")),
|
||||
(b":authority", request.url.authority.encode("ascii")),
|
||||
(b":scheme", request.url.scheme.encode("ascii")),
|
||||
(b":path", request.url.full_path.encode("ascii")),
|
||||
] + [(k, v) for k, v in request.headers.raw if k != b"host"]
|
||||
|
||||
logger.debug(
|
||||
f"send_headers "
|
||||
f"stream_id={stream_id} "
|
||||
f"method={request.method!r} "
|
||||
f"target={request.url.full_path!r} "
|
||||
f"headers={headers!r}"
|
||||
)
|
||||
self.h2_state.send_headers(stream_id, headers)
|
||||
data_to_send = self.h2_state.data_to_send()
|
||||
await self.stream.write(data_to_send, timeout)
|
||||
return stream_id
|
||||
|
||||
async def send_request_data(
|
||||
self,
|
||||
stream_id: int,
|
||||
stream: typing.AsyncIterator[bytes],
|
||||
timeout: TimeoutConfig = None,
|
||||
) -> None:
|
||||
try:
|
||||
async for data in stream:
|
||||
await self.send_data(stream_id, data, timeout)
|
||||
await self.end_stream(stream_id, timeout)
|
||||
finally:
|
||||
# Once we've sent the request we should enable read timeouts.
|
||||
self.timeout_flags[stream_id].set_read_timeouts()
|
||||
|
||||
async def send_data(
|
||||
self, stream_id: int, data: bytes, timeout: TimeoutConfig = None
|
||||
) -> None:
|
||||
while data:
|
||||
# The data will be divided into frames to send based on the flow control
|
||||
# window and the maximum frame size. Because the flow control window
|
||||
# can decrease in size, even possibly to zero, this will loop until all the
|
||||
# data is sent. In http2 specification:
|
||||
# https://tools.ietf.org/html/rfc7540#section-6.9
|
||||
flow_control = self.h2_state.local_flow_control_window(stream_id)
|
||||
chunk_size = min(
|
||||
len(data), flow_control, self.h2_state.max_outbound_frame_size
|
||||
)
|
||||
if chunk_size == 0:
|
||||
# this means that the flow control window is 0 (either for the stream
|
||||
# or the connection one), and no data can be sent until the flow control
|
||||
# window is updated.
|
||||
await self.window_update_received[stream_id].wait()
|
||||
self.window_update_received[stream_id].clear()
|
||||
else:
|
||||
chunk, data = data[:chunk_size], data[chunk_size:]
|
||||
self.h2_state.send_data(stream_id, chunk)
|
||||
data_to_send = self.h2_state.data_to_send()
|
||||
await self.stream.write(data_to_send, timeout)
|
||||
|
||||
async def end_stream(self, stream_id: int, timeout: TimeoutConfig = None) -> None:
|
||||
logger.debug(f"end_stream stream_id={stream_id}")
|
||||
self.h2_state.end_stream(stream_id)
|
||||
data_to_send = self.h2_state.data_to_send()
|
||||
await self.stream.write(data_to_send, timeout)
|
||||
|
||||
async def receive_response(
|
||||
self, stream_id: int, timeout: TimeoutConfig = None
|
||||
) -> typing.Tuple[int, typing.List[typing.Tuple[bytes, bytes]]]:
|
||||
"""
|
||||
Read the response status and headers from the network.
|
||||
"""
|
||||
while True:
|
||||
event = await self.receive_event(stream_id, timeout)
|
||||
# As soon as we start seeing response events, we should enable
|
||||
# read timeouts, if we haven't already.
|
||||
self.timeout_flags[stream_id].set_read_timeouts()
|
||||
if isinstance(event, h2.events.ResponseReceived):
|
||||
break
|
||||
|
||||
status_code = 200
|
||||
headers = []
|
||||
for k, v in event.headers:
|
||||
if k == b":status":
|
||||
status_code = int(v.decode("ascii", errors="ignore"))
|
||||
elif not k.startswith(b":"):
|
||||
headers.append((k, v))
|
||||
|
||||
return (status_code, headers)
|
||||
|
||||
async def body_iter(
|
||||
self, stream_id: int, timeout: TimeoutConfig = None
|
||||
) -> typing.AsyncIterator[bytes]:
|
||||
while True:
|
||||
event = await self.receive_event(stream_id, timeout)
|
||||
if isinstance(event, h2.events.DataReceived):
|
||||
self.h2_state.acknowledge_received_data(
|
||||
event.flow_controlled_length, stream_id
|
||||
)
|
||||
yield event.data
|
||||
elif isinstance(event, (h2.events.StreamEnded, h2.events.StreamReset)):
|
||||
break
|
||||
|
||||
async def receive_event(
|
||||
self, stream_id: int, timeout: TimeoutConfig = None
|
||||
) -> h2.events.Event:
|
||||
while not self.events[stream_id]:
|
||||
flag = self.timeout_flags[stream_id]
|
||||
data = await self.stream.read(self.READ_NUM_BYTES, timeout, flag=flag)
|
||||
events = self.h2_state.receive_data(data)
|
||||
for event in events:
|
||||
event_stream_id = getattr(event, "stream_id", 0)
|
||||
logger.debug(
|
||||
f"receive_event stream_id={event_stream_id} event={event!r}"
|
||||
)
|
||||
|
||||
if hasattr(event, "error_code"):
|
||||
raise ProtocolError(event)
|
||||
|
||||
if isinstance(event, h2.events.WindowUpdated):
|
||||
if event_stream_id == 0:
|
||||
for window_update_event in self.window_update_received.values():
|
||||
window_update_event.set()
|
||||
else:
|
||||
try:
|
||||
self.window_update_received[event_stream_id].set()
|
||||
except KeyError:
|
||||
# the window_update_received dictionary is only relevant
|
||||
# when sending data, which should never raise a KeyError
|
||||
# here.
|
||||
pass
|
||||
|
||||
if event_stream_id:
|
||||
self.events[event.stream_id].append(event)
|
||||
|
||||
data_to_send = self.h2_state.data_to_send()
|
||||
await self.stream.write(data_to_send, timeout)
|
||||
|
||||
return self.events[stream_id].pop(0)
|
||||
|
||||
async def response_closed(self, stream_id: int) -> None:
|
||||
del self.events[stream_id]
|
||||
del self.timeout_flags[stream_id]
|
||||
del self.window_update_received[stream_id]
|
||||
|
||||
if not self.events and self.on_release is not None:
|
||||
await self.on_release()
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
return False
|
||||
|
||||
def is_connection_dropped(self) -> bool:
|
||||
return self.stream.is_connection_dropped()
|
||||
@ -1,256 +0,0 @@
|
||||
import enum
|
||||
|
||||
import h11
|
||||
|
||||
from ..concurrency.base import ConcurrencyBackend
|
||||
from ..config import (
|
||||
DEFAULT_POOL_LIMITS,
|
||||
DEFAULT_TIMEOUT_CONFIG,
|
||||
CertTypes,
|
||||
HTTPVersionTypes,
|
||||
PoolLimits,
|
||||
SSLConfig,
|
||||
TimeoutTypes,
|
||||
VerifyTypes,
|
||||
)
|
||||
from ..exceptions import ProxyError
|
||||
from ..middleware.basic_auth import build_basic_auth_header
|
||||
from ..models import (
|
||||
URL,
|
||||
AsyncRequest,
|
||||
AsyncResponse,
|
||||
Headers,
|
||||
HeaderTypes,
|
||||
Origin,
|
||||
URLTypes,
|
||||
)
|
||||
from ..utils import get_logger
|
||||
from .connection import HTTPConnection
|
||||
from .connection_pool import ConnectionPool
|
||||
from .http2 import HTTP2Connection
|
||||
from .http11 import HTTP11Connection
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class HTTPProxyMode(enum.Enum):
|
||||
DEFAULT = "DEFAULT"
|
||||
FORWARD_ONLY = "FORWARD_ONLY"
|
||||
TUNNEL_ONLY = "TUNNEL_ONLY"
|
||||
|
||||
|
||||
class HTTPProxy(ConnectionPool):
|
||||
"""A proxy that sends requests to the recipient server
|
||||
on behalf of the connecting client.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
proxy_url: URLTypes,
|
||||
*,
|
||||
proxy_headers: HeaderTypes = None,
|
||||
proxy_mode: HTTPProxyMode = HTTPProxyMode.DEFAULT,
|
||||
verify: VerifyTypes = True,
|
||||
cert: CertTypes = None,
|
||||
trust_env: bool = None,
|
||||
timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
|
||||
pool_limits: PoolLimits = DEFAULT_POOL_LIMITS,
|
||||
http_versions: HTTPVersionTypes = None,
|
||||
backend: ConcurrencyBackend = None,
|
||||
):
|
||||
|
||||
super(HTTPProxy, self).__init__(
|
||||
verify=verify,
|
||||
cert=cert,
|
||||
timeout=timeout,
|
||||
pool_limits=pool_limits,
|
||||
backend=backend,
|
||||
trust_env=trust_env,
|
||||
http_versions=http_versions,
|
||||
)
|
||||
|
||||
self.proxy_url = URL(proxy_url)
|
||||
self.proxy_mode = proxy_mode
|
||||
self.proxy_headers = Headers(proxy_headers)
|
||||
|
||||
url = self.proxy_url
|
||||
if url.username or url.password:
|
||||
self.proxy_headers.setdefault(
|
||||
"Proxy-Authorization",
|
||||
build_basic_auth_header(url.username, url.password),
|
||||
)
|
||||
# Remove userinfo from the URL authority, e.g.:
|
||||
# 'username:password@proxy_host:proxy_port' -> 'proxy_host:proxy_port'
|
||||
credentials, _, authority = url.authority.rpartition("@")
|
||||
self.proxy_url = url.copy_with(authority=authority)
|
||||
|
||||
async def acquire_connection(self, origin: Origin) -> HTTPConnection:
|
||||
if self.should_forward_origin(origin):
|
||||
logger.debug(
|
||||
f"forward_connection proxy_url={self.proxy_url!r} origin={origin!r}"
|
||||
)
|
||||
return await super().acquire_connection(self.proxy_url.origin)
|
||||
else:
|
||||
logger.debug(
|
||||
f"tunnel_connection proxy_url={self.proxy_url!r} origin={origin!r}"
|
||||
)
|
||||
return await self.tunnel_connection(origin)
|
||||
|
||||
async def tunnel_connection(self, origin: Origin) -> HTTPConnection:
|
||||
"""Creates a new HTTPConnection via the CONNECT method
|
||||
usually reserved for proxying HTTPS connections.
|
||||
"""
|
||||
connection = self.pop_connection(origin)
|
||||
|
||||
if connection is None:
|
||||
connection = await self.request_tunnel_proxy_connection(origin)
|
||||
|
||||
# After we receive the 2XX response from the proxy that our
|
||||
# tunnel is open we switch the connection's origin
|
||||
# to the original so the tunnel can be re-used.
|
||||
self.active_connections.remove(connection)
|
||||
connection.origin = origin
|
||||
self.active_connections.add(connection)
|
||||
|
||||
await self.tunnel_start_tls(origin, connection)
|
||||
else:
|
||||
self.active_connections.add(connection)
|
||||
|
||||
return connection
|
||||
|
||||
async def request_tunnel_proxy_connection(self, origin: Origin) -> HTTPConnection:
|
||||
"""Creates an HTTPConnection by setting up a TCP tunnel"""
|
||||
proxy_headers = self.proxy_headers.copy()
|
||||
proxy_headers.setdefault("Accept", "*/*")
|
||||
proxy_request = AsyncRequest(
|
||||
method="CONNECT", url=self.proxy_url.copy_with(), headers=proxy_headers
|
||||
)
|
||||
proxy_request.url.full_path = f"{origin.host}:{origin.port}"
|
||||
|
||||
await self.max_connections.acquire()
|
||||
|
||||
connection = HTTPConnection(
|
||||
self.proxy_url.origin,
|
||||
verify=self.verify,
|
||||
cert=self.cert,
|
||||
timeout=self.timeout,
|
||||
backend=self.backend,
|
||||
http_versions=["HTTP/1.1"], # Short-lived 'connection'
|
||||
trust_env=self.trust_env,
|
||||
release_func=self.release_connection,
|
||||
)
|
||||
self.active_connections.add(connection)
|
||||
|
||||
# See if our tunnel has been opened successfully
|
||||
proxy_response = await connection.send(proxy_request)
|
||||
logger.debug(
|
||||
f"tunnel_response "
|
||||
f"proxy_url={self.proxy_url!r} "
|
||||
f"origin={origin!r} "
|
||||
f"response={proxy_response!r}"
|
||||
)
|
||||
if not (200 <= proxy_response.status_code <= 299):
|
||||
await proxy_response.read()
|
||||
raise ProxyError(
|
||||
f"Non-2XX response received from HTTP proxy "
|
||||
f"({proxy_response.status_code})",
|
||||
request=proxy_request,
|
||||
response=proxy_response,
|
||||
)
|
||||
else:
|
||||
proxy_response.on_close = None
|
||||
await proxy_response.read()
|
||||
|
||||
return connection
|
||||
|
||||
async def tunnel_start_tls(
|
||||
self, origin: Origin, connection: HTTPConnection
|
||||
) -> None:
|
||||
"""Runs start_tls() on a TCP-tunneled connection"""
|
||||
|
||||
# Store this information here so that we can transfer
|
||||
# it to the new internal connection object after
|
||||
# the old one goes to 'SWITCHED_PROTOCOL'.
|
||||
http_version = "HTTP/1.1"
|
||||
http_connection = connection.h11_connection
|
||||
assert http_connection is not None
|
||||
assert http_connection.h11_state.our_state == h11.SWITCHED_PROTOCOL
|
||||
on_release = http_connection.on_release
|
||||
stream = http_connection.stream
|
||||
|
||||
# If we need to start TLS again for the target server
|
||||
# we need to pull the TCP stream off the internal
|
||||
# HTTP connection object and run start_tls()
|
||||
if origin.is_ssl:
|
||||
ssl_config = SSLConfig(cert=self.cert, verify=self.verify)
|
||||
timeout = connection.timeout
|
||||
ssl_context = await connection.get_ssl_context(ssl_config)
|
||||
assert ssl_context is not None
|
||||
|
||||
logger.debug(
|
||||
f"tunnel_start_tls "
|
||||
f"proxy_url={self.proxy_url!r} "
|
||||
f"origin={origin!r}"
|
||||
)
|
||||
stream = await self.backend.start_tls(
|
||||
stream=stream,
|
||||
hostname=origin.host,
|
||||
ssl_context=ssl_context,
|
||||
timeout=timeout,
|
||||
)
|
||||
http_version = stream.get_http_version()
|
||||
logger.debug(
|
||||
f"tunnel_tls_complete "
|
||||
f"proxy_url={self.proxy_url!r} "
|
||||
f"origin={origin!r} "
|
||||
f"http_version={http_version!r}"
|
||||
)
|
||||
|
||||
if http_version == "HTTP/2":
|
||||
connection.h2_connection = HTTP2Connection(
|
||||
stream, self.backend, on_release=on_release
|
||||
)
|
||||
else:
|
||||
assert http_version == "HTTP/1.1"
|
||||
connection.h11_connection = HTTP11Connection(
|
||||
stream, self.backend, on_release=on_release
|
||||
)
|
||||
|
||||
def should_forward_origin(self, origin: Origin) -> bool:
|
||||
"""Determines if the given origin should
|
||||
be forwarded or tunneled. If 'proxy_mode' is 'DEFAULT'
|
||||
then the proxy will forward all 'HTTP' requests and
|
||||
tunnel all 'HTTPS' requests.
|
||||
"""
|
||||
return (
|
||||
self.proxy_mode == HTTPProxyMode.DEFAULT and not origin.is_ssl
|
||||
) or self.proxy_mode == HTTPProxyMode.FORWARD_ONLY
|
||||
|
||||
async def send(
|
||||
self,
|
||||
request: AsyncRequest,
|
||||
verify: VerifyTypes = None,
|
||||
cert: CertTypes = None,
|
||||
timeout: TimeoutTypes = None,
|
||||
) -> AsyncResponse:
|
||||
|
||||
if self.should_forward_origin(request.url.origin):
|
||||
# Change the request to have the target URL
|
||||
# as its full_path and switch the proxy URL
|
||||
# for where the request will be sent.
|
||||
target_url = str(request.url)
|
||||
request.url = self.proxy_url.copy_with()
|
||||
request.url.full_path = target_url
|
||||
for name, value in self.proxy_headers.items():
|
||||
request.headers.setdefault(name, value)
|
||||
|
||||
return await super().send(
|
||||
request=request, verify=verify, cert=cert, timeout=timeout
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"HTTPProxy(proxy_url={self.proxy_url!r} "
|
||||
f"proxy_headers={self.proxy_headers!r} "
|
||||
f"proxy_mode={self.proxy_mode!r})"
|
||||
)
|
||||
@ -1,97 +0,0 @@
|
||||
from ..concurrency.base import ConcurrencyBackend
|
||||
from ..config import CertTypes, TimeoutTypes, VerifyTypes
|
||||
from ..models import (
|
||||
AsyncRequest,
|
||||
AsyncRequestData,
|
||||
AsyncResponse,
|
||||
AsyncResponseContent,
|
||||
Request,
|
||||
RequestData,
|
||||
Response,
|
||||
ResponseContent,
|
||||
)
|
||||
from .base import AsyncDispatcher, Dispatcher
|
||||
|
||||
|
||||
class ThreadedDispatcher(AsyncDispatcher):
|
||||
"""
|
||||
The ThreadedDispatcher class is used to mediate between the Client
|
||||
(which always uses async under the hood), and a synchronous `Dispatch`
|
||||
class.
|
||||
"""
|
||||
|
||||
def __init__(self, dispatch: Dispatcher, backend: ConcurrencyBackend) -> None:
|
||||
self.sync_dispatcher = dispatch
|
||||
self.backend = backend
|
||||
|
||||
async def send(
|
||||
self,
|
||||
request: AsyncRequest,
|
||||
verify: VerifyTypes = None,
|
||||
cert: CertTypes = None,
|
||||
timeout: TimeoutTypes = None,
|
||||
) -> AsyncResponse:
|
||||
concurrency_backend = self.backend
|
||||
|
||||
data = getattr(request, "content", getattr(request, "content_aiter", None))
|
||||
sync_data = self._sync_request_data(data)
|
||||
|
||||
sync_request = Request(
|
||||
method=request.method,
|
||||
url=request.url,
|
||||
headers=request.headers,
|
||||
data=sync_data,
|
||||
)
|
||||
|
||||
func = self.sync_dispatcher.send
|
||||
kwargs = {
|
||||
"request": sync_request,
|
||||
"verify": verify,
|
||||
"cert": cert,
|
||||
"timeout": timeout,
|
||||
}
|
||||
sync_response = await self.backend.run_in_threadpool(func, **kwargs)
|
||||
assert isinstance(sync_response, Response)
|
||||
|
||||
content = getattr(
|
||||
sync_response, "_raw_content", getattr(sync_response, "_raw_stream", None)
|
||||
)
|
||||
|
||||
async_content = self._async_response_content(content)
|
||||
|
||||
async def async_on_close() -> None:
|
||||
nonlocal concurrency_backend, sync_response
|
||||
await concurrency_backend.run_in_threadpool(sync_response.close)
|
||||
|
||||
return AsyncResponse(
|
||||
status_code=sync_response.status_code,
|
||||
http_version=sync_response.http_version,
|
||||
headers=sync_response.headers,
|
||||
content=async_content,
|
||||
on_close=async_on_close,
|
||||
request=request,
|
||||
history=sync_response.history,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""
|
||||
The `.close()` method runs the `Dispatcher.close()` within a threadpool,
|
||||
so as not to block the async event loop.
|
||||
"""
|
||||
func = self.sync_dispatcher.close
|
||||
await self.backend.run_in_threadpool(func)
|
||||
|
||||
def _async_response_content(self, content: ResponseContent) -> AsyncResponseContent:
|
||||
if isinstance(content, bytes):
|
||||
return content
|
||||
|
||||
# Coerce an async iterator into an iterator, with each item in the
|
||||
# iteration run within the event loop.
|
||||
assert hasattr(content, "__iter__")
|
||||
return self.backend.iterate_in_threadpool(content)
|
||||
|
||||
def _sync_request_data(self, data: AsyncRequestData) -> RequestData:
|
||||
if isinstance(data, bytes):
|
||||
return data
|
||||
|
||||
return self.backend.iterate(data)
|
||||
@ -1,164 +0,0 @@
|
||||
import io
|
||||
import typing
|
||||
|
||||
from ..config import CertTypes, TimeoutTypes, VerifyTypes
|
||||
from ..models import Request, Response
|
||||
from .base import Dispatcher
|
||||
|
||||
|
||||
class WSGIDispatch(Dispatcher):
|
||||
"""
|
||||
A custom dispatcher that handles sending requests directly to an ASGI app.
|
||||
|
||||
The simplest way to use this functionality is to use the `app`argument.
|
||||
This will automatically infer if 'app' is a WSGI or an ASGI application,
|
||||
and will setup an appropriate dispatch class:
|
||||
|
||||
```
|
||||
client = httpx.Client(app=app)
|
||||
```
|
||||
|
||||
Alternatively, you can setup the dispatch instance explicitly.
|
||||
This allows you to include any additional configuration arguments specific
|
||||
to the WSGIDispatch class:
|
||||
|
||||
```
|
||||
dispatch = httpx.WSGIDispatch(
|
||||
app=app,
|
||||
script_name="/submount",
|
||||
remote_addr="1.2.3.4"
|
||||
)
|
||||
client = httpx.Client(dispatch=dispatch)
|
||||
|
||||
|
||||
Arguments:
|
||||
|
||||
* `app` - The ASGI application.
|
||||
* `raise_app_exceptions` - Boolean indicating if exceptions in the application
|
||||
should be raised. Default to `True`. Can be set to `False` for use cases
|
||||
such as testing the content of a client 500 response.
|
||||
* `script_name` - The root path on which the ASGI application should be mounted.
|
||||
* `remote_addr` - A string indicating the client IP of incoming requests.
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: typing.Callable,
|
||||
raise_app_exceptions: bool = True,
|
||||
script_name: str = "",
|
||||
remote_addr: str = "127.0.0.1",
|
||||
) -> None:
|
||||
self.app = app
|
||||
self.raise_app_exceptions = raise_app_exceptions
|
||||
self.script_name = script_name
|
||||
self.remote_addr = remote_addr
|
||||
|
||||
def send(
|
||||
self,
|
||||
request: Request,
|
||||
verify: VerifyTypes = None,
|
||||
cert: CertTypes = None,
|
||||
timeout: TimeoutTypes = None,
|
||||
) -> Response:
|
||||
environ = {
|
||||
"wsgi.version": (1, 0),
|
||||
"wsgi.url_scheme": request.url.scheme,
|
||||
"wsgi.input": BodyStream(request.stream()),
|
||||
"wsgi.errors": io.BytesIO(),
|
||||
"wsgi.multithread": True,
|
||||
"wsgi.multiprocess": False,
|
||||
"wsgi.run_once": False,
|
||||
"REQUEST_METHOD": request.method,
|
||||
"SCRIPT_NAME": self.script_name,
|
||||
"PATH_INFO": request.url.path,
|
||||
"QUERY_STRING": request.url.query,
|
||||
"SERVER_NAME": request.url.host,
|
||||
"SERVER_PORT": str(request.url.port),
|
||||
"REMOTE_ADDR": self.remote_addr,
|
||||
}
|
||||
for key, value in request.headers.items():
|
||||
key = key.upper().replace("-", "_")
|
||||
if key not in ("CONTENT_TYPE", "CONTENT_LENGTH"):
|
||||
key = "HTTP_" + key
|
||||
environ[key] = value
|
||||
|
||||
seen_status = None
|
||||
seen_response_headers = None
|
||||
seen_exc_info = None
|
||||
|
||||
def start_response(
|
||||
status: str, response_headers: list, exc_info: typing.Any = None
|
||||
) -> None:
|
||||
nonlocal seen_status, seen_response_headers, seen_exc_info
|
||||
seen_status = status
|
||||
seen_response_headers = response_headers
|
||||
seen_exc_info = exc_info
|
||||
|
||||
result = self.app(environ, start_response)
|
||||
|
||||
assert seen_status is not None
|
||||
assert seen_response_headers is not None
|
||||
if seen_exc_info and self.raise_app_exceptions:
|
||||
raise seen_exc_info[1]
|
||||
|
||||
return Response(
|
||||
status_code=int(seen_status.split()[0]),
|
||||
http_version="HTTP/1.1",
|
||||
headers=seen_response_headers,
|
||||
content=(chunk for chunk in result),
|
||||
on_close=getattr(result, "close", None),
|
||||
)
|
||||
|
||||
|
||||
class BodyStream(io.RawIOBase):
|
||||
def __init__(self, iterator: typing.Iterator[bytes]) -> None:
|
||||
self._iterator = iterator
|
||||
self._buffer = b""
|
||||
self._closed = False
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
if self._closed:
|
||||
return b""
|
||||
|
||||
if size == -1:
|
||||
return self.readall()
|
||||
|
||||
try:
|
||||
while len(self._buffer) < size:
|
||||
self._buffer += next(self._iterator)
|
||||
except StopIteration:
|
||||
self._closed = True
|
||||
return self._buffer
|
||||
|
||||
output = self._buffer[:size]
|
||||
self._buffer = self._buffer[size:]
|
||||
return output
|
||||
|
||||
def readall(self) -> bytes:
|
||||
if self._closed:
|
||||
raise OSError("Stream closed") # pragma: nocover
|
||||
|
||||
for chunk in self._iterator:
|
||||
self._buffer += chunk
|
||||
|
||||
self._closed = True
|
||||
return self._buffer
|
||||
|
||||
def readinto(self, b: bytearray) -> typing.Optional[int]: # pragma: nocover
|
||||
output = self.read(len(b))
|
||||
count = len(output)
|
||||
b[:count] = output
|
||||
return count
|
||||
|
||||
def write(self, b: bytes) -> int:
|
||||
raise OSError("Operation not supported") # pragma: nocover
|
||||
|
||||
def fileno(self) -> int:
|
||||
raise OSError("Operation not supported") # pragma: nocover
|
||||
|
||||
def seek(self, offset: int, whence: int = 0) -> int:
|
||||
raise OSError("Operation not supported") # pragma: nocover
|
||||
|
||||
def truncate(self, size: int = None) -> int:
|
||||
raise OSError("Operation not supported") # pragma: nocover
|
||||
@ -1,156 +0,0 @@
|
||||
import typing
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .models import BaseRequest, BaseResponse # pragma: nocover
|
||||
|
||||
|
||||
class HTTPError(Exception):
|
||||
"""
|
||||
Base class for Httpx exception
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: typing.Any,
|
||||
request: "BaseRequest" = None,
|
||||
response: "BaseResponse" = None,
|
||||
) -> None:
|
||||
self.response = response
|
||||
self.request = request or getattr(self.response, "request", None)
|
||||
super().__init__(*args)
|
||||
|
||||
|
||||
# Timeout exceptions...
|
||||
|
||||
|
||||
class Timeout(HTTPError):
|
||||
"""
|
||||
A base class for all timeouts.
|
||||
"""
|
||||
|
||||
|
||||
class ConnectTimeout(Timeout):
|
||||
"""
|
||||
Timeout while establishing a connection.
|
||||
"""
|
||||
|
||||
|
||||
class ReadTimeout(Timeout):
|
||||
"""
|
||||
Timeout while reading response data.
|
||||
"""
|
||||
|
||||
|
||||
class WriteTimeout(Timeout):
|
||||
"""
|
||||
Timeout while writing request data.
|
||||
"""
|
||||
|
||||
|
||||
class PoolTimeout(Timeout):
|
||||
"""
|
||||
Timeout while waiting to acquire a connection from the pool.
|
||||
"""
|
||||
|
||||
|
||||
class ProxyError(HTTPError):
|
||||
"""
|
||||
Error from within a proxy
|
||||
"""
|
||||
|
||||
|
||||
# HTTP exceptions...
|
||||
|
||||
|
||||
class ProtocolError(HTTPError):
|
||||
"""
|
||||
Malformed HTTP.
|
||||
"""
|
||||
|
||||
|
||||
class DecodingError(HTTPError):
|
||||
"""
|
||||
Decoding of the response failed.
|
||||
"""
|
||||
|
||||
|
||||
# Redirect exceptions...
|
||||
|
||||
|
||||
class RedirectError(HTTPError):
|
||||
"""
|
||||
Base class for HTTP redirect errors.
|
||||
"""
|
||||
|
||||
|
||||
class TooManyRedirects(RedirectError):
|
||||
"""
|
||||
Too many redirects.
|
||||
"""
|
||||
|
||||
|
||||
class RedirectBodyUnavailable(RedirectError):
|
||||
"""
|
||||
Got a redirect response, but the request body was streaming, and is
|
||||
no longer available.
|
||||
"""
|
||||
|
||||
|
||||
class RedirectLoop(RedirectError):
|
||||
"""
|
||||
Infinite redirect loop.
|
||||
"""
|
||||
|
||||
|
||||
class NotRedirectResponse(RedirectError):
|
||||
"""
|
||||
Response was not a redirect response.
|
||||
"""
|
||||
|
||||
|
||||
# Stream exceptions...
|
||||
|
||||
|
||||
class StreamError(HTTPError):
|
||||
"""
|
||||
The base class for stream exceptions.
|
||||
|
||||
The developer made an error in accessing the request stream in
|
||||
an invalid way.
|
||||
"""
|
||||
|
||||
|
||||
class StreamConsumed(StreamError):
|
||||
"""
|
||||
Attempted to read or stream response content, but the content has already
|
||||
been streamed.
|
||||
"""
|
||||
|
||||
|
||||
class ResponseNotRead(StreamError):
|
||||
"""
|
||||
Attempted to access response content, without having called `read()`
|
||||
after a streaming response.
|
||||
"""
|
||||
|
||||
|
||||
class ResponseClosed(StreamError):
|
||||
"""
|
||||
Attempted to read or stream response content, but the request has been
|
||||
closed.
|
||||
"""
|
||||
|
||||
|
||||
# Other cases...
|
||||
|
||||
|
||||
class InvalidURL(HTTPError):
|
||||
"""
|
||||
URL was missing a hostname, or was not one of HTTP/HTTPS.
|
||||
"""
|
||||
|
||||
|
||||
class CookieConflict(HTTPError):
|
||||
"""
|
||||
Attempted to lookup a cookie by name, but multiple cookies existed.
|
||||
"""
|
||||
@ -1,10 +0,0 @@
|
||||
import typing
|
||||
|
||||
from ..models import AsyncRequest, AsyncResponse
|
||||
|
||||
|
||||
class BaseMiddleware:
|
||||
async def __call__(
|
||||
self, request: AsyncRequest, get_response: typing.Callable
|
||||
) -> AsyncResponse:
|
||||
raise NotImplementedError # pragma: no cover
|
||||
@ -1,27 +0,0 @@
|
||||
import typing
|
||||
from base64 import b64encode
|
||||
|
||||
from ..models import AsyncRequest, AsyncResponse
|
||||
from ..utils import to_bytes
|
||||
from .base import BaseMiddleware
|
||||
|
||||
|
||||
class BasicAuthMiddleware(BaseMiddleware):
|
||||
def __init__(
|
||||
self, username: typing.Union[str, bytes], password: typing.Union[str, bytes]
|
||||
):
|
||||
self.authorization_header = build_basic_auth_header(username, password)
|
||||
|
||||
async def __call__(
|
||||
self, request: AsyncRequest, get_response: typing.Callable
|
||||
) -> AsyncResponse:
|
||||
request.headers["Authorization"] = self.authorization_header
|
||||
return await get_response(request)
|
||||
|
||||
|
||||
def build_basic_auth_header(
|
||||
username: typing.Union[str, bytes], password: typing.Union[str, bytes]
|
||||
) -> str:
|
||||
userpass = b":".join((to_bytes(username), to_bytes(password)))
|
||||
token = b64encode(userpass).decode().strip()
|
||||
return f"Basic {token}"
|
||||
@ -1,15 +0,0 @@
|
||||
import typing
|
||||
|
||||
from ..models import AsyncRequest, AsyncResponse
|
||||
from .base import BaseMiddleware
|
||||
|
||||
|
||||
class CustomAuthMiddleware(BaseMiddleware):
|
||||
def __init__(self, auth: typing.Callable[[AsyncRequest], AsyncRequest]):
|
||||
self.auth = auth
|
||||
|
||||
async def __call__(
|
||||
self, request: AsyncRequest, get_response: typing.Callable
|
||||
) -> AsyncResponse:
|
||||
request = self.auth(request)
|
||||
return await get_response(request)
|
||||
@ -1,181 +0,0 @@
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import typing
|
||||
from urllib.request import parse_http_list
|
||||
|
||||
from ..exceptions import ProtocolError
|
||||
from ..models import AsyncRequest, AsyncResponse, StatusCode
|
||||
from ..utils import to_bytes, to_str, unquote
|
||||
from .base import BaseMiddleware
|
||||
|
||||
|
||||
class DigestAuth(BaseMiddleware):
|
||||
ALGORITHM_TO_HASH_FUNCTION: typing.Dict[str, typing.Callable] = {
|
||||
"MD5": hashlib.md5,
|
||||
"MD5-SESS": hashlib.md5,
|
||||
"SHA": hashlib.sha1,
|
||||
"SHA-SESS": hashlib.sha1,
|
||||
"SHA-256": hashlib.sha256,
|
||||
"SHA-256-SESS": hashlib.sha256,
|
||||
"SHA-512": hashlib.sha512,
|
||||
"SHA-512-SESS": hashlib.sha512,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self, username: typing.Union[str, bytes], password: typing.Union[str, bytes]
|
||||
) -> None:
|
||||
self.username = to_bytes(username)
|
||||
self.password = to_bytes(password)
|
||||
|
||||
async def __call__(
|
||||
self, request: AsyncRequest, get_response: typing.Callable
|
||||
) -> AsyncResponse:
|
||||
response = await get_response(request)
|
||||
if not (
|
||||
StatusCode.is_client_error(response.status_code)
|
||||
and "www-authenticate" in response.headers
|
||||
):
|
||||
return response
|
||||
|
||||
header = response.headers["www-authenticate"]
|
||||
try:
|
||||
challenge = DigestAuthChallenge.from_header(header)
|
||||
except ValueError:
|
||||
raise ProtocolError("Malformed Digest authentication header")
|
||||
|
||||
request.headers["Authorization"] = self._build_auth_header(request, challenge)
|
||||
return await get_response(request)
|
||||
|
||||
def _build_auth_header(
|
||||
self, request: AsyncRequest, challenge: "DigestAuthChallenge"
|
||||
) -> str:
|
||||
hash_func = self.ALGORITHM_TO_HASH_FUNCTION[challenge.algorithm]
|
||||
|
||||
def digest(data: bytes) -> bytes:
|
||||
return hash_func(data).hexdigest().encode()
|
||||
|
||||
A1 = b":".join((self.username, challenge.realm, self.password))
|
||||
|
||||
path = request.url.full_path.encode("utf-8")
|
||||
A2 = b":".join((request.method.encode(), path))
|
||||
# TODO: implement auth-int
|
||||
HA2 = digest(A2)
|
||||
|
||||
nonce_count = 1 # TODO: implement nonce counting
|
||||
nc_value = b"%08x" % nonce_count
|
||||
cnonce = self._get_client_nonce(nonce_count, challenge.nonce)
|
||||
|
||||
HA1 = digest(A1)
|
||||
if challenge.algorithm.lower().endswith("-sess"):
|
||||
HA1 = digest(b":".join((HA1, challenge.nonce, cnonce)))
|
||||
|
||||
qop = self._resolve_qop(challenge.qop)
|
||||
if qop is None:
|
||||
digest_data = [HA1, challenge.nonce, HA2]
|
||||
else:
|
||||
digest_data = [challenge.nonce, nc_value, cnonce, qop, HA2]
|
||||
key_digest = b":".join(digest_data)
|
||||
|
||||
format_args = {
|
||||
"username": self.username,
|
||||
"realm": challenge.realm,
|
||||
"nonce": challenge.nonce,
|
||||
"uri": path,
|
||||
"response": digest(b":".join((HA1, key_digest))),
|
||||
"algorithm": challenge.algorithm.encode(),
|
||||
}
|
||||
if challenge.opaque:
|
||||
format_args["opaque"] = challenge.opaque
|
||||
if qop:
|
||||
format_args["qop"] = b"auth"
|
||||
format_args["nc"] = nc_value
|
||||
format_args["cnonce"] = cnonce
|
||||
|
||||
return "Digest " + self._get_header_value(format_args)
|
||||
|
||||
def _get_client_nonce(self, nonce_count: int, nonce: bytes) -> bytes:
|
||||
s = str(nonce_count).encode()
|
||||
s += nonce
|
||||
s += time.ctime().encode()
|
||||
s += os.urandom(8)
|
||||
|
||||
return hashlib.sha1(s).hexdigest()[:16].encode()
|
||||
|
||||
def _get_header_value(self, header_fields: typing.Dict[str, bytes]) -> str:
|
||||
NON_QUOTED_FIELDS = ("algorithm", "qop", "nc")
|
||||
QUOTED_TEMPLATE = '{}="{}"'
|
||||
NON_QUOTED_TEMPLATE = "{}={}"
|
||||
|
||||
header_value = ""
|
||||
for i, (field, value) in enumerate(header_fields.items()):
|
||||
if i > 0:
|
||||
header_value += ", "
|
||||
template = (
|
||||
QUOTED_TEMPLATE
|
||||
if field not in NON_QUOTED_FIELDS
|
||||
else NON_QUOTED_TEMPLATE
|
||||
)
|
||||
header_value += template.format(field, to_str(value))
|
||||
|
||||
return header_value
|
||||
|
||||
def _resolve_qop(self, qop: typing.Optional[bytes]) -> typing.Optional[bytes]:
|
||||
if qop is None:
|
||||
return None
|
||||
qops = re.split(b", ?", qop)
|
||||
if b"auth" in qops:
|
||||
return b"auth"
|
||||
|
||||
if qops == [b"auth-int"]:
|
||||
raise NotImplementedError("Digest auth-int support is not yet implemented")
|
||||
|
||||
raise ProtocolError(f'Unexpected qop value "{qop!r}" in digest auth')
|
||||
|
||||
|
||||
class DigestAuthChallenge:
|
||||
def __init__(
|
||||
self,
|
||||
realm: bytes,
|
||||
nonce: bytes,
|
||||
algorithm: str = None,
|
||||
opaque: typing.Optional[bytes] = None,
|
||||
qop: typing.Optional[bytes] = None,
|
||||
) -> None:
|
||||
self.realm = realm
|
||||
self.nonce = nonce
|
||||
self.algorithm = algorithm or "MD5"
|
||||
self.opaque = opaque
|
||||
self.qop = qop
|
||||
|
||||
@classmethod
|
||||
def from_header(cls, header: str) -> "DigestAuthChallenge":
|
||||
"""Returns a challenge from a Digest WWW-Authenticate header.
|
||||
These take the form of:
|
||||
`Digest realm="realm@host.com",qop="auth,auth-int",nonce="abc",opaque="xyz"`
|
||||
"""
|
||||
scheme, _, fields = header.partition(" ")
|
||||
if scheme.lower() != "digest":
|
||||
raise ValueError("Header does not start with 'Digest'")
|
||||
|
||||
header_dict: typing.Dict[str, str] = {}
|
||||
for field in parse_http_list(fields):
|
||||
key, value = field.strip().split("=", 1)
|
||||
header_dict[key] = unquote(value)
|
||||
|
||||
try:
|
||||
return cls.from_header_dict(header_dict)
|
||||
except KeyError as exc:
|
||||
raise ValueError("Malformed Digest WWW-Authenticate header") from exc
|
||||
|
||||
@classmethod
|
||||
def from_header_dict(cls, header_dict: dict) -> "DigestAuthChallenge":
|
||||
realm = header_dict["realm"].encode()
|
||||
nonce = header_dict["nonce"].encode()
|
||||
qop = header_dict["qop"].encode() if "qop" in header_dict else None
|
||||
opaque = header_dict["opaque"].encode() if "opaque" in header_dict else None
|
||||
algorithm = header_dict.get("algorithm")
|
||||
return cls(
|
||||
realm=realm, nonce=nonce, qop=qop, opaque=opaque, algorithm=algorithm
|
||||
)
|
||||
@ -1,128 +0,0 @@
|
||||
import functools
|
||||
import typing
|
||||
|
||||
from ..config import DEFAULT_MAX_REDIRECTS
|
||||
from ..exceptions import RedirectBodyUnavailable, RedirectLoop, TooManyRedirects
|
||||
from ..models import URL, AsyncRequest, AsyncResponse, Cookies, Headers
|
||||
from ..status_codes import codes
|
||||
from .base import BaseMiddleware
|
||||
|
||||
|
||||
class RedirectMiddleware(BaseMiddleware):
|
||||
def __init__(
|
||||
self,
|
||||
allow_redirects: bool = True,
|
||||
max_redirects: int = DEFAULT_MAX_REDIRECTS,
|
||||
cookies: typing.Optional[Cookies] = None,
|
||||
):
|
||||
self.allow_redirects = allow_redirects
|
||||
self.max_redirects = max_redirects
|
||||
self.cookies = cookies
|
||||
self.history: typing.List[AsyncResponse] = []
|
||||
|
||||
async def __call__(
|
||||
self, request: AsyncRequest, get_response: typing.Callable
|
||||
) -> AsyncResponse:
|
||||
if len(self.history) > self.max_redirects:
|
||||
raise TooManyRedirects()
|
||||
if request.url in (response.url for response in self.history):
|
||||
raise RedirectLoop()
|
||||
|
||||
response = await get_response(request)
|
||||
response.history = list(self.history)
|
||||
|
||||
if not response.is_redirect:
|
||||
return response
|
||||
|
||||
self.history.append(response)
|
||||
next_request = self.build_redirect_request(request, response)
|
||||
|
||||
if self.allow_redirects:
|
||||
return await self(next_request, get_response)
|
||||
|
||||
response.call_next = functools.partial(self, next_request, get_response)
|
||||
return response
|
||||
|
||||
def build_redirect_request(
|
||||
self, request: AsyncRequest, response: AsyncResponse
|
||||
) -> AsyncRequest:
|
||||
method = self.redirect_method(request, response)
|
||||
url = self.redirect_url(request, response)
|
||||
headers = self.redirect_headers(request, url, method) # TODO: merge headers?
|
||||
content = self.redirect_content(request, method)
|
||||
cookies = Cookies(self.cookies)
|
||||
cookies.update(request.cookies)
|
||||
return AsyncRequest(
|
||||
method=method, url=url, headers=headers, data=content, cookies=cookies
|
||||
)
|
||||
|
||||
def redirect_method(self, request: AsyncRequest, response: AsyncResponse) -> str:
|
||||
"""
|
||||
When being redirected we may want to change the method of the request
|
||||
based on certain specs or browser behavior.
|
||||
"""
|
||||
method = request.method
|
||||
|
||||
# https://tools.ietf.org/html/rfc7231#section-6.4.4
|
||||
if response.status_code == codes.SEE_OTHER and method != "HEAD":
|
||||
method = "GET"
|
||||
|
||||
# Do what the browsers do, despite standards...
|
||||
# Turn 302s into GETs.
|
||||
if response.status_code == codes.FOUND and method != "HEAD":
|
||||
method = "GET"
|
||||
|
||||
# If a POST is responded to with a 301, turn it into a GET.
|
||||
# This bizarre behaviour is explained in 'requests' issue 1704.
|
||||
if response.status_code == codes.MOVED_PERMANENTLY and method == "POST":
|
||||
method = "GET"
|
||||
|
||||
return method
|
||||
|
||||
def redirect_url(self, request: AsyncRequest, response: AsyncResponse) -> URL:
|
||||
"""
|
||||
Return the URL for the redirect to follow.
|
||||
"""
|
||||
location = response.headers["Location"]
|
||||
|
||||
url = URL(location, allow_relative=True)
|
||||
|
||||
# Facilitate relative 'Location' headers, as allowed by RFC 7231.
|
||||
# (e.g. '/path/to/resource' instead of 'http://domain.tld/path/to/resource')
|
||||
if url.is_relative_url:
|
||||
url = request.url.join(url)
|
||||
|
||||
# Attach previous fragment if needed (RFC 7231 7.1.2)
|
||||
if request.url.fragment and not url.fragment:
|
||||
url = url.copy_with(fragment=request.url.fragment)
|
||||
|
||||
return url
|
||||
|
||||
def redirect_headers(self, request: AsyncRequest, url: URL, method: str) -> Headers:
|
||||
"""
|
||||
Return the headers that should be used for the redirect request.
|
||||
"""
|
||||
headers = Headers(request.headers)
|
||||
|
||||
if url.origin != request.url.origin:
|
||||
# Strip Authorization headers when responses are redirected away from
|
||||
# the origin.
|
||||
headers.pop("Authorization", None)
|
||||
headers["Host"] = url.authority
|
||||
|
||||
if method != request.method and method == "GET":
|
||||
# If we've switch to a 'GET' request, then strip any headers which
|
||||
# are only relevant to the request body.
|
||||
headers.pop("Content-Length", None)
|
||||
headers.pop("Transfer-Encoding", None)
|
||||
return headers
|
||||
|
||||
def redirect_content(self, request: AsyncRequest, method: str) -> bytes:
|
||||
"""
|
||||
Return the body that should be used for the redirect request.
|
||||
"""
|
||||
if method != request.method and method == "GET":
|
||||
return b""
|
||||
if request.is_streaming:
|
||||
raise RedirectBodyUnavailable()
|
||||
return request.content
|
||||