mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 23:00:17 +00:00
Compare commits
960 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cbcaf9c4f | |||
| 3fa6f11ffa | |||
| 8b88b148bf | |||
| 1aecafa82a | |||
| 8c763aa97e | |||
| 91aa242a5a | |||
| 084d057a99 | |||
| d3acf2c1c1 | |||
| 80715a12ac | |||
| 66fc7afbe4 | |||
| e7776eb9e8 | |||
| 944d47da45 | |||
| a3a12cff77 | |||
| 7b2839086d | |||
| 351ff8d95e | |||
| 2278beba18 | |||
| 3cfc7ec2b6 | |||
| 0de22eeed2 | |||
| b0cc37861b | |||
| 7d4532acc9 | |||
| 1b63d2943a | |||
| b5723303c8 | |||
| 5730be4b31 | |||
| 6f9c11645a | |||
| 827cc64988 | |||
| 7b5db5bc33 | |||
| b9a03c7088 | |||
| 4cbf55508e | |||
| 83d0fcf1ae | |||
| a698eaaab3 | |||
| 3aa21eed08 | |||
| 2741c74b90 | |||
| aba96525ad | |||
| a5b6d36991 | |||
| e4cff76fa6 | |||
| f11ad7136d | |||
| c32e8c7468 | |||
| d93e3cd12c | |||
| 040f1a57e4 | |||
| 307313744f | |||
| 98ca45003b | |||
| ab76594297 | |||
| 7fba0f6362 | |||
| 4ff73e9d0c | |||
| 68bbea0a55 | |||
| 106e5e9073 | |||
| 3426aa71da | |||
| 413028b636 | |||
| 3edf979a8c | |||
| cd75deeb4e | |||
| b71bb5ddb9 | |||
| 27a9459f22 | |||
| b39c539d57 | |||
| 718b53cce2 | |||
| 2e0b4975f7 | |||
| a118a5dc4b | |||
| 69c1d7f185 | |||
| fba2f135a3 | |||
| 4006de72cd | |||
| b3c7252197 | |||
| 398ac3343e | |||
| 8b197ba361 | |||
| e700aa2937 | |||
| 3894550642 | |||
| 43fd041138 | |||
| 363af5338d | |||
| 55430a4366 | |||
| f7c6a3ae97 | |||
| dcadba1425 | |||
| de08b15ae8 | |||
| 0cfca6d906 | |||
| a73e413a66 | |||
| 87931a25d0 | |||
| 1fd9a682dd | |||
| 5d3e650901 | |||
| 48d082e6a5 | |||
| 87e22481e8 | |||
| e48ce6c301 | |||
| e9613500da | |||
| c2943accd0 | |||
| 649a255657 | |||
| 7eaaaaafe1 | |||
| ae09b88978 | |||
| e3e307fd68 | |||
| 89f0724029 | |||
| bebe62adaf | |||
| eb9cddc8c2 | |||
| 7c19eca78a | |||
| ed28b11d21 | |||
| 46cdd4a245 | |||
| ac91b172e6 | |||
| ed0da6d462 | |||
| 555e9bff65 | |||
| bf43d9f202 | |||
| e239cc304d | |||
| 3285bd57c7 | |||
| 3090fb9e68 | |||
| e90bd24ebe | |||
| a0acc03a97 | |||
| 8a668e6efe | |||
| 4c75742e4d | |||
| 796fdc2ddf | |||
| a8caa3054b | |||
| 2ef9e133ad | |||
| 2ec570ad61 | |||
| 02aa338970 | |||
| 882250bd86 | |||
| 3809eda2f2 | |||
| b32eda70d2 | |||
| f1b2f46a10 | |||
| cf82dac4ad | |||
| a0913e3f63 | |||
| f90955a9b9 | |||
| 3736c9229d | |||
| a802853367 | |||
| 96ca88fe88 | |||
| a57570210a | |||
| 7682e94b35 | |||
| 8bbebe113c | |||
| 7c921f827b | |||
| 4cc055f93a | |||
| e596a8b457 | |||
| fd2da55880 | |||
| 975e9b5643 | |||
| c0036e0474 | |||
| 103816e27a | |||
| b7c1684ab4 | |||
| 16bd6ca266 | |||
| 20bae4712b | |||
| a7aa80c690 | |||
| df89d1d58b | |||
| 477cddd29c | |||
| 9b8cf3a1b1 | |||
| 2871a3c07f | |||
| 13763296dd | |||
| 783b22ab1c | |||
| 109937adf4 | |||
| 63ea9cc4e0 | |||
| ec40a0c4c3 | |||
| 0855d1a378 | |||
| 77fe17d350 | |||
| 0b8a031ccb | |||
| 0678daa880 | |||
| 6761e3bdd8 | |||
| ead213a506 | |||
| 75b5782eee | |||
| a80df809e4 | |||
| 7f3177f662 | |||
| 906cd2fbbf | |||
| 9d0129da56 | |||
| aedcf12d99 | |||
| 86361523e2 | |||
| a7110ef441 | |||
| d3e4968546 | |||
| 03e34d56ab | |||
| b470d10416 | |||
| b8aea89039 | |||
| 4a1e89af1b | |||
| 81fbc94d36 | |||
| 6487671559 | |||
| 838c7f29b5 | |||
| df85a4c214 | |||
| 1bdbea238e | |||
| 97dbef92d9 | |||
| 85c1c0036c | |||
| 0653ee2c6b | |||
| d1db913c7d | |||
| d24b921cdc | |||
| b31b742787 | |||
| d820f0277f | |||
| 7219856177 | |||
| e6d302aabb | |||
| d73243ab60 | |||
| 8101e7d7b0 | |||
| 784c7e72ae | |||
| 93156fd2f1 | |||
| e4f6898498 | |||
| ef330135f9 | |||
| 54cbbdface | |||
| f3c9320837 | |||
| 0529629ac8 | |||
| 22af42ead6 | |||
| bd2efb68e1 | |||
| 37ba3d2efc | |||
| ed8afeaa87 | |||
| ee6efe5aa4 | |||
| 3a8113d8b0 | |||
| 7afce42943 | |||
| 67a6c25256 | |||
| 38dea8311c | |||
| 555e1f7924 | |||
| 8b87f63609 | |||
| f01b1d493f | |||
| a802245bf0 | |||
| 6dbbad158a | |||
| 877fe144b4 | |||
| 70e6bc0466 | |||
| 63f2e833eb | |||
| fb71abe534 | |||
| 8ccb39560e | |||
| e6b880be62 | |||
| d0016ac7c9 | |||
| 05035e0171 | |||
| 78b5bef879 | |||
| a6955b5db5 | |||
| a1a0a1b71e | |||
| 0bdde6d5fe | |||
| cf5447d5bd | |||
| b2dd2c205d | |||
| e52c9277c8 | |||
| 712ec2410d | |||
| dea2ca41d2 | |||
| ca0f32c02b | |||
| f21b296fba | |||
| 3224479b99 | |||
| f95950eedc | |||
| 4467376d0a | |||
| ac65dc5361 | |||
| 4957793c80 | |||
| ff7f4b502d | |||
| 816cb7188b | |||
| 6456d435eb | |||
| 63e338ed6f | |||
| 00211c8f03 | |||
| ebed9fe3aa | |||
| 734b5e7303 | |||
| 1696d501e2 | |||
| e65d2f8c50 | |||
| 9ea705b2ea | |||
| 5a5a811dca | |||
| df7b9419c2 | |||
| 37318f1106 | |||
| 19e9f6ac5d | |||
| 658b51a449 | |||
| 485303c0f2 | |||
| 885d902b7d | |||
| a35f02fb64 | |||
| 28d1f16ad5 | |||
| a04d7c3a9a | |||
| b876f8484c | |||
| 854c6d3d65 | |||
| f9a850a8fe | |||
| e808662fe7 | |||
| 7bbb02126e | |||
| aa101059a7 | |||
| d1f7fe02e4 | |||
| 3e26dc1373 | |||
| 0a9d819555 | |||
| b31dfeefb7 | |||
| fc640ec331 | |||
| 3382723457 | |||
| 1fc0722ad6 | |||
| b21e308357 | |||
| 738105314b | |||
| f3cdc99b29 | |||
| eb70376438 | |||
| dae1a4fa35 | |||
| 2ad351197e | |||
| 3d9235c4bc | |||
| 2cd5596def | |||
| d4191030d9 | |||
| 447630a051 | |||
| f7b53a4895 | |||
| 21896aa171 | |||
| e8a15697d2 | |||
| 0030993631 | |||
| 13ba2f72f5 | |||
| 9f39917895 | |||
| 1b0859fdbb | |||
| acd1561b1b | |||
| 9f2182949d | |||
| 6e5b3a4bf9 | |||
| d2ec323888 | |||
| 8b9645cf2d | |||
| 4ecfef0ddf | |||
| 84fb7bd622 | |||
| 0b261252e1 | |||
| d60b5ee39e | |||
| e2f887ec5f | |||
| 97da6a6694 | |||
| c0e9a6778d | |||
| 5c327a2e0b | |||
| 5ed45634cb | |||
| a50a373e84 | |||
| 86705d0c2f | |||
| b9581444f9 | |||
| 2a60b094b8 | |||
| 1ec567cabf | |||
| 4fd898b239 | |||
| 03d6b72a00 | |||
| 4d0382d580 | |||
| a0dd7481ec | |||
| 1c91480b0c | |||
| 85e5ec0a9a | |||
| 4ac04b0abc | |||
| d7e64a6e39 | |||
| 17d526632e | |||
| 43da481df7 | |||
| 5f5402833b | |||
| d59c4333f2 | |||
| 49114f36ce | |||
| b2039d99f3 | |||
| 94fd86fee0 | |||
| d70fdd3301 | |||
| 05b75efb43 | |||
| be56e92d65 | |||
| 69eb843604 | |||
| 84a7f0e90b | |||
| d1e105a29a | |||
| 9f0a568fa3 | |||
| 05b46cbe34 | |||
| c045586997 | |||
| 8f0707f697 | |||
| 36929b265c | |||
| 734ba64965 | |||
| 148e6742df | |||
| bcb7e8f4f3 | |||
| f678112099 | |||
| 60b0c5f256 | |||
| c8627939de | |||
| 9144f0158a | |||
| d541aca80f | |||
| c73b2b8d34 | |||
| e2493b489d | |||
| 8dee28ac7c | |||
| cdd3885a0c | |||
| 1a28d528d0 | |||
| 3ba12b8cee | |||
| 5a29ab6917 | |||
| 694144a0c8 | |||
| 8bed8e8741 | |||
| a81a348bce | |||
| fd9e8c5cbc | |||
| 8030b1919d | |||
| 72c789fdd7 | |||
| 1113a9aa0d | |||
| a5532614a2 | |||
| 122023fb70 | |||
| b8fa923ec9 | |||
| ae06b3e01a | |||
| 5599ec2809 | |||
| e795cbddb6 | |||
| 0cb087c37b | |||
| 983cbcc711 | |||
| 6d154b0c78 | |||
| f3f36e28c4 | |||
| fdf4797726 | |||
| 67d8a3be98 | |||
| 4001a60f6c | |||
| d94db41271 | |||
| 8abb78bb58 | |||
| a80db99aa3 | |||
| 69a300f21a | |||
| 1b024b8092 | |||
| a622689597 | |||
| 9943e66c49 | |||
| 7233c08281 | |||
| 0845d92fda | |||
| 1cc02e5a83 | |||
| aa4cd7a144 | |||
| b42ae0dfd7 | |||
| a6bd179726 | |||
| aac7b5117b | |||
| 8e8e99ed2e | |||
| 078ac23b20 | |||
| 8e61df6b6a | |||
| cf7fb56653 | |||
| da20d13c49 | |||
| 7a250aa8fc | |||
| af28ecb82d | |||
| 39a0e52a2a | |||
| a4f5a111c7 | |||
| c65e585493 | |||
| c9e94561aa | |||
| 1daa4c202b | |||
| 83ff361672 | |||
| 2c686f107d | |||
| ac8ec3d5ef | |||
| 21e70ef913 | |||
| c4f5b0e7c2 | |||
| 45d6c1389d | |||
| 0c9bc5a3af | |||
| 5b67d5a04a | |||
| f3396a5573 | |||
| e9f48788a3 | |||
| 6993a1ea46 | |||
| 8bcfb4585b | |||
| db45251f7f | |||
| 5582667b4f | |||
| 2c898aaf23 | |||
| e0999ffcdd | |||
| 03811768bb | |||
| fbef577c9f | |||
| 9434510ce9 | |||
| 354130c151 | |||
| 3e3cba016a | |||
| f75e120bef | |||
| 1d0294e430 | |||
| f786dd8254 | |||
| cd9d09fd53 | |||
| 7471bbcd4e | |||
| 43b04eccbd | |||
| 6a5d0b5e9f | |||
| 359d366de4 | |||
| 556d9f3a7b | |||
| 2cab2dcec0 | |||
| 99d4e78dc9 | |||
| 9aa99869ae | |||
| 08e0d87347 | |||
| 3f9e4057d3 | |||
| a29e40353c | |||
| 778cb2dd0f | |||
| f7d5514b94 | |||
| 954637f7b3 | |||
| 1ab46104c8 | |||
| 815776d473 | |||
| 8db1a7be90 | |||
| 7b11fa24dd | |||
| 1f0f2318d5 | |||
| 029b3e2a52 | |||
| 4fff823def | |||
| cab78275f4 | |||
| 5f60e4fedb | |||
| 96971a33a7 | |||
| 9a7409f521 | |||
| 80aa7e305b | |||
| 27d513cb01 | |||
| 9bf5cc8c03 | |||
| 7994b210cd | |||
| 46555bbe3f | |||
| 4d15dbc465 | |||
| 855d3c4320 | |||
| 4564862acc | |||
| 176dd70073 | |||
| a5e6f0c196 | |||
| 083bb5a96c | |||
| 04522281be | |||
| 0e8bb49b59 | |||
| 9abf6eea16 | |||
| 1d7a04ce7b | |||
| 49fb5792c3 | |||
| 5eebba09c5 | |||
| b86974688e | |||
| 74afe2ed13 | |||
| ed53a0b624 | |||
| 23e15d6459 | |||
| 71ea19d1c1 | |||
| fa621d076d | |||
| 4902f1328a | |||
| 2ee8ff484d | |||
| c872fe3c78 | |||
| a08b275463 | |||
| 9717208dd4 | |||
| c9a233f5e5 | |||
| 7389350ff9 | |||
| f46ac08cff | |||
| 296d5e7974 | |||
| fe0bea686c | |||
| 838d172512 | |||
| 2c02c51c37 | |||
| 67a4cbca2c | |||
| a2f97e727f | |||
| 462506113e | |||
| 5f2a72203f | |||
| d6febe2d02 | |||
| c2bd1e989a | |||
| f886c2c050 | |||
| ae770e603a | |||
| 7b79472d65 | |||
| 090a3a571b | |||
| f9d55fc425 | |||
| 4f57e8a5d1 | |||
| 1e6c9d935a | |||
| 00cfde169b | |||
| 02733ac718 | |||
| 55b55e62da | |||
| 5fccedd4c4 | |||
| b9ad78ec79 | |||
| 64ac6bcd1f | |||
| 45e4d80c4d | |||
| a5b1652d15 | |||
| f954eb7d88 | |||
| 53216813e5 | |||
| 1618203930 | |||
| 237a2ed426 | |||
| d33289503a | |||
| f5ff4c9725 | |||
| 62f932dcfc | |||
| b66112d0ca | |||
| b98354e63a | |||
| 94b3625718 | |||
| f7ee720281 | |||
| 4ab523bf01 | |||
| 2d4f1bfd02 | |||
| 38426c9143 | |||
| bdf151e0a7 | |||
| 9768b7888d | |||
| 740a48566f | |||
| 475cd1a106 | |||
| 38e7c39d69 | |||
| 774db6bead | |||
| a1a3e0412a | |||
| 0b39c89e60 | |||
| 53be4d8954 | |||
| 03812cc7eb | |||
| aa12b24293 | |||
| daf43009ba | |||
| 955d777ca5 | |||
| cc9472aa2f | |||
| e527f3cb1f | |||
| 3a375a8975 | |||
| 2698496592 | |||
| 0155d854e3 | |||
| c74cc8586f | |||
| 8eb89da9a0 | |||
| dee6ee3cef | |||
| beab89df09 | |||
| 5164d4ec32 | |||
| 878db851af | |||
| 686ff72ae0 | |||
| 2710d7098f | |||
| 7f41ff4035 | |||
| ed8d51014c | |||
| d09a51f47d | |||
| 59bae90454 | |||
| 13ee0ca94e | |||
| 5abc095050 | |||
| 7eb68c8388 | |||
| f69b644a77 | |||
| 6b93125ff2 | |||
| 43faef4569 | |||
| fe41d4c863 | |||
| 29830455ed | |||
| e50828093d | |||
| 880d29c5a9 | |||
| 77b2e9ba7a | |||
| 586fad7646 | |||
| fb636028fb | |||
| a8c3f8fc46 | |||
| 72f4227c5a | |||
| 8ccace8ef9 | |||
| 6d40c6dfe5 | |||
| 0b5562cdec | |||
| eeff0816f3 | |||
| f1f16dea3f | |||
| bfc6ef2049 | |||
| 5212de79d3 | |||
| b61c02e5df | |||
| f982954e8f | |||
| 3ba20e69ba | |||
| aea01fd893 | |||
| 950be14eca | |||
| 446deffc17 | |||
| e0863115ee | |||
| e34cb539d2 | |||
| d8ade8638a | |||
| 3067080474 | |||
| 886cc0f214 | |||
| 071d34b016 | |||
| a1564ca003 | |||
| 60f0e765c2 | |||
| 3f0ecea4bf | |||
| 2c9e6572c5 | |||
| 371a83f20f | |||
| b8cff1655a | |||
| 232856ca3a | |||
| 3f168ac6fd | |||
| c59cb1d0d3 | |||
| ec13df75d0 | |||
| 6fc02964ba | |||
| ed79e45680 | |||
| 1be983bf89 | |||
| b09d6a9d04 | |||
| db143d845d | |||
| 2e23501f9d | |||
| bd6addcd3a | |||
| 631e1fb604 | |||
| 30ee6726a8 | |||
| 1c397db9d8 | |||
| cc23ca80f4 | |||
| 449379a0ed | |||
| b3208b1c5b | |||
| 4df60b55a6 | |||
| 379553a1a5 | |||
| a2eaa5c7b5 | |||
| 175c46e68c | |||
| a58cc11079 | |||
| 218a375c27 | |||
| 567b1577c6 | |||
| 3c3687d11f | |||
| 19dfac8340 | |||
| b61feafe5a | |||
| 0c342c8b3e | |||
| dbcba8fad7 | |||
| b8053e20f2 | |||
| 1896901aa8 | |||
| 383c9132ed | |||
| 57b144c3e7 | |||
| eed5365fe0 | |||
| f5905568c4 | |||
| 096099470e | |||
| e7ed7aca3c | |||
| 6725b275b8 | |||
| 3447a7ef41 | |||
| 99f35fbea4 | |||
| 5c9a3912a9 | |||
| 5d43c0418c | |||
| 87c0076e12 | |||
| 95252ac697 | |||
| 5bb9f96701 | |||
| 750e9dfaa7 | |||
| f34f3c1661 | |||
| d4f83c978c | |||
| 212f280c19 | |||
| f3e2450636 | |||
| d6d496018d | |||
| 78be7fc772 | |||
| 6ebadd8469 | |||
| 557750c8d4 | |||
| e85ef27e6c | |||
| 4ca961a1b4 | |||
| 6a9110e9c1 | |||
| 51ffce09ae | |||
| 1c4e96b365 | |||
| 0db70e8edd | |||
| e46b3a5e19 | |||
| fdd3d4d85a | |||
| 37c9cba42e | |||
| c1544f66bb | |||
| d37f41f6a5 | |||
| b245dd2d51 | |||
| a1fcf11399 | |||
| 8f876da245 | |||
| 23b8e5a2b3 | |||
| 3b7e7c7192 | |||
| b7ecf6e2e0 | |||
| 2ec6aaff03 | |||
| 19f8553f2d | |||
| 05a64ff095 | |||
| a8fc78fcda | |||
| e0e8b40fa2 | |||
| 00165cd6ca | |||
| cd799ddfcd | |||
| fffd6b7c86 | |||
| 439b008a34 | |||
| f38e538892 | |||
| 6aa87a073f | |||
| c38198ccba | |||
| 3be88c8cbf | |||
| 558ced1afb | |||
| 0149e6935d | |||
| d97fdfd7c4 | |||
| 8b85d8c6fb | |||
| 673779490c | |||
| 48154e7e2d | |||
| 20f72b3f63 | |||
| e82c958af2 | |||
| 60c311ab9f | |||
| fbac81c245 | |||
| 9ca67d9228 | |||
| 5ffa18221f | |||
| aceb1f0f61 | |||
| cee5ca8873 | |||
| d961d4ab43 | |||
| 5205150a89 | |||
| 48e58cde5d | |||
| 033e91f8df | |||
| aab3705897 | |||
| d02efa81f2 | |||
| 95a8240da7 | |||
| dd0ddab610 | |||
| d23ac10f90 | |||
| ec18290b8a | |||
| 2c4cd39dc9 | |||
| 830bad0b85 | |||
| f14ef6fa15 | |||
| 7400b1c83d | |||
| e7caf39fba | |||
| 09fd0fb0ca | |||
| 72adb13c0f | |||
| ea0e382f82 | |||
| e70cba5143 | |||
| 8aec244c31 | |||
| 60e163164f | |||
| 86b9b5f3fa | |||
| 401a208767 | |||
| a1bfbda05b | |||
| 7d1f991ce4 | |||
| 1b10378f58 | |||
| 2bbb379994 | |||
| a835f119e1 | |||
| 91d8bac680 | |||
| 3db10a4ce8 | |||
| 590640645b | |||
| 7f02bfdf0c | |||
| e5cef0d9c0 | |||
| 85f9c33b2b | |||
| 148a430da4 | |||
| f7657679ac | |||
| f0479019c3 | |||
| a9a4ceaa78 | |||
| c55c905621 | |||
| 4db2289b7e | |||
| 93172ea1d0 | |||
| 2d935542e1 | |||
| f309ad7746 | |||
| a7ec1364f4 | |||
| eb71ced092 | |||
| 712ad0a73b | |||
| 48c0b137d5 | |||
| dfccfcc3e5 | |||
| 6abe667efb | |||
| c2472215ab | |||
| ac3c1e149c | |||
| cdf989427a | |||
| ebf129edd3 | |||
| 08c30f4baf | |||
| cf6bdc20ef | |||
| 3ece644af8 | |||
| 3991c82c91 | |||
| 9b635253f0 | |||
| b62f41336e | |||
| f7b777c79e | |||
| d18fa8e42a | |||
| 525c62ad26 | |||
| 4000a6a48c | |||
| 5b173ed4c4 | |||
| f56ad73565 | |||
| 003991c8c6 | |||
| e2a32afb80 | |||
| f305a69bb3 | |||
| 84e8babd9e | |||
| aeb46d9b54 | |||
| fafe0bd8e4 | |||
| 9a2ab45957 | |||
| 66978a8cdc | |||
| 1636012700 | |||
| 09206ae1e4 | |||
| 9188475746 | |||
| 34d158a632 | |||
| c06e6aa5ca | |||
| f4f670f048 | |||
| 778d742b6e | |||
| c8392b65b6 | |||
| c0ace9c2e5 | |||
| dfcab7dcbf | |||
| eb0870deb1 | |||
| 5b7ef34523 | |||
| 6ec728e466 | |||
| f12a562a08 | |||
| 17c4c95593 | |||
| 9b72c90944 | |||
| ec34da60a1 | |||
| daa4b6368a | |||
| 931a7a1a6c | |||
| 69d5790078 | |||
| 7571c18a55 | |||
| ff7ce9bdd0 | |||
| e5fc801899 | |||
| b362aa6813 | |||
| 652b961ac8 | |||
| 652713aec4 | |||
| 387b2f166b | |||
| 164b4a056a | |||
| 29e514fea6 | |||
| 310fff78c6 | |||
| f2efdc007c | |||
| b3be767923 | |||
| e86f2f3873 | |||
| 13d84f73d4 | |||
| e31342d3ba | |||
| daf0538bf3 | |||
| 451ce8b0c7 | |||
| b8cce14705 | |||
| bf1c9c650e | |||
| 8f6387536c | |||
| 56535ece11 | |||
| f1767719cb | |||
| c925b06114 | |||
| 402426884d | |||
| df6c8a5a75 | |||
| 99f5ae7125 | |||
| d50a1b7d07 | |||
| fab3bb76f7 | |||
| 5025c66bb2 | |||
| 800c153e96 | |||
| 71bbda0fb7 | |||
| 6e6bac429a | |||
| 1ce091a4d9 | |||
| a8f889be74 | |||
| 5f33c6bfee | |||
| 6a290c49d8 | |||
| b304d5d784 | |||
| cfe83b97d9 | |||
| 2fec2bf560 | |||
| 73dc1a7839 | |||
| 66fe951831 | |||
| 7991bcbf1a | |||
| de9516563a | |||
| 27fefb821c | |||
| c195894db9 | |||
| 6777b4d370 | |||
| 09269c22a2 | |||
| 2e24a2f079 | |||
| 5d9932dd61 | |||
| 062064213a | |||
| a2ae3ffb2b | |||
| 6cb4a0a3eb | |||
| f17c49091f | |||
| c16afc07df | |||
| 1616a96b2c | |||
| 261601230a | |||
| 453a38df54 | |||
| 5b004a849f | |||
| 29d811d3fd | |||
| 36c5739318 | |||
| b3f9c67d34 | |||
| bc8eb802f7 | |||
| a138eead74 | |||
| a700a0e1b1 | |||
| 205a33a241 | |||
| c88fd94c8b | |||
| a2b4e2e87c | |||
| 4a8f1e95ba | |||
| 3a847d921e | |||
| 806fdb9541 | |||
| cf1adbdb01 | |||
| 349d08e799 | |||
| d680c7ed83 | |||
| d4cb7a711b | |||
| bb6e19e7cd | |||
| 1c3ea53e63 | |||
| 88e17029c5 | |||
| 588e91b19f | |||
| 8cc2e7b6f1 | |||
| 222353b532 | |||
| b88b266fd5 | |||
| 60e6fb99af | |||
| 65b60e57b2 | |||
| 16a8402bf4 | |||
| 5896411136 | |||
| 0bb74a7885 | |||
| 86dfb9231f | |||
| 7198ce3eb0 | |||
| 08fecf1eb2 | |||
| 3eda26ca94 | |||
| d907914c7c | |||
| 266ab48fed | |||
| 3325cffa91 | |||
| 43469ac62a | |||
| a5c953fdb6 | |||
| 627c46e458 | |||
| 205eb34adc | |||
| 125e14d377 | |||
| a51c8a700b | |||
| 94e0400ea1 | |||
| 47c5b84093 | |||
| 8b1fbfd16d | |||
| cceb698899 | |||
| 01741df10d | |||
| f91ebf8baa | |||
| 4dde076030 | |||
| 3491001b7f | |||
| 2acec68649 | |||
| 51dab27374 | |||
| 145f5041bf | |||
| 6034505380 | |||
| 8533d74906 | |||
| b2ae57b982 | |||
| 49ffe9bec9 | |||
| fe5d92674e | |||
| 197d28f5c7 | |||
| cd48bb0789 | |||
| 90fc411e9a | |||
| c22b6a84aa | |||
| 9b65642f05 | |||
| 83547dce9c | |||
| efeecceb54 | |||
| ba9b5a40d2 | |||
| 47b5bda277 | |||
| a343b6b1b6 | |||
| 0fe48d3003 | |||
| 23e3760b08 | |||
| 3d31905562 | |||
| 9638c5266b | |||
| ad7ce9f55a | |||
| b0baf3b85a | |||
| d4d3687882 | |||
| faf55ca191 | |||
| d5096a23fb | |||
| ed5841d201 | |||
| bbfc095a00 | |||
| 0fcb68a13d | |||
| f97744c098 | |||
| d1cfa8d27a | |||
| 218dcf25c1 | |||
| 06e06973a4 | |||
| 6f73cfc5f2 | |||
| 6db5bbeaee | |||
| 6ef5077164 | |||
| 45e1ed7022 | |||
| c14b4535a6 | |||
| 411631d2f8 | |||
| f4c3690bd8 | |||
| 56fdea6b5d | |||
| 8a5c053d39 | |||
| 42870cfa23 | |||
| 6cf256cc05 | |||
| 9fec915f62 | |||
| f1d5ab73cd | |||
| cd62972945 | |||
| 998d09170c | |||
| 4ba57181ec | |||
| 8b9d8bdc62 | |||
| 4291d42dc0 | |||
| 79fcc1ce40 | |||
| bfc6778dca | |||
| 701e57c264 | |||
| 163d025c0d | |||
| d9befc6d8c | |||
| 9e50a4c241 | |||
| 9b0cae3794 | |||
| 6160dfb2f7 | |||
| cd013cdb06 | |||
| 26cc7c90e9 | |||
| f28ac3cf22 | |||
| 58fec4b082 | |||
| b91805a5df | |||
| 0fa0df1bdf | |||
| 3f7cacee3e | |||
| 72637fd650 | |||
| aba1284f8e | |||
| 179e1dc9e5 | |||
| 75879a494e | |||
| 73b1ea4713 | |||
| 55dc991c13 | |||
| c30316588a | |||
| db5d6e7481 | |||
| f8d52f58d4 | |||
| 227ee499e4 | |||
| dcdaf6a674 | |||
| d524ba3a37 | |||
| da5e288476 | |||
| baad7cd60d | |||
| e9d6fc33fd | |||
| c2fa0899e9 | |||
| 2dc09ec1f2 | |||
| fba640976f | |||
| 8e7df61a73 | |||
| 41776cf2df | |||
| 23983f0b75 | |||
| 84b457ede5 | |||
| a906e0bf0c | |||
| 3db1aad96a | |||
| 9c909e7a2c | |||
| ad2ef7cb33 | |||
| c851510ca9 | |||
| 71a21c2059 | |||
| d90537eb8d | |||
| 25e9888438 |
@@ -0,0 +1,3 @@
|
||||
github: kennethreitz
|
||||
thanks_dev: kennethreitz
|
||||
custom: https://cash.app/$KennethReitz
|
||||
@@ -0,0 +1,16 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
@@ -0,0 +1,54 @@
|
||||
name: "Documentation"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request: ~
|
||||
workflow_dispatch:
|
||||
|
||||
# Cancel redundant in-progress jobs.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
documentation:
|
||||
name: "Documentation: Python ${{ matrix.python-version }} on ${{ matrix.os }}"
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ["ubuntu-latest"]
|
||||
python-version: ["3.13"]
|
||||
env:
|
||||
UV_SYSTEM_PYTHON: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
version: "latest"
|
||||
enable-cache: true
|
||||
cache-suffix: ${{ matrix.python-version }}
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
|
||||
- name: Install package and documentation dependencies
|
||||
run: |
|
||||
uv pip install '.[develop,docs]'
|
||||
|
||||
- name: Run link checker
|
||||
run: |
|
||||
poe docs-linkcheck
|
||||
|
||||
- name: Build static HTML documentation
|
||||
run: |
|
||||
poe docs-html
|
||||
@@ -0,0 +1,55 @@
|
||||
name: "Tests"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request: ~
|
||||
workflow_dispatch:
|
||||
|
||||
# Cancel redundant in-progress jobs.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
test:
|
||||
name: "Python ${{ matrix.python-version }}"
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: [
|
||||
"3.9",
|
||||
"3.10",
|
||||
"3.11",
|
||||
"3.12",
|
||||
"3.13",
|
||||
]
|
||||
env:
|
||||
UV_SYSTEM_PYTHON: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
version: "latest"
|
||||
enable-cache: true
|
||||
cache-suffix: ${{ matrix.python-version }}
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
|
||||
- name: Install and validate package
|
||||
run: |
|
||||
uv pip install '.[develop,test]'
|
||||
poe check
|
||||
+15
@@ -1,4 +1,19 @@
|
||||
.venv*
|
||||
.vscode/
|
||||
.cache
|
||||
.idea
|
||||
.python-version
|
||||
.coverage
|
||||
.pytest_cache
|
||||
.DS_Store
|
||||
coverage.xml
|
||||
.coverage*
|
||||
|
||||
__pycache__
|
||||
tests/__pycache__
|
||||
|
||||
build
|
||||
responder.egg-info/
|
||||
dist/
|
||||
app.py
|
||||
app2.py
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# .readthedocs.yml
|
||||
# Read the Docs configuration file
|
||||
|
||||
# Details
|
||||
# - https://docs.readthedocs.io/en/stable/config-file/v2.html
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
build:
|
||||
os: "ubuntu-24.04"
|
||||
tools:
|
||||
python: "3.12"
|
||||
|
||||
python:
|
||||
install:
|
||||
- method: pip
|
||||
path: .
|
||||
extra_requirements:
|
||||
- docs
|
||||
|
||||
sphinx:
|
||||
configuration: docs/source/conf.py
|
||||
|
||||
# Use standard HTML builder.
|
||||
builder: html
|
||||
|
||||
# Fail on all warnings to avoid broken references.
|
||||
fail_on_warning: true
|
||||
|
||||
# Optionally build your docs in additional formats such as PDF
|
||||
#formats:
|
||||
# - pdf
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
language: python
|
||||
python:
|
||||
- "3.6"
|
||||
- "3.7"
|
||||
|
||||
# command to install dependencies
|
||||
install:
|
||||
- "pip install pipenv --upgrade-strategy=only-if-needed"
|
||||
- "pipenv install --dev"
|
||||
|
||||
# command to run the dependencies
|
||||
script:
|
||||
- "pytest"
|
||||
+422
@@ -0,0 +1,422 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and
|
||||
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v3.0.0] - 2026-03-22
|
||||
|
||||
### Added
|
||||
|
||||
- Platform: Added support for Python 3.10 - Python 3.13
|
||||
- CLI: `responder run` now also accepts a filesystem path on its `<target>`
|
||||
argument, enabling usage on single-file applications.
|
||||
- CLI: `responder run` now also accepts URLs.
|
||||
|
||||
### Changed
|
||||
|
||||
- Platform: Minimum Python version is now 3.9 (dropped 3.6, 3.7, 3.8)
|
||||
- Dependencies: Dramatically reduced core dependency count (10 → 5)
|
||||
- Removed `requests`, `requests-toolbelt`, `rfc3986`, `whitenoise`
|
||||
- Moved `apispec` and `marshmallow` to `openapi` optional extra
|
||||
- Replaced `rfc3986` with stdlib `urllib.parse`
|
||||
- Replaced `requests-toolbelt` multipart decoder with `python-multipart`
|
||||
- Replaced deprecated `starlette.middleware.wsgi` with `a2wsgi`
|
||||
- Switched from WhiteNoise to ServeStatic
|
||||
- Dependencies: Pinned `starlette[full]>=0.40` (was unpinned)
|
||||
- GraphQL: Upgraded to `graphene>=3` and `graphql-core>=3.1`
|
||||
(from `graphene<3` and `graphql-server-core`, which is unmaintained)
|
||||
- GraphQL: Updated GraphiQL UI from 0.12.0 (2018) to 3.0.6 with React 18
|
||||
- Extensions: All of CLI-, GraphQL-, and OpenAPI-Support modules are
|
||||
extensions now, found within the `responder.ext` module namespace.
|
||||
- Packaging: Migrated from `setup.py` to declarative `pyproject.toml`
|
||||
|
||||
### Removed
|
||||
|
||||
- Platform: Removed support for EOL Python 3.6, 3.7, 3.8
|
||||
- Status codes: Removed deprecated `resume_incomplete` and `resume`
|
||||
aliases for HTTP 308 (marked for removal in 3.0)
|
||||
- CLI: `responder run --build` ceased to exist
|
||||
|
||||
### Fixed
|
||||
|
||||
- Routing: Fixed dispatching `static_route=None` on Windows
|
||||
- uvicorn: `--debug` now maps to uvicorn's `log_level = "debug"`
|
||||
- Tests: Fixed deprecated httpx TestClient usage
|
||||
|
||||
## [v2.0.5] - 2019-12-15
|
||||
|
||||
### Added
|
||||
|
||||
- Update requirements to support python 3.8
|
||||
|
||||
## [v2.0.4] - 2019-11-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix static app resolving
|
||||
|
||||
## [v2.0.3] - 2019-09-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix template conflicts
|
||||
|
||||
## [v2.0.2] - 2019-09-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix template conflicts
|
||||
|
||||
## [v2.0.1] - 2019-09-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix template import
|
||||
|
||||
## [v2.0.0] - 2019-09-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactor Router and Schema
|
||||
|
||||
## [v1.3.2] - 2019-08-15
|
||||
|
||||
### Added
|
||||
|
||||
- ASGI 3 support
|
||||
- CI tests for python 3.8-dev
|
||||
- Now requests have `state` a mapping object
|
||||
|
||||
### Deprecated
|
||||
|
||||
- ASGI 2
|
||||
|
||||
## [v1.3.1] - 2019-04-28
|
||||
|
||||
### Added
|
||||
|
||||
- Route params Converters
|
||||
- Add search for documentation pages
|
||||
|
||||
### Changed
|
||||
|
||||
- Bump dependencies
|
||||
|
||||
## [v1.3.0] - 2019-02-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- Versioning issue
|
||||
- Multiple cookies.
|
||||
- Whitenoise returns not found.
|
||||
- Other bugfixes.
|
||||
|
||||
### Added
|
||||
|
||||
- Stream support via `resp.stream`.
|
||||
- Cookie directives via `resp.set_cookie`.
|
||||
- Add `resp.html` to send HTML.
|
||||
- Other improvements.
|
||||
|
||||
## [v1.1.3] - 2019-01-12
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactor `_route_for`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Resolve startup/shutdwown events
|
||||
|
||||
## [v1.2.0] - 2018-12-29
|
||||
|
||||
### Added
|
||||
|
||||
- Documentations
|
||||
|
||||
### Changed
|
||||
|
||||
- Use Starlette's LifeSpan middleware
|
||||
- Update denpendencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix route.is_class_based
|
||||
- Fix test_500
|
||||
- Typos
|
||||
|
||||
## [v1.1.2] - 2018-11-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- Minor fixes for Open API
|
||||
- Typos
|
||||
|
||||
## [v1.1.1] - 2018-10-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Run sync views in a threadpoolexecutor.
|
||||
|
||||
## [v1.1.0] - 2018-10-27
|
||||
|
||||
### Added
|
||||
|
||||
- Support for `before_request`.
|
||||
|
||||
## [v1.0.5]- 2018-10-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix sessions.
|
||||
|
||||
## [v1.0.4] - 2018-10-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Potential bufix for cookies.
|
||||
|
||||
## [v1.0.3] - 2018-10-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bugfix for redirects.
|
||||
|
||||
## [v1.0.2] - 2018-10-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Improvement for static file hosting.
|
||||
|
||||
## [v1.0.1] - 2018-10-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Improve cors configuration settings.
|
||||
|
||||
## [v1.0.0] - 2018-10-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Move GraphQL support into a built-in plugin.
|
||||
|
||||
## [v0.3.3] - 2018-10-25
|
||||
|
||||
### Added
|
||||
|
||||
- CORS support
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved exceptions.
|
||||
|
||||
## [v0.3.2] - 2018-10-25
|
||||
|
||||
### Changed
|
||||
|
||||
- Subtle improvements.
|
||||
|
||||
## [v0.3.1] - 2018-10-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- Packaging fix.
|
||||
|
||||
## [v0.3.0] - 2018-10-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Interactive Documentation endpoint.
|
||||
- Minor improvements.
|
||||
|
||||
## [v0.2.3] - 2018-10-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Overall improvements.
|
||||
|
||||
## [v0.2.2] - 2018-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- Show traceback info when background tasks raise exceptions.
|
||||
|
||||
## [v0.2.1] - 2018-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- api.requests.
|
||||
|
||||
## [v0.2.0] - 2018-10-22
|
||||
|
||||
### Added
|
||||
|
||||
- WebSocket support.
|
||||
|
||||
## [v0.1.6] - 2018-10-20
|
||||
|
||||
### Added
|
||||
|
||||
- 500 support.
|
||||
|
||||
## [v0.1.5] - 2018-10-20
|
||||
|
||||
### Added
|
||||
|
||||
- File upload support
|
||||
|
||||
### Changed
|
||||
|
||||
- Improvements to sequential media reading.
|
||||
|
||||
## [v0.1.4] - 2018-10-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stability.
|
||||
|
||||
## [v0.1.3] - 2018-10-18
|
||||
|
||||
### Added
|
||||
|
||||
- Sessions support.
|
||||
|
||||
## [v0.1.2] - 2018-10-18
|
||||
|
||||
### Added
|
||||
|
||||
- Cookies support.
|
||||
|
||||
## [v0.1.1] - 2018-10-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Default routes.
|
||||
|
||||
## [v0.1.0] - 2018-10-17
|
||||
|
||||
### Added
|
||||
|
||||
- Prototype of static application support.
|
||||
|
||||
## [v0.0.10] - 2018-10-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bugfix for async class-based views.
|
||||
|
||||
## [v0.0.9] - 2018-10-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bugfix for async class-based views.
|
||||
|
||||
## [v0.0.8] - 2018-10-17
|
||||
|
||||
### Added
|
||||
|
||||
- GraphiQL Support.
|
||||
|
||||
### Changed
|
||||
|
||||
- Improvement to route selection.
|
||||
|
||||
## [v0.0.7] - 2018-10-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Immutable Request object.
|
||||
|
||||
## [v0.0.6] - 2018-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Ability to mount WSGI apps.
|
||||
- Supply content-type when serving up the schema.
|
||||
|
||||
## [v0.0.5] - 2018-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- OpenAPI Schema support.
|
||||
- Safe load/dump yaml.
|
||||
|
||||
## [v0.0.4] - 2018-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Asynchronous support for data uploads.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bug fixes.
|
||||
|
||||
## [v0.0.3] - 2018-10-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bug fixes.
|
||||
|
||||
## [v0.0.2] - 2018-10-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Switch to ASGI/Starlette.
|
||||
|
||||
## [v0.0.1] - 2018-10-12
|
||||
|
||||
### Added
|
||||
|
||||
- Conception!
|
||||
|
||||
[unreleased]: https://github.com/kennethreitz/responder/compare/v3.0.0..HEAD
|
||||
[v3.0.0]: https://github.com/kennethreitz/responder/compare/v2.0.5..v3.0.0
|
||||
[v2.0.5]: https://github.com/kennethreitz/responder/compare/v2.0.4..v2.0.5
|
||||
[v2.0.4]: https://github.com/kennethreitz/responder/compare/v2.0.3..v2.0.4
|
||||
[v2.0.3]: https://github.com/kennethreitz/responder/compare/v2.0.2..v2.0.3
|
||||
[v2.0.2]: https://github.com/kennethreitz/responder/compare/v2.0.1..v2.0.2
|
||||
[v2.0.1]: https://github.com/kennethreitz/responder/compare/v2.0.0..v2.0.1
|
||||
[v2.0.0]: https://github.com/kennethreitz/responder/compare/v1.3.2..v2.0.0
|
||||
[v1.3.2]: https://github.com/kennethreitz/responder/compare/v1.3.1..v1.3.2
|
||||
[v1.3.1]: https://github.com/kennethreitz/responder/compare/v1.3.0..v1.3.1
|
||||
[v1.3.0]: https://github.com/kennethreitz/responder/compare/v1.2.0..v1.3.0
|
||||
[v1.2.0]: https://github.com/kennethreitz/responder/compare/v1.1.3..v1.2.0
|
||||
[v1.1.3]: https://github.com/kennethreitz/responder/compare/v1.1.2..v1.1.3
|
||||
[v1.1.2]: https://github.com/kennethreitz/responder/compare/v1.1.1..v1.1.2
|
||||
[v1.1.1]: https://github.com/kennethreitz/responder/compare/v1.1.0..v1.1.1
|
||||
[v1.1.0]: https://github.com/kennethreitz/responder/compare/v1.0.5..v1.1.0
|
||||
[v1.0.5]: https://github.com/kennethreitz/responder/compare/v1.0.4..v1.0.5
|
||||
[v1.0.4]: https://github.com/kennethreitz/responder/compare/v1.0.3..v1.0.4
|
||||
[v1.0.3]: https://github.com/kennethreitz/responder/compare/v1.0.2..v1.0.3
|
||||
[v1.0.2]: https://github.com/kennethreitz/responder/compare/v1.0.1..v1.0.2
|
||||
[v1.0.1]: https://github.com/kennethreitz/responder/compare/v1.0.0..v1.0.1
|
||||
[v1.0.0]: https://github.com/kennethreitz/responder/compare/v0.3.3..v1.0.0
|
||||
[v0.3.3]: https://github.com/kennethreitz/responder/compare/v0.3.2..v0.3.3
|
||||
[v0.3.2]: https://github.com/kennethreitz/responder/compare/v0.3.1..v0.3.2
|
||||
[v0.3.1]: https://github.com/kennethreitz/responder/compare/v0.3.0..v0.3.1
|
||||
[v0.3.0]: https://github.com/kennethreitz/responder/compare/v0.2.3..v0.3.0
|
||||
[v0.2.3]: https://github.com/kennethreitz/responder/compare/v0.2.2..v0.2.3
|
||||
[v0.2.2]: https://github.com/kennethreitz/responder/compare/v0.2.1..v0.2.2
|
||||
[v0.2.1]: https://github.com/kennethreitz/responder/compare/v0.2.0..v0.2.1
|
||||
[v0.2.0]: https://github.com/kennethreitz/responder/compare/v0.1.6..v0.2.0
|
||||
[v0.1.6]: https://github.com/kennethreitz/responder/compare/v0.1.5..v0.1.6
|
||||
[v0.1.5]: https://github.com/kennethreitz/responder/compare/v0.1.4..v0.1.5
|
||||
[v0.1.4]: https://github.com/kennethreitz/responder/compare/v0.1.3..v0.1.4
|
||||
[v0.1.3]: https://github.com/kennethreitz/responder/compare/v0.1.2..v0.1.3
|
||||
[v0.1.2]: https://github.com/kennethreitz/responder/compare/v0.1.1..v0.1.2
|
||||
[v0.1.1]: https://github.com/kennethreitz/responder/compare/v0.1.0..v0.1.1
|
||||
[v0.1.0]: https://github.com/kennethreitz/responder/compare/v0.0.10..v0.1.0
|
||||
[v0.0.10]: https://github.com/kennethreitz/responder/compare/v0.0.9..v0.0.10
|
||||
[v0.0.9]: https://github.com/kennethreitz/responder/compare/v0.0.8..v0.0.9
|
||||
[v0.0.8]: https://github.com/kennethreitz/responder/compare/v0.0.7..v0.0.8
|
||||
[v0.0.7]: https://github.com/kennethreitz/responder/compare/v0.0.6..v0.0.7
|
||||
[v0.0.6]: https://github.com/kennethreitz/responder/compare/v0.0.5..v0.0.6
|
||||
[v0.0.5]: https://github.com/kennethreitz/responder/compare/v0.0.4..v0.0.5
|
||||
[v0.0.4]: https://github.com/kennethreitz/responder/compare/v0.0.3..v0.0.4
|
||||
[v0.0.3]: https://github.com/kennethreitz/responder/compare/v0.0.2..v0.0.3
|
||||
[v0.0.2]: https://github.com/kennethreitz/responder/compare/v0.0.1..v0.0.2
|
||||
[v0.0.1]: https://github.com/kennethreitz/responder/compare/v0.0.0..v0.0.1
|
||||
@@ -1,4 +0,0 @@
|
||||
- Kenneth Reitz (primary)
|
||||
- Tom Christie
|
||||
- Bruno Oliveira
|
||||
- serhii73
|
||||
@@ -0,0 +1,178 @@
|
||||
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
@@ -1,22 +0,0 @@
|
||||
[[source]]
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
responder = {editable = true, path = "."}
|
||||
uvicorn = "*"
|
||||
|
||||
[dev-packages]
|
||||
pytest = "*"
|
||||
"flake8" = "*"
|
||||
black = "*"
|
||||
twine = "*"
|
||||
flask = "*"
|
||||
sphinx = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.7"
|
||||
|
||||
[pipenv]
|
||||
allow_prereleases = true
|
||||
Generated
-615
@@ -1,615 +0,0 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "76d2978ee90d2c028b13c9a5abdd2371d74d514045d50fb9b92aec44e72054b3"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
"python_version": "3.7"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"name": "pypi",
|
||||
"url": "https://pypi.org/simple",
|
||||
"verify_ssl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"aniso8601": {
|
||||
"hashes": [
|
||||
"sha256:7849749cf00ae0680ad2bdfe4419c7a662bef19c03691a19e008c8b9a5267802",
|
||||
"sha256:94f90871fcd314a458a3d4eca1c84448efbd200e86f55fe4c733c7a40149ef50"
|
||||
],
|
||||
"version": "==3.0.2"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:376690d6f16d32f9d1fe8932551d80b23e9d393a8578c5633a2ed39a64861638",
|
||||
"sha256:456048c7e371c089d0a77a5212fb37a2c2dce1e24146e3b7e0261736aaeaa22a"
|
||||
],
|
||||
"version": "==2018.8.24"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
|
||||
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
|
||||
],
|
||||
"version": "==3.0.4"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
|
||||
"sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
|
||||
],
|
||||
"version": "==7.0"
|
||||
},
|
||||
"graphene": {
|
||||
"hashes": [
|
||||
"sha256:b8ec446d17fa68721636eaad3d6adc1a378cb6323e219814c8f98c9928fc9642",
|
||||
"sha256:faa26573b598b22ffd274e2fd7a4c52efa405dcca96e01a62239482246248aa3"
|
||||
],
|
||||
"version": "==2.1.3"
|
||||
},
|
||||
"graphql-core": {
|
||||
"hashes": [
|
||||
"sha256:889e869be5574d02af77baf1f30b5db9ca2959f1c9f5be7b2863ead5a3ec6181",
|
||||
"sha256:9462e22e32c7f03b667373ec0a84d95fba10e8ce2ead08f29fbddc63b671b0c1"
|
||||
],
|
||||
"version": "==2.1"
|
||||
},
|
||||
"graphql-relay": {
|
||||
"hashes": [
|
||||
"sha256:2716b7245d97091af21abf096fabafac576905096d21ba7118fba722596f65db"
|
||||
],
|
||||
"version": "==0.4.5"
|
||||
},
|
||||
"graphql-server-core": {
|
||||
"hashes": [
|
||||
"sha256:e5f82add4b3d5580aa1f1e7d9f00e944ad3abe1b65eb337e611d6a77cc20f231"
|
||||
],
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"h11": {
|
||||
"hashes": [
|
||||
"sha256:acca6a44cb52a32ab442b1779adf0875c443c689e9e028f8d831a3769f9c5208",
|
||||
"sha256:f2b1ca39bfed357d1f19ac732913d5f9faa54a5062eca7d2ec3a916cfb7ae4c7"
|
||||
],
|
||||
"version": "==0.8.1"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e",
|
||||
"sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"
|
||||
],
|
||||
"version": "==2.7"
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
"sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd",
|
||||
"sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4"
|
||||
],
|
||||
"version": "==2.10"
|
||||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
"sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665"
|
||||
],
|
||||
"version": "==1.0"
|
||||
},
|
||||
"parse": {
|
||||
"hashes": [
|
||||
"sha256:9dd6048ea212cd032a342f9f6aa2b7bc222f7407c7e37bdc2777fecd36897437"
|
||||
],
|
||||
"version": "==1.9.0"
|
||||
},
|
||||
"promise": {
|
||||
"hashes": [
|
||||
"sha256:2ebbfc10b7abf6354403ed785fe4f04b9dfd421eb1a474ac8d187022228332af",
|
||||
"sha256:348f5f6c3edd4fd47c9cd65aed03ac1b31136d375aa63871a57d3e444c85655c"
|
||||
],
|
||||
"version": "==2.2.1"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
"sha256:254bf6fda2b7c651837acb2c718e213df29d531eebf00edb54743d10bcb694eb",
|
||||
"sha256:3108529b78577327d15eec243f0ff348a0640b0c3478d67ad7f5648f93bac3e2",
|
||||
"sha256:3c17fb92c8ba2f525e4b5f7941d850e7a48c3a59b32d331e2502a3cdc6648e76",
|
||||
"sha256:8d6d96001aa7f0a6a4a95e8143225b5d06e41b1131044913fecb8f85a125714b",
|
||||
"sha256:c8a88edd93ee29ede719080b2be6cb2333dfee1dccba213b422a9c8e97f2967b"
|
||||
],
|
||||
"version": "==4.2b4"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1",
|
||||
"sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a"
|
||||
],
|
||||
"version": "==2.19.1"
|
||||
},
|
||||
"requests-wsgi-adapter": {
|
||||
"hashes": [
|
||||
"sha256:7080c98ae2614b8d0b7339b611d97a535470d2fb479731f7d588d5f8108ea134"
|
||||
],
|
||||
"version": "==0.4.0"
|
||||
},
|
||||
"responder": {
|
||||
"editable": true,
|
||||
"path": "."
|
||||
},
|
||||
"rx": {
|
||||
"hashes": [
|
||||
"sha256:13a1d8d9e252625c173dc795471e614eadfe1cf40ffc684e08b8fff0d9748c23",
|
||||
"sha256:7357592bc7e881a95e0c2013b73326f704953301ab551fbc8133a6fadab84105"
|
||||
],
|
||||
"version": "==1.6.1"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
|
||||
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
|
||||
],
|
||||
"version": "==1.11.0"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf",
|
||||
"sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5"
|
||||
],
|
||||
"version": "==1.23"
|
||||
},
|
||||
"uvicorn": {
|
||||
"hashes": [
|
||||
"sha256:8de03999a936d8704f07cc3b1d3a3edb6922a068b64d84b4f5e49604c8b70a11"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.3.12"
|
||||
},
|
||||
"waitress": {
|
||||
"hashes": [
|
||||
"sha256:40b0f297a7f3af61fbfbdc67e59090c70dc150a1601c39ecc9f5f1d283fb931b",
|
||||
"sha256:d33cd3d62426c0f1b3cd84ee3d65779c7003aae3fc060dee60524d10a57f05a9"
|
||||
],
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"websockets": {
|
||||
"hashes": [
|
||||
"sha256:0e2f7d6567838369af074f0ef4d0b802d19fa1fee135d864acc656ceefa33136",
|
||||
"sha256:2a16dac282b2fdae75178d0ed3d5b9bc3258dabfae50196cbb30578d84b6f6a6",
|
||||
"sha256:5a1fa6072405648cb5b3688e9ed3b94be683ce4a4e5723e6f5d34859dee495c1",
|
||||
"sha256:5c1f55a1274df9d6a37553fef8cff2958515438c58920897675c9bc70f5a0538",
|
||||
"sha256:669d1e46f165e0ad152ed8197f7edead22854a6c90419f544e0f234cc9dac6c4",
|
||||
"sha256:695e34c4dbea18d09ab2c258994a8bf6a09564e762655408241f6a14592d2908",
|
||||
"sha256:6b2e03d69afa8d20253455e67b64de1a82ff8612db105113cccec35d3f8429f0",
|
||||
"sha256:79ca7cdda7ad4e3663ea3c43bfa8637fc5d5604c7737f19a8964781abbd1148d",
|
||||
"sha256:7fd2dd9a856f72e6ed06f82facfce01d119b88457cd4b47b7ae501e8e11eba9c",
|
||||
"sha256:82c0354ac39379d836719a77ee360ef865377aa6fdead87909d50248d0f05f4d",
|
||||
"sha256:8f3b956d11c5b301206382726210dc1d3bee1a9ccf7aadf895aaf31f71c3716c",
|
||||
"sha256:91ec98640220ae05b34b79ee88abf27f97ef7c61cf525eec57ea8fcea9f7dddb",
|
||||
"sha256:952be9540d83dba815569d5cb5f31708801e0bbfc3a8c5aef1890b57ed7e58bf",
|
||||
"sha256:99ac266af38ba1b1fe13975aea01ac0e14bb5f3a3200d2c69f05385768b8568e",
|
||||
"sha256:9fa122e7adb24232247f8a89f2d9070bf64b7869daf93ac5e19546b409e47e96",
|
||||
"sha256:a0873eadc4b8ca93e2e848d490809e0123eea154aa44ecd0109c4d0171869584",
|
||||
"sha256:cb998bd4d93af46b8b49ecf5a72c0a98e5cc6d57fdca6527ba78ad89d6606484",
|
||||
"sha256:e02e57346f6a68523e3c43bbdf35dde5c440318d1f827208ae455f6a2ace446d",
|
||||
"sha256:e79a5a896bcee7fff24a788d72e5c69f13e61369d055f28113e71945a7eb1559",
|
||||
"sha256:ee55eb6bcf23ecc975e6b47c127c201b913598f38b6a300075f84eeef2d3baff",
|
||||
"sha256:f1414e6cbcea8d22843e7eafdfdfae3dd1aba41d1945f6ca66e4806c07c4f454"
|
||||
],
|
||||
"version": "==6.0"
|
||||
},
|
||||
"werkzeug": {
|
||||
"hashes": [
|
||||
"sha256:c3fd7a7d41976d9f44db327260e263132466836cef6f91512889ed60ad26557c",
|
||||
"sha256:d5da73735293558eb1651ee2fddc4d0dedcfa06538b8813a2e20011583c9e49b"
|
||||
],
|
||||
"version": "==0.14.1"
|
||||
},
|
||||
"whitenoise": {
|
||||
"hashes": [
|
||||
"sha256:133a92ff0ab8fb9509f77d4f7d0de493eca19c6fea973f4195d4184f888f2e02",
|
||||
"sha256:32b57d193478908a48acb66bf73e7a3c18679263e3e64bfebcfac1144a430039"
|
||||
],
|
||||
"version": "==4.1"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
"alabaster": {
|
||||
"hashes": [
|
||||
"sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359",
|
||||
"sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"
|
||||
],
|
||||
"version": "==0.7.12"
|
||||
},
|
||||
"appdirs": {
|
||||
"hashes": [
|
||||
"sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92",
|
||||
"sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"
|
||||
],
|
||||
"version": "==1.4.3"
|
||||
},
|
||||
"atomicwrites": {
|
||||
"hashes": [
|
||||
"sha256:0312ad34fcad8fac3704d441f7b317e50af620823353ec657a53e981f92920c0",
|
||||
"sha256:ec9ae8adaae229e4f8446952d204a3e4b5fdd2d099f9be3aaf556120135fb3ee"
|
||||
],
|
||||
"version": "==1.2.1"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69",
|
||||
"sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb"
|
||||
],
|
||||
"version": "==18.2.0"
|
||||
},
|
||||
"babel": {
|
||||
"hashes": [
|
||||
"sha256:6778d85147d5d85345c14a26aada5e478ab04e39b078b0745ee6870c2b5cf669",
|
||||
"sha256:8cba50f48c529ca3fa18cf81fa9403be176d374ac4d60738b839122dfaaa3d23"
|
||||
],
|
||||
"version": "==2.6.0"
|
||||
},
|
||||
"black": {
|
||||
"hashes": [
|
||||
"sha256:817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739",
|
||||
"sha256:e030a9a28f542debc08acceb273f228ac422798e5215ba2a791a6ddeaaca22a5"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==18.9b0"
|
||||
},
|
||||
"bleach": {
|
||||
"hashes": [
|
||||
"sha256:48d39675b80a75f6d1c3bdbffec791cf0bbbab665cf01e20da701c77de278718",
|
||||
"sha256:73d26f018af5d5adcdabf5c1c974add4361a9c76af215fe32fdec8a6fc5fb9b9"
|
||||
],
|
||||
"version": "==3.0.2"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:376690d6f16d32f9d1fe8932551d80b23e9d393a8578c5633a2ed39a64861638",
|
||||
"sha256:456048c7e371c089d0a77a5212fb37a2c2dce1e24146e3b7e0261736aaeaa22a"
|
||||
],
|
||||
"version": "==2018.8.24"
|
||||
},
|
||||
"cffi": {
|
||||
"hashes": [
|
||||
"sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743",
|
||||
"sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef",
|
||||
"sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50",
|
||||
"sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f",
|
||||
"sha256:3bb6bd7266598f318063e584378b8e27c67de998a43362e8fce664c54ee52d30",
|
||||
"sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93",
|
||||
"sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257",
|
||||
"sha256:495c5c2d43bf6cebe0178eb3e88f9c4aa48d8934aa6e3cddb865c058da76756b",
|
||||
"sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3",
|
||||
"sha256:57b2533356cb2d8fac1555815929f7f5f14d68ac77b085d2326b571310f34f6e",
|
||||
"sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc",
|
||||
"sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04",
|
||||
"sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6",
|
||||
"sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359",
|
||||
"sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596",
|
||||
"sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b",
|
||||
"sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd",
|
||||
"sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95",
|
||||
"sha256:a6a5cb8809091ec9ac03edde9304b3ad82ad4466333432b16d78ef40e0cce0d5",
|
||||
"sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e",
|
||||
"sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6",
|
||||
"sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca",
|
||||
"sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31",
|
||||
"sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1",
|
||||
"sha256:ca1bd81f40adc59011f58159e4aa6445fc585a32bb8ac9badf7a2c1aa23822f2",
|
||||
"sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085",
|
||||
"sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801",
|
||||
"sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4",
|
||||
"sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184",
|
||||
"sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917",
|
||||
"sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f",
|
||||
"sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb"
|
||||
],
|
||||
"version": "==1.11.5"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
|
||||
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
|
||||
],
|
||||
"version": "==3.0.4"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
|
||||
"sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
|
||||
],
|
||||
"version": "==7.0"
|
||||
},
|
||||
"cmarkgfm": {
|
||||
"hashes": [
|
||||
"sha256:0186dccca79483e3405217993b83b914ba4559fe9a8396efc4eea56561b74061",
|
||||
"sha256:1a625afc6f62da428df96ec325dc30866cc5781520cbd904ff4ec44cf018171c",
|
||||
"sha256:207b7673ff4e177374c572feeae0e4ef33be620ec9171c08fd22e2b796e03e3d",
|
||||
"sha256:275905bb371a99285c74931700db3f0c078e7603bed383e8cf1a09f3ee05a3de",
|
||||
"sha256:50098f1c4950722521f0671e54139e0edc1837d63c990cf0f3d2c49607bb51a2",
|
||||
"sha256:50ed116d0b60a07df0dc7b180c28569064b9d37d1578d4c9021cff04d725cb63",
|
||||
"sha256:61a72def110eed903cd1848245897bcb80d295cd9d13944d4f9f30cba5b76655",
|
||||
"sha256:64186fb75d973a06df0e6ea12879533b71f6e7ba1ab01ffee7fc3e7534758889",
|
||||
"sha256:665303d34d7f14f10d7b0651082f25ebf7107f29ef3d699490cac16cdc0fc8ce",
|
||||
"sha256:70b18f843aec58e4e64aadce48a897fe7c50426718b7753aaee399e72df64190",
|
||||
"sha256:761ee7b04d1caee2931344ac6bfebf37102ffb203b136b676b0a71a3f0ea3c87",
|
||||
"sha256:811527e9b7280b136734ed6cb6845e5fbccaeaa132ddf45f0246cbe544016957",
|
||||
"sha256:987b0e157f70c72a84f3c2f9ef2d7ab0f26c08f2bf326c12c087ff9eebcb3ff5",
|
||||
"sha256:9fc6a2183d0a9b0974ec7cdcdad42bd78a3be674cc3e65f87dd694419b3b0ab7",
|
||||
"sha256:a3d17ee4ae739fe16f7501a52255c2e287ac817cfd88565b9859f70520afffea",
|
||||
"sha256:ba5b5488719c0f2ced0aa1986376f7baff1a1653a8eb5fdfcf3f84c7ce46ef8d",
|
||||
"sha256:c573ea89dd95d41b6d8cf36799c34b6d5b1eac4aed0212dee0f0a11fb7b01e8f",
|
||||
"sha256:c5f1b9e8592d2c448c44e6bc0d91224b16ea5f8293908b1561de1f6d2d0658b1",
|
||||
"sha256:cbe581456357d8f0674d6a590b1aaf46c11d01dd0a23af147a51a798c3818034",
|
||||
"sha256:cf219bec69e601fe27e3974b7307d2f06082ab385d42752738ad2eb630a47d65",
|
||||
"sha256:cf5014eb214d814a83a7a47407272d5db10b719dbeaf4d3cfe5969309d0fcf4b",
|
||||
"sha256:d08bad67fa18f7e8ff738c090628ee0cbf0505d74a991c848d6d04abfe67b697",
|
||||
"sha256:d6f716d7b1182bf35862b5065112f933f43dd1aa4f8097c9bcfb246f71528a34",
|
||||
"sha256:e08e479102627641c7cb4ece421c6ed4124820b1758765db32201136762282d9",
|
||||
"sha256:e20ac21418af0298437d29599f7851915497ce9f2866bc8e86b084d8911ee061",
|
||||
"sha256:e25f53c37e319241b9a412382140dffac98ca756ba8f360ac7ab5e30cad9670a",
|
||||
"sha256:e8932bddf159064f04e946fbb64693753488de21586f20e840b3be51745c8c09",
|
||||
"sha256:f20900f16377f2109783ae9348d34bc80530808439591c3d3df73d5c7ef1a00c"
|
||||
],
|
||||
"version": "==0.4.2"
|
||||
},
|
||||
"colorama": {
|
||||
"hashes": [
|
||||
"sha256:a3d89af5db9e9806a779a50296b5fdb466e281147c2c235e8225ecc6dbf7bbf3",
|
||||
"sha256:c9b54bebe91a6a803e0772c8561d53f2926bfeb17cd141fbabcb08424086595c"
|
||||
],
|
||||
"markers": "sys_platform == 'win32'",
|
||||
"version": "==0.4.0"
|
||||
},
|
||||
"docutils": {
|
||||
"hashes": [
|
||||
"sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6",
|
||||
"sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274",
|
||||
"sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"
|
||||
],
|
||||
"version": "==0.14"
|
||||
},
|
||||
"flake8": {
|
||||
"hashes": [
|
||||
"sha256:7253265f7abd8b313e3892944044a365e3f4ac3fcdcfb4298f55ee9ddf188ba0",
|
||||
"sha256:c7841163e2b576d435799169b78703ad6ac1bbb0f199994fc05f700b2a90ea37"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.5.0"
|
||||
},
|
||||
"flask": {
|
||||
"hashes": [
|
||||
"sha256:2271c0070dbcb5275fad4a82e29f23ab92682dc45f9dfbc22c02ba9b9322ce48",
|
||||
"sha256:a080b744b7e345ccfcbc77954861cb05b3c63786e93f2b3875e0913d44b43f05"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.0.2"
|
||||
},
|
||||
"future": {
|
||||
"hashes": [
|
||||
"sha256:e39ced1ab767b5936646cedba8bcce582398233d6a627067d4c6a454c90cfedb"
|
||||
],
|
||||
"version": "==0.16.0"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e",
|
||||
"sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"
|
||||
],
|
||||
"version": "==2.7"
|
||||
},
|
||||
"imagesize": {
|
||||
"hashes": [
|
||||
"sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8",
|
||||
"sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5"
|
||||
],
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"itsdangerous": {
|
||||
"hashes": [
|
||||
"sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519"
|
||||
],
|
||||
"version": "==0.24"
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
"sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd",
|
||||
"sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4"
|
||||
],
|
||||
"version": "==2.10"
|
||||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
"sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665"
|
||||
],
|
||||
"version": "==1.0"
|
||||
},
|
||||
"mccabe": {
|
||||
"hashes": [
|
||||
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
|
||||
"sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
|
||||
],
|
||||
"version": "==0.6.1"
|
||||
},
|
||||
"more-itertools": {
|
||||
"hashes": [
|
||||
"sha256:c187a73da93e7a8acc0001572aebc7e3c69daf7bf6881a2cea10650bd4420092",
|
||||
"sha256:c476b5d3a34e12d40130bc2f935028b5f636df8f372dc2c1c01dc19681b2039e",
|
||||
"sha256:fcbfeaea0be121980e15bc97b3817b5202ca73d0eae185b4550cbfce2a3ebb3d"
|
||||
],
|
||||
"version": "==4.3.0"
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:0886227f54515e592aaa2e5a553332c73962917f2831f1b0f9b9f4380a4b9807",
|
||||
"sha256:f95a1e147590f204328170981833854229bb2912ac3d5f89e2a8ccd2834800c9"
|
||||
],
|
||||
"version": "==18.0"
|
||||
},
|
||||
"pkginfo": {
|
||||
"hashes": [
|
||||
"sha256:5878d542a4b3f237e359926384f1dde4e099c9f5525d236b1840cf704fa8d474",
|
||||
"sha256:a39076cb3eb34c333a0dd390b568e9e1e881c7bf2cc0aee12120636816f55aee"
|
||||
],
|
||||
"version": "==1.4.2"
|
||||
},
|
||||
"pluggy": {
|
||||
"hashes": [
|
||||
"sha256:6e3836e39f4d36ae72840833db137f7b7d35105079aee6ec4a62d9f80d594dd1",
|
||||
"sha256:95eb8364a4708392bae89035f45341871286a333f749c3141c20573d2b3876e1"
|
||||
],
|
||||
"version": "==0.7.1"
|
||||
},
|
||||
"py": {
|
||||
"hashes": [
|
||||
"sha256:bf92637198836372b520efcba9e020c330123be8ce527e535d185ed4b6f45694",
|
||||
"sha256:e76826342cefe3c3d5f7e8ee4316b80d1dd8a300781612ddbc765c17ba25a6c6"
|
||||
],
|
||||
"version": "==1.7.0"
|
||||
},
|
||||
"pycodestyle": {
|
||||
"hashes": [
|
||||
"sha256:682256a5b318149ca0d2a9185d365d8864a768a28db66a84a2ea946bcc426766",
|
||||
"sha256:6c4245ade1edfad79c3446fadfc96b0de2759662dc29d07d80a6f27ad1ca6ba9"
|
||||
],
|
||||
"version": "==2.3.1"
|
||||
},
|
||||
"pycparser": {
|
||||
"hashes": [
|
||||
"sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3"
|
||||
],
|
||||
"version": "==2.19"
|
||||
},
|
||||
"pyflakes": {
|
||||
"hashes": [
|
||||
"sha256:08bd6a50edf8cffa9fa09a463063c425ecaaf10d1eb0335a7e8b1401aef89e6f",
|
||||
"sha256:8d616a382f243dbf19b54743f280b80198be0bca3a5396f1d2e1fca6223e8805"
|
||||
],
|
||||
"version": "==1.6.0"
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d",
|
||||
"sha256:dbae1046def0efb574852fab9e90209b23f556367b5a320c0bcb871c77c3e8cc"
|
||||
],
|
||||
"version": "==2.2.0"
|
||||
},
|
||||
"pyparsing": {
|
||||
"hashes": [
|
||||
"sha256:bc6c7146b91af3f567cf6daeaec360bc07d45ffec4cf5353f4d7a208ce7ca30a",
|
||||
"sha256:d29593d8ebe7b57d6967b62494f8c72b03ac0262b1eed63826c6f788b3606401"
|
||||
],
|
||||
"version": "==2.2.2"
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:7e258ee50338f4e46957f9e09a0f10fb1c2d05493fa901d113a8dafd0790de4e",
|
||||
"sha256:9332147e9af2dcf46cd7ceb14d5acadb6564744ddff1fe8c17f0ce60ece7d9a2"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.8.2"
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:a061aa0a9e06881eb8b3b2b43f05b9439d6583c206d0a6c340ff72a7b6669053",
|
||||
"sha256:ffb9ef1de172603304d9d2819af6f5ece76f2e85ec10692a524dd876e72bf277"
|
||||
],
|
||||
"version": "==2018.5"
|
||||
},
|
||||
"readme-renderer": {
|
||||
"hashes": [
|
||||
"sha256:237ca8705ffea849870de41101dba41543561da05c0ae45b2f1c547efa9843d2",
|
||||
"sha256:f75049a3a7afa57165551e030dd8f9882ebf688b9600535a3f7e23596651875d"
|
||||
],
|
||||
"version": "==22.0"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1",
|
||||
"sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a"
|
||||
],
|
||||
"version": "==2.19.1"
|
||||
},
|
||||
"requests-toolbelt": {
|
||||
"hashes": [
|
||||
"sha256:42c9c170abc2cacb78b8ab23ac957945c7716249206f90874651971a4acff237",
|
||||
"sha256:f6a531936c6fa4c6cfce1b9c10d5c4f498d16528d2a54a22ca00011205a187b5"
|
||||
],
|
||||
"version": "==0.8.0"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
|
||||
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
|
||||
],
|
||||
"version": "==1.11.0"
|
||||
},
|
||||
"snowballstemmer": {
|
||||
"hashes": [
|
||||
"sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128",
|
||||
"sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89"
|
||||
],
|
||||
"version": "==1.2.1"
|
||||
},
|
||||
"sphinx": {
|
||||
"hashes": [
|
||||
"sha256:652eb8c566f18823a022bb4b6dbc868d366df332a11a0226b5bc3a798a479f17",
|
||||
"sha256:d222626d8356de702431e813a05c68a35967e3d66c6cd1c2c89539bb179a7464"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.8.1"
|
||||
},
|
||||
"sphinxcontrib-websupport": {
|
||||
"hashes": [
|
||||
"sha256:68ca7ff70785cbe1e7bccc71a48b5b6d965d79ca50629606c7861a21b206d9dd",
|
||||
"sha256:9de47f375baf1ea07cdb3436ff39d7a9c76042c10a769c52353ec46e4e8fc3b9"
|
||||
],
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"toml": {
|
||||
"hashes": [
|
||||
"sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
|
||||
"sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
|
||||
],
|
||||
"version": "==0.10.0"
|
||||
},
|
||||
"tqdm": {
|
||||
"hashes": [
|
||||
"sha256:18f1818ce951aeb9ea162ae1098b43f583f7d057b34d706f66939353d1208889",
|
||||
"sha256:df02c0650160986bac0218bb07952245fc6960d23654648b5d5526ad5a4128c9"
|
||||
],
|
||||
"version": "==4.26.0"
|
||||
},
|
||||
"twine": {
|
||||
"hashes": [
|
||||
"sha256:7d89bc6acafb31d124e6e5b295ef26ac77030bf098960c2a4c4e058335827c5c",
|
||||
"sha256:fad6f1251195f7ddd1460cb76d6ea106c93adb4e56c41e0da79658e56e547d2c"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.12.1"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf",
|
||||
"sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5"
|
||||
],
|
||||
"version": "==1.23"
|
||||
},
|
||||
"webencodings": {
|
||||
"hashes": [
|
||||
"sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78",
|
||||
"sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"
|
||||
],
|
||||
"version": "==0.5.1"
|
||||
},
|
||||
"werkzeug": {
|
||||
"hashes": [
|
||||
"sha256:c3fd7a7d41976d9f44db327260e263132466836cef6f91512889ed60ad26557c",
|
||||
"sha256:d5da73735293558eb1651ee2fddc4d0dedcfa06538b8813a2e20011583c9e49b"
|
||||
],
|
||||
"version": "==0.14.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +1,97 @@
|
||||
# Responder: a familiar HTTP Service Framework for Python
|
||||
|
||||

|
||||
[](https://github.com/kennethreitz/responder/actions/workflows/test.yaml)
|
||||
[](https://github.com/kennethreitz/responder/actions/workflows/docs.yaml)
|
||||
[](https://responder.kennethreitz.org/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://pepy.tech/project/responder)
|
||||
[](https://github.com/kennethreitz/responder/graphs/contributors)
|
||||
[](https://pypi.org/project/responder/)
|
||||
|
||||
The Python world certainly doesn't need more web frameworks. But, it does need more creativity, so I thought I'd bring some of my ideas to the table and see what I could come up with.
|
||||
[](https://responder.readthedocs.io)
|
||||
|
||||
## But will it blend?
|
||||
Responder is powered by [Starlette](https://www.starlette.io/).
|
||||
[View documentation](https://responder.readthedocs.io).
|
||||
|
||||
```python
|
||||
import responder
|
||||
Responder gets you an ASGI app, with a production static files server pre-installed,
|
||||
Jinja templating, and a production webserver based on uvloop, automatically serving
|
||||
up requests with gzip compression.
|
||||
The `async` declaration within the example program is optional.
|
||||
|
||||
api = responder.API()
|
||||
## Testimonials
|
||||
|
||||
@api.route("/{greeting}")
|
||||
def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
> "Pleasantly very taken with python-responder.
|
||||
> [@kennethreitz](https://x.com/kennethreitz42) at his absolute best." —Rudraksh
|
||||
> M.K.
|
||||
|
||||
if __name__ == '__main__':
|
||||
api.run()
|
||||
```
|
||||
> "ASGI is going to enable all sorts of new high-performance web services. It's awesome
|
||||
> to see Responder starting to take advantage of that." — Tom Christie author of
|
||||
> [Django REST Framework](https://www.django-rest-framework.org/)
|
||||
|
||||
This gets you a WSGI app, with WhiteNoise pre-installed, jinja2 templating (without additional imports), and a production webserver (ready for slowloris attacks), serving up requests with gzip compression automatically.
|
||||
> "I love that you are exploring new patterns. Go go go!" — Danny Greenfield, author of
|
||||
> [Two Scoops of Django](https://www.feldroy.com/two-scoops-press#two-scoops-of-django)
|
||||
|
||||
Class-based views (and setting some headers and stuff):
|
||||
## More Examples
|
||||
|
||||
```python
|
||||
@api.route("/{greeting}")
|
||||
class GreetingResource:
|
||||
def on_request(req, resp, *, greeting): # or on_get...
|
||||
resp.text = f"{greeting}, world!"
|
||||
resp.headers.update({'X-Life': '42'})
|
||||
resp.status_code = api.status_codes.HTTP_416
|
||||
```
|
||||
See
|
||||
[the documentation's feature tour](https://responder.readthedocs.io/tour.html)
|
||||
for more details on features available in Responder.
|
||||
|
||||
Render a template, with arguments:
|
||||
# Installing Responder
|
||||
|
||||
```python
|
||||
@api.route("/{greeting}")
|
||||
def greet_world(req, resp, *, greeting):
|
||||
resp.content = api.template("index.html", greeting=greeting)
|
||||
```
|
||||
Install the most recent stable release:
|
||||
|
||||
The `api` instance is available as an object during template rendering.
|
||||
pip install --upgrade responder
|
||||
|
||||
Serve a GraphQL API:
|
||||
Alternatively, install directly from the repository:
|
||||
|
||||
```python
|
||||
import graphene
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
|
||||
def resolve_hello(self, info, name):
|
||||
return "Hello " + name
|
||||
|
||||
api.add_route("/graph", graphene.Schema(query=Query))
|
||||
```
|
||||
|
||||
We can then send a query to our service:
|
||||
|
||||
```pycon
|
||||
>>> requests = api.session()
|
||||
>>> r = requests.get("http://;/graph", params={"query": "{ hello }"})
|
||||
>>> r.json()
|
||||
{'data': {'hello': 'Hello stranger'}}
|
||||
```
|
||||
|
||||
Or, request YAML back:
|
||||
|
||||
```pycon
|
||||
>>> r = requests.get("http://;/graph", params={"query": "{ hello(name:\"john\") }"}, headers={"Accept": "application/x-yaml"})
|
||||
>>> print(r.text)
|
||||
data: {hello: Hello john}
|
||||
|
||||
```
|
||||
|
||||
Want HSTS?
|
||||
|
||||
```
|
||||
api = responder.API(enable_hsts=True)
|
||||
```
|
||||
|
||||
Boom. ✨🍰✨
|
||||
pip install 'responder @ git+https://github.com/kennethreitz/responder.git'
|
||||
|
||||
Responder supports **Python 3.9+**.
|
||||
|
||||
# The Basic Idea
|
||||
|
||||
The primary concept here is to bring the nicities that are brought forth from both Flask and Falcon and unify them into a single framework, along with some new ideas I have. I also wanted to take some of the API primitives that are instilled in the Requests library and put them into a web framework. So, you'll find a lot of parallels here with Requests.
|
||||
The primary concept here is to bring the niceties from both Flask and Falcon and
|
||||
unify them into a single framework. You'll find a familiar API with a clean,
|
||||
Pythonic design.
|
||||
|
||||
- Setting `resp.text` sends back unicode, while setting `resp.content` sends back bytes.
|
||||
- Setting `resp.media` sends back JSON/YAML (`.text`/`.content` override this).
|
||||
- Case-insensitive `req.headers` dict (from Requests directly).
|
||||
- `resp.status_code`, `req.method`, `req.url`, and other familar friends.
|
||||
- Setting `resp.text` sends back unicode, while setting `resp.html` sends back HTML.
|
||||
- Setting `resp.media` sends back JSON/YAML (`.text`/`.html`/`.content` override this).
|
||||
- Setting `resp.content` sends back bytes.
|
||||
- Use `resp.file("path")` to serve files with automatic content-type detection.
|
||||
- Case-insensitive `req.headers` dict.
|
||||
- `resp.status_code`, `req.method`, `req.url`, and other familiar friends.
|
||||
|
||||
## New Ideas
|
||||
## Features
|
||||
|
||||
- **A built in testing client that uses the actual Requests you know and love**.
|
||||
- The ability to mount other WSGI apps easily.
|
||||
- Automatic gzipped-responses (still working on that).
|
||||
- In addition to Falcon's `on_get`, `on_post`, etc methods, Responder features an `on_request` method, which gets called on every type of request, much like Requests.
|
||||
- WhiteNoise is built-in, for serving static files.
|
||||
- Waitress built-in as a production web server. I would have chosen Gunicorn, but it doesn't run on Windows. Plus, Waitress serves well to protect against slowloris attacks, making nginx unnecessary in production.
|
||||
- GraphQL support, via Graphene. The goal here is to have any GraphQL query exposable at any route, magically.
|
||||
- Flask-style route expressions with f-string syntax and type convertors
|
||||
(`str`, `int`, `float`, `uuid`, `path`).
|
||||
- HTTP method filtering: `@api.route("/data", methods=["GET"])`.
|
||||
- Every request and response is passed into each view and mutated — including
|
||||
`response.media` for JSON/YAML content negotiation.
|
||||
- Built-in test client powered by Starlette's TestClient.
|
||||
- Mount other WSGI/ASGI apps at subroutes.
|
||||
- Automatic gzip compression.
|
||||
- Class-based views with `on_get`, `on_post`, `on_request` methods.
|
||||
- GraphQL support via Graphene with `api.graphql()`.
|
||||
- OpenAPI schema generation with interactive docs.
|
||||
- Lifespan context managers for startup/shutdown.
|
||||
- Custom exception handlers.
|
||||
- Before-request hooks with short-circuit support.
|
||||
- Cookie-based sessions.
|
||||
- WebSocket support.
|
||||
- Background tasks.
|
||||
- Production uvicorn server built-in.
|
||||
|
||||
## Development
|
||||
|
||||
## Old Ideas
|
||||
See [Development Sandbox](https://responder.kennethreitz.org/sandbox.html).
|
||||
|
||||
- Flask-style route expression, with new capabilities -- primarily, the ability to cast a parameter to integers as well as other types that are missing from Flask, all while using Python 3.6+'s new f-string syntax.
|
||||
## Supported by
|
||||
|
||||
- I love Falcon's "every request and response is passed into to each view and mutated" methodology, especially `response.media`, and have used it here. In addition to supporting JSON, I have decided to support YAML as well, as Kubernetes is slowly taking over the world, and it uses YAML for all the things. Content-negotiation and all that.
|
||||
[](https://jb.gg/OpenSourceSupport)
|
||||
|
||||
## Future Ideas
|
||||
|
||||
- I want to be able to "mount" any WSGI app into a sub-route.
|
||||
- Cooke-based sessions are currently an afterthought, as this is an API framework, but websites are APIs too.
|
||||
- Potentially support ASGI instead of WSGI. Will the tradeoffs be worth it? This is a question to ask. Procedural code works well for 90% use cases.
|
||||
- If frontend websites are supported, provide an official way to run webpack.
|
||||
|
||||
# The Goal
|
||||
|
||||
The primary goal here is to learn, not to get adoption. Though, who knows how these things will pan out.
|
||||
|
||||
# When can I use it?
|
||||
|
||||
When it's ready. It's not. I started work on this a few days ago. It works surprisingly well, considering! :)
|
||||
Special thanks to the kind people at JetBrains s.r.o. for supporting us with
|
||||
excellent development tooling.
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import responder
|
||||
import graphene
|
||||
|
||||
|
||||
from flask import Flask
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def hello_world():
|
||||
return "Hello, World from flask!"
|
||||
|
||||
|
||||
api = responder.API(enable_hsts=False)
|
||||
api.mount("/hello", app)
|
||||
|
||||
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
# resp.status = responder.status.ok
|
||||
resp.content = api.template("test.html")
|
||||
|
||||
|
||||
class ThingsResource:
|
||||
def on_request(self, req, resp):
|
||||
resp.status = responder.status.HTTP_200
|
||||
resp.media = ["yolo"]
|
||||
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
|
||||
def resolve_hello(self, info, name):
|
||||
return "Hello " + name
|
||||
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
|
||||
# Alerntatively,
|
||||
api.add_route("/graph", schema, graphiql=True)
|
||||
|
||||
print(
|
||||
api.session()
|
||||
.get(
|
||||
"http://app/",
|
||||
# data="{ hello }",
|
||||
# headers={"Accept": "application/x-yaml"},
|
||||
# data="hello",
|
||||
)
|
||||
.text
|
||||
)
|
||||
|
||||
# print(
|
||||
# api.session()
|
||||
# .get(
|
||||
# "http://app/hello/",
|
||||
# data="{ hello }",
|
||||
# headers={"Accept": "application/x-yaml"},
|
||||
# # data="hello",
|
||||
# )
|
||||
# .text
|
||||
# )
|
||||
# {hello: Hello stranger}
|
||||
|
||||
api.run(port=5000)
|
||||
@@ -1,48 +0,0 @@
|
||||
alabaster==0.7.12
|
||||
appdirs==1.4.3
|
||||
atomicwrites==1.2.1
|
||||
attrs==18.2.0
|
||||
babel==2.6.0
|
||||
black==18.9b0
|
||||
bleach==3.0.2
|
||||
certifi==2018.8.24
|
||||
cffi==1.11.5
|
||||
chardet==3.0.4
|
||||
click==7.0
|
||||
cmarkgfm==0.4.2
|
||||
colorama==0.4.0 ; sys_platform == 'win32'
|
||||
docutils==0.14
|
||||
flake8==3.5.0
|
||||
flask==1.0.2
|
||||
future==0.16.0
|
||||
idna==2.7
|
||||
imagesize==1.1.0
|
||||
itsdangerous==0.24
|
||||
jinja2==2.10
|
||||
markupsafe==1.0
|
||||
mccabe==0.6.1
|
||||
more-itertools==4.3.0
|
||||
packaging==18.0
|
||||
pkginfo==1.4.2
|
||||
pluggy==0.7.1
|
||||
py==1.7.0
|
||||
pycodestyle==2.3.1
|
||||
pycparser==2.19
|
||||
pyflakes==1.6.0
|
||||
pygments==2.2.0
|
||||
pyparsing==2.2.2
|
||||
pytest==3.8.2
|
||||
pytz==2018.5
|
||||
readme-renderer==22.0
|
||||
requests-toolbelt==0.8.0
|
||||
requests==2.19.1
|
||||
six==1.11.0
|
||||
snowballstemmer==1.2.1
|
||||
sphinx==1.8.1
|
||||
sphinxcontrib-websupport==1.1.0
|
||||
toml==0.10.0
|
||||
tqdm==4.26.0
|
||||
twine==1.12.1
|
||||
urllib3==1.23
|
||||
webencodings==0.5.1
|
||||
werkzeug==0.14.1
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Hide module name and default value for environment variable section */
|
||||
div[id$='environment-variables'] code.descclassname {
|
||||
display: none;
|
||||
div[id$="environment-variables"] code.descclassname {
|
||||
display: none;
|
||||
}
|
||||
div[id$='environment-variables'] em.property {
|
||||
display: none;
|
||||
div[id$="environment-variables"] em.property {
|
||||
display: none;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+142
-132
@@ -10,142 +10,152 @@
|
||||
*/
|
||||
|
||||
var Konami = function (callback) {
|
||||
var konami = {
|
||||
addEvent: function (obj, type, fn, ref_obj) {
|
||||
if (obj.addEventListener)
|
||||
obj.addEventListener(type, fn, false);
|
||||
else if (obj.attachEvent) {
|
||||
// IE
|
||||
obj["e" + type + fn] = fn;
|
||||
obj[type + fn] = function () {
|
||||
obj["e" + type + fn](window.event, ref_obj);
|
||||
}
|
||||
obj.attachEvent("on" + type, obj[type + fn]);
|
||||
}
|
||||
},
|
||||
removeEvent: function (obj, eventName, eventCallback) {
|
||||
if (obj.removeEventListener) {
|
||||
obj.removeEventListener(eventName, eventCallback);
|
||||
} else if (obj.attachEvent) {
|
||||
obj.detachEvent(eventName);
|
||||
}
|
||||
},
|
||||
input: "",
|
||||
pattern: "38384040373937396665",
|
||||
keydownHandler: function (e, ref_obj) {
|
||||
if (ref_obj) {
|
||||
konami = ref_obj;
|
||||
} // IE
|
||||
konami.input += e ? e.keyCode : event.keyCode;
|
||||
if (konami.input.length > konami.pattern.length) {
|
||||
konami.input = konami.input.substr((konami.input.length - konami.pattern.length));
|
||||
}
|
||||
if (konami.input === konami.pattern) {
|
||||
konami.code(konami._currentLink);
|
||||
konami.input = '';
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
load: function (link) {
|
||||
this._currentLink = link;
|
||||
this.addEvent(document, "keydown", this.keydownHandler, this);
|
||||
this.iphone.load(link);
|
||||
},
|
||||
unload: function () {
|
||||
this.removeEvent(document, 'keydown', this.keydownHandler);
|
||||
this.iphone.unload();
|
||||
},
|
||||
code: function (link) {
|
||||
window.location = link
|
||||
},
|
||||
iphone: {
|
||||
start_x: 0,
|
||||
start_y: 0,
|
||||
stop_x: 0,
|
||||
stop_y: 0,
|
||||
tap: false,
|
||||
capture: false,
|
||||
orig_keys: "",
|
||||
keys: ["UP", "UP", "DOWN", "DOWN", "LEFT", "RIGHT", "LEFT", "RIGHT", "TAP", "TAP"],
|
||||
input: [],
|
||||
code: function (link) {
|
||||
konami.code(link);
|
||||
},
|
||||
touchmoveHandler: function (e) {
|
||||
if (e.touches.length === 1 && konami.iphone.capture === true) {
|
||||
var touch = e.touches[0];
|
||||
konami.iphone.stop_x = touch.pageX;
|
||||
konami.iphone.stop_y = touch.pageY;
|
||||
konami.iphone.tap = false;
|
||||
konami.iphone.capture = false;
|
||||
konami.iphone.check_direction();
|
||||
}
|
||||
},
|
||||
touchendHandler: function () {
|
||||
konami.iphone.input.push(konami.iphone.check_direction());
|
||||
|
||||
if (konami.iphone.input.length > konami.iphone.keys.length) konami.iphone.input.shift();
|
||||
|
||||
if (konami.iphone.input.length === konami.iphone.keys.length) {
|
||||
var match = true;
|
||||
for (var i = 0; i < konami.iphone.keys.length; i++) {
|
||||
if (konami.iphone.input[i] !== konami.iphone.keys[i]) {
|
||||
match = false;
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
konami.iphone.code(konami._currentLink);
|
||||
}
|
||||
}
|
||||
},
|
||||
touchstartHandler: function (e) {
|
||||
konami.iphone.start_x = e.changedTouches[0].pageX;
|
||||
konami.iphone.start_y = e.changedTouches[0].pageY;
|
||||
konami.iphone.tap = true;
|
||||
konami.iphone.capture = true;
|
||||
},
|
||||
load: function (link) {
|
||||
this.orig_keys = this.keys;
|
||||
konami.addEvent(document, "touchmove", this.touchmoveHandler);
|
||||
konami.addEvent(document, "touchend", this.touchendHandler, false);
|
||||
konami.addEvent(document, "touchstart", this.touchstartHandler);
|
||||
},
|
||||
unload: function () {
|
||||
konami.removeEvent(document, 'touchmove', this.touchmoveHandler);
|
||||
konami.removeEvent(document, 'touchend', this.touchendHandler);
|
||||
konami.removeEvent(document, 'touchstart', this.touchstartHandler);
|
||||
},
|
||||
check_direction: function () {
|
||||
x_magnitude = Math.abs(this.start_x - this.stop_x);
|
||||
y_magnitude = Math.abs(this.start_y - this.stop_y);
|
||||
x = ((this.start_x - this.stop_x) < 0) ? "RIGHT" : "LEFT";
|
||||
y = ((this.start_y - this.stop_y) < 0) ? "DOWN" : "UP";
|
||||
result = (x_magnitude > y_magnitude) ? x : y;
|
||||
result = (this.tap === true) ? "TAP" : result;
|
||||
return result;
|
||||
}
|
||||
var konami = {
|
||||
addEvent: function (obj, type, fn, ref_obj) {
|
||||
if (obj.addEventListener) obj.addEventListener(type, fn, false);
|
||||
else if (obj.attachEvent) {
|
||||
// IE
|
||||
obj["e" + type + fn] = fn;
|
||||
obj[type + fn] = function () {
|
||||
obj["e" + type + fn](window.event, ref_obj);
|
||||
};
|
||||
obj.attachEvent("on" + type, obj[type + fn]);
|
||||
}
|
||||
},
|
||||
removeEvent: function (obj, eventName, eventCallback) {
|
||||
if (obj.removeEventListener) {
|
||||
obj.removeEventListener(eventName, eventCallback);
|
||||
} else if (obj.attachEvent) {
|
||||
obj.detachEvent(eventName);
|
||||
}
|
||||
},
|
||||
input: "",
|
||||
pattern: "38384040373937396665",
|
||||
keydownHandler: function (e, ref_obj) {
|
||||
if (ref_obj) {
|
||||
konami = ref_obj;
|
||||
} // IE
|
||||
konami.input += e ? e.keyCode : event.keyCode;
|
||||
if (konami.input.length > konami.pattern.length) {
|
||||
konami.input = konami.input.substr(konami.input.length - konami.pattern.length);
|
||||
}
|
||||
if (konami.input === konami.pattern) {
|
||||
konami.code(konami._currentLink);
|
||||
konami.input = "";
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
load: function (link) {
|
||||
this._currentLink = link;
|
||||
this.addEvent(document, "keydown", this.keydownHandler, this);
|
||||
this.iphone.load(link);
|
||||
},
|
||||
unload: function () {
|
||||
this.removeEvent(document, "keydown", this.keydownHandler);
|
||||
this.iphone.unload();
|
||||
},
|
||||
code: function (link) {
|
||||
window.location = link;
|
||||
},
|
||||
iphone: {
|
||||
start_x: 0,
|
||||
start_y: 0,
|
||||
stop_x: 0,
|
||||
stop_y: 0,
|
||||
tap: false,
|
||||
capture: false,
|
||||
orig_keys: "",
|
||||
keys: [
|
||||
"UP",
|
||||
"UP",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"LEFT",
|
||||
"RIGHT",
|
||||
"LEFT",
|
||||
"RIGHT",
|
||||
"TAP",
|
||||
"TAP",
|
||||
],
|
||||
input: [],
|
||||
code: function (link) {
|
||||
konami.code(link);
|
||||
},
|
||||
touchmoveHandler: function (e) {
|
||||
if (e.touches.length === 1 && konami.iphone.capture === true) {
|
||||
var touch = e.touches[0];
|
||||
konami.iphone.stop_x = touch.pageX;
|
||||
konami.iphone.stop_y = touch.pageY;
|
||||
konami.iphone.tap = false;
|
||||
konami.iphone.capture = false;
|
||||
konami.iphone.check_direction();
|
||||
}
|
||||
}
|
||||
},
|
||||
touchendHandler: function () {
|
||||
konami.iphone.input.push(konami.iphone.check_direction());
|
||||
|
||||
typeof callback === "string" && konami.load(callback);
|
||||
if (typeof callback === "function") {
|
||||
konami.code = callback;
|
||||
konami.load();
|
||||
}
|
||||
if (konami.iphone.input.length > konami.iphone.keys.length)
|
||||
konami.iphone.input.shift();
|
||||
|
||||
return konami;
|
||||
if (konami.iphone.input.length === konami.iphone.keys.length) {
|
||||
var match = true;
|
||||
for (var i = 0; i < konami.iphone.keys.length; i++) {
|
||||
if (konami.iphone.input[i] !== konami.iphone.keys[i]) {
|
||||
match = false;
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
konami.iphone.code(konami._currentLink);
|
||||
}
|
||||
}
|
||||
},
|
||||
touchstartHandler: function (e) {
|
||||
konami.iphone.start_x = e.changedTouches[0].pageX;
|
||||
konami.iphone.start_y = e.changedTouches[0].pageY;
|
||||
konami.iphone.tap = true;
|
||||
konami.iphone.capture = true;
|
||||
},
|
||||
load: function (link) {
|
||||
this.orig_keys = this.keys;
|
||||
konami.addEvent(document, "touchmove", this.touchmoveHandler);
|
||||
konami.addEvent(document, "touchend", this.touchendHandler, false);
|
||||
konami.addEvent(document, "touchstart", this.touchstartHandler);
|
||||
},
|
||||
unload: function () {
|
||||
konami.removeEvent(document, "touchmove", this.touchmoveHandler);
|
||||
konami.removeEvent(document, "touchend", this.touchendHandler);
|
||||
konami.removeEvent(document, "touchstart", this.touchstartHandler);
|
||||
},
|
||||
check_direction: function () {
|
||||
x_magnitude = Math.abs(this.start_x - this.stop_x);
|
||||
y_magnitude = Math.abs(this.start_y - this.stop_y);
|
||||
x = this.start_x - this.stop_x < 0 ? "RIGHT" : "LEFT";
|
||||
y = this.start_y - this.stop_y < 0 ? "DOWN" : "UP";
|
||||
result = x_magnitude > y_magnitude ? x : y;
|
||||
result = this.tap === true ? "TAP" : result;
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
typeof callback === "string" && konami.load(callback);
|
||||
if (typeof callback === "function") {
|
||||
konami.code = callback;
|
||||
konami.load();
|
||||
}
|
||||
|
||||
return konami;
|
||||
};
|
||||
|
||||
|
||||
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
|
||||
module.exports = Konami;
|
||||
if (typeof module !== "undefined" && typeof module.exports !== "undefined") {
|
||||
module.exports = Konami;
|
||||
} else {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
define([], function() {
|
||||
return Konami;
|
||||
});
|
||||
} else {
|
||||
window.Konami = Konami;
|
||||
}
|
||||
if (typeof define === "function" && define.amd) {
|
||||
define([], function () {
|
||||
return Konami;
|
||||
});
|
||||
} else {
|
||||
window.Konami = Konami;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<link rel="stylesheet" type="text/css" href="https://cloud.typography.com/7584432/7586812/css/fonts.css" />
|
||||
<script type="text/javascript">$('#searchbox').hide(0);</script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href="https://cloud.typography.com/7584432/7586812/css/fonts.css"
|
||||
/>
|
||||
<script async defer src="https://buttons.github.io/buttons.js"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
$("#searchbox").hide(0);
|
||||
</script>
|
||||
<!--Alabaster (krTheme++) Hacks -->
|
||||
|
||||
<!-- CSS Adjustments (I'm very picky.) -->
|
||||
@@ -22,10 +30,11 @@
|
||||
}
|
||||
|
||||
pre,
|
||||
.pre,
|
||||
.class em,
|
||||
.descname,
|
||||
.method em {
|
||||
font-family: "Operator Mono SSm A", "Operator Mono SSm B" !important;
|
||||
font-family: "Operator Mono SSm A", "Operator Mono SSm B", monospace !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
@@ -38,9 +47,7 @@
|
||||
}
|
||||
|
||||
.method {
|
||||
|
||||
margin-bottom: 2em;
|
||||
|
||||
}
|
||||
|
||||
.si,
|
||||
@@ -49,9 +56,13 @@
|
||||
.method em,
|
||||
.class em {
|
||||
font-style: italic !important;
|
||||
color: grey;
|
||||
}
|
||||
|
||||
.method em,
|
||||
.class em {
|
||||
margin-left: 0.3em;
|
||||
margin-right: 0.3em;
|
||||
color: grey;
|
||||
}
|
||||
|
||||
.method p,
|
||||
@@ -71,6 +82,9 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
#testimonials p.attribution {
|
||||
margin-top: -1em;
|
||||
}
|
||||
|
||||
/* "Quick Search" should be not be shown for now. */
|
||||
div#searchbox h3 {
|
||||
@@ -105,19 +119,15 @@
|
||||
</style>
|
||||
|
||||
<!-- Analytics tracking for Kenneth. -->
|
||||
<script type="text/javascript">
|
||||
var _gauges = _gauges || [];
|
||||
(function () {
|
||||
var t = document.createElement('script');
|
||||
t.type = 'text/javascript';
|
||||
t.async = true;
|
||||
t.id = 'gauges-tracker';
|
||||
t.setAttribute('data-site-id', '588f8e99c88d9013e60fa373');
|
||||
t.setAttribute('data-track-path', 'https://track.gaug.es/track.gif');
|
||||
t.src = 'https://d36ee2fcip1434.cloudfront.net/track.js';
|
||||
var s = document.getElementsByTagName('script')[0];
|
||||
s.parentNode.insertBefore(t, s);
|
||||
})();
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-127383416-1"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
|
||||
gtag("config", "UA-127383416-1");
|
||||
</script>
|
||||
|
||||
<!-- There are no more hacks. -->
|
||||
@@ -126,7 +136,10 @@
|
||||
|
||||
<script src="{{ pathto('_static/', 1) }}/konami.js"></script>
|
||||
<script>
|
||||
var easter_egg = new Konami('https://www.myfortunecookie.co.uk/fortunes/' + (Math.floor(Math.random() * 152) + 1));
|
||||
var easter_egg = new Konami(
|
||||
"https://www.myfortunecookie.co.uk/fortunes/" +
|
||||
(Math.floor(Math.random() * 152) + 1)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -136,67 +149,94 @@
|
||||
</style>
|
||||
|
||||
<!-- GitHub Logo -->
|
||||
<a href="https://github.com/kennethreitz/responder" class="github-corner" aria-label="View source on GitHub">
|
||||
<svg width="80" height="80" viewBox="0 0 250 250" style="fill:#151513; color:#fff; position: absolute; top: 0; border: 0; right: 0;"
|
||||
aria-hidden="true">
|
||||
<a
|
||||
href="https://github.com/kennethreitz/responder"
|
||||
class="github-corner"
|
||||
aria-label="View source on GitHub"
|
||||
>
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 250 250"
|
||||
style="fill: #151513; color: #fff; position: absolute; top: 0; border: 0; right: 0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
|
||||
<path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2"
|
||||
fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path>
|
||||
<path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
|
||||
fill="currentColor" class="octo-body"></path>
|
||||
<path
|
||||
d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2"
|
||||
fill="currentColor"
|
||||
style="transform-origin: 130px 106px"
|
||||
class="octo-arm"
|
||||
></path>
|
||||
<path
|
||||
d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
|
||||
fill="currentColor"
|
||||
class="octo-body"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
<style>
|
||||
.github-corner:hover .octo-arm {
|
||||
animation: octocat-wave 560ms ease-in-out
|
||||
animation: octocat-wave 560ms ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes octocat-wave {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0)
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
20%,
|
||||
60% {
|
||||
transform: rotate(-25deg)
|
||||
transform: rotate(-25deg);
|
||||
}
|
||||
|
||||
40%,
|
||||
80% {
|
||||
transform: rotate(10deg)
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:500px) {
|
||||
@media (max-width: 500px) {
|
||||
.github-corner:hover .octo-arm {
|
||||
animation: none
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.github-corner .octo-arm {
|
||||
animation: octocat-wave 560ms ease-in-out
|
||||
animation: octocat-wave 560ms ease-in-out;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<!-- That was not a hack. That was art.
|
||||
|
||||
<!-- UserVoice JavaScript SDK (only needed once on a page) -->
|
||||
<script>(function () { var uv = document.createElement('script'); uv.type = 'text/javascript'; uv.async = true; uv.src = '//widget.uservoice.com/f4AQraEfwInlMzkexfRLg.js'; var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(uv, s) })()</script>
|
||||
<script>
|
||||
(function () {
|
||||
var uv = document.createElement("script");
|
||||
uv.type = "text/javascript";
|
||||
uv.async = true;
|
||||
uv.src = "//widget.uservoice.com/f4AQraEfwInlMzkexfRLg.js";
|
||||
var s = document.getElementsByTagName("script")[0];
|
||||
s.parentNode.insertBefore(uv, s);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- A tab to launch the Classic Widget -->
|
||||
<script>
|
||||
UserVoice = window.UserVoice || [];
|
||||
UserVoice.push(['showTab', 'classic_widget', {
|
||||
mode: 'feedback',
|
||||
primary_color: '#fa8c28',
|
||||
link_color: '#0a8cc6',
|
||||
forum_id: 913660,
|
||||
tab_label: 'Got feedback?',
|
||||
tab_color: '#00994f',
|
||||
tab_position: 'bottom-left',
|
||||
tab_inverted: true
|
||||
}]);
|
||||
UserVoice.push([
|
||||
"showTab",
|
||||
"classic_widget",
|
||||
{
|
||||
mode: "feedback",
|
||||
primary_color: "#fa8c28",
|
||||
link_color: "#0a8cc6",
|
||||
forum_id: 913660,
|
||||
tab_label: "Got feedback?",
|
||||
tab_color: "#00994f",
|
||||
tab_position: "bottom-left",
|
||||
tab_inverted: true,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -1,33 +1,88 @@
|
||||
<p class="logo">
|
||||
<a href="{{ pathto(master_doc) }}">
|
||||
<img class="logo" src="{{ pathto('_static/responder.png', 1) }}" title="https://kennethreitz.org/tattoos" />
|
||||
<img
|
||||
class="logo"
|
||||
src="{{ pathto('_static/responder.png', 1) }}"
|
||||
title="https://kennethreitz.org/tattoos"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<iframe src="https://ghbtns.com/github-btn.html?user=kennethreitz&repo=responder&type=watch&count=true&size=large"
|
||||
allowtransparency="true" frameborder="0" scrolling="0" width="200px" height="35px"></iframe>
|
||||
<a class="github-button"
|
||||
href="https://github.com/kennethreitz/responder"
|
||||
data-color-scheme="no-preference: light; light: light; dark: light;"
|
||||
data-size="large"
|
||||
data-show-count="true"
|
||||
aria-label="Star kennethreitz/responder on GitHub">Star</a>
|
||||
</p>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.css"
|
||||
/>
|
||||
<style>
|
||||
.algolia-autocomplete {
|
||||
width: 100%;
|
||||
height: 1.5em;
|
||||
}
|
||||
.algolia-autocomplete a {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
#doc_search {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
<input id="doc_search" placeholder="Search the doc" autofocus />
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.js"
|
||||
onload="docsearch({
|
||||
apiKey: 'ac965312db252e0496283c75c6f76f0b',
|
||||
indexName: 'python-responder',
|
||||
inputSelector: '#doc_search',
|
||||
debug: false // Set debug to true if you want to inspect the dropdown
|
||||
})"
|
||||
async
|
||||
></script>
|
||||
|
||||
<p>
|
||||
<strong>Responder</strong> is a web service framework, written for human beings.
|
||||
</p>
|
||||
<p><strong>Responder</strong> is a web service framework, written for human beings.</p>
|
||||
|
||||
<h3>Stay Informed</h3>
|
||||
<p>Receive updates on new releases and upcoming projects.</p>
|
||||
|
||||
<p><iframe src="https://ghbtns.com/github-btn.html?user=kennethreitz&type=follow&count=true" allowtransparency="true"
|
||||
frameborder="0" scrolling="0" width="200" height="20"></iframe></p>
|
||||
|
||||
<p><a href="https://twitter.com/kennethreitz" class="twitter-follow-button" data-show-count="false">Follow
|
||||
@kennethreitz</a>
|
||||
<script>!function (d, s, id) { var js, fjs = d.getElementsByTagName(s)[0], p = /^http:/.test(d.location) ? 'http' : 'https'; if (!d.getElementById(id)) { js = d.createElement(s); js.id = id; js.src = p + '://platform.twitter.com/widgets.js'; fjs.parentNode.insertBefore(js, fjs); } }(document, 'script', 'twitter-wjs');</script>
|
||||
<p>
|
||||
<a class="github-button"
|
||||
href="https://github.com/kennethreitz"
|
||||
data-color-scheme="no-preference: light; light: light; dark: light;"
|
||||
data-size="medium"
|
||||
data-show-count="true"
|
||||
aria-label="Follow @kennethreitz on GitHub">Follow @kennethreitz</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a
|
||||
href="https://x.com/kennethreitz42"
|
||||
class="twitter-follow-button"
|
||||
data-show-count="false"
|
||||
>Follow @kennethreitz</a
|
||||
>
|
||||
<script>
|
||||
!(function (d, s, id) {
|
||||
var js,
|
||||
fjs = d.getElementsByTagName(s)[0],
|
||||
p = /^http:/.test(d.location) ? "http" : "https";
|
||||
if (!d.getElementById(id)) {
|
||||
js = d.createElement(s);
|
||||
js.id = id;
|
||||
js.src = p + "://platform.twitter.com/widgets.js";
|
||||
fjs.parentNode.insertBefore(js, fjs);
|
||||
}
|
||||
})(document, "script", "twitter-wjs");
|
||||
</script>
|
||||
</p>
|
||||
|
||||
<h3>Useful Links</h3>
|
||||
<ul>
|
||||
|
||||
<li><a href="http://github.com/kennethreitz/responder">Responder @ GitHub</a></li>
|
||||
<li><a href="http://pypi.python.org/pypi/responder">Responder @ PyPI</a></li>
|
||||
<li><a href="http://github.com/kennethreitz/responder/issues">Issue Tracker</a></li>
|
||||
|
||||
@@ -1,33 +1,93 @@
|
||||
<p class="logo">
|
||||
<a href="{{ pathto(master_doc) }}">
|
||||
<img class="logo" src="{{ pathto('_static/responder.png', 1) }}" title="https://kennethreitz.org/tattoos" />
|
||||
<img
|
||||
class="logo"
|
||||
src="{{ pathto('_static/responder.png', 1) }}"
|
||||
title="https://kennethreitz.org/tattoos"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<iframe src="https://ghbtns.com/github-btn.html?user=kennethreitz&repo=responder&type=watch&count=true&size=large"
|
||||
allowtransparency="true" frameborder="0" scrolling="0" width="200px" height="35px"></iframe>
|
||||
<iframe
|
||||
src="https://ghbtns.com/github-btn.html?user=kennethreitz&repo=responder&type=watch&count=true&size=large"
|
||||
allowtransparency="true"
|
||||
frameborder="0"
|
||||
scrolling="0"
|
||||
width="200px"
|
||||
height="35px"
|
||||
></iframe>
|
||||
</p>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.css"
|
||||
/>
|
||||
<style>
|
||||
.algolia-autocomplete {
|
||||
width: 100%;
|
||||
height: 1.5em;
|
||||
}
|
||||
.algolia-autocomplete a {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
#doc_search {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
<input id="doc_search" placeholder="Search the doc" autofocus />
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.js"
|
||||
onload="docsearch({
|
||||
apiKey: 'ac965312db252e0496283c75c6f76f0b',
|
||||
indexName: 'python-responder',
|
||||
inputSelector: '#doc_search',
|
||||
debug: false // Set debug to true if you want to inspect the dropdown
|
||||
})"
|
||||
async
|
||||
></script>
|
||||
|
||||
<p>
|
||||
<strong>Responder</strong> is a web service framework, written for human beings.
|
||||
</p>
|
||||
<p><strong>Responder</strong> is a web service framework, written for human beings.</p>
|
||||
|
||||
<h3>Stay Informed</h3>
|
||||
<p>Receive updates on new releases and upcoming projects.</p>
|
||||
|
||||
<p><iframe src="https://ghbtns.com/github-btn.html?user=kennethreitz&type=follow&count=true" allowtransparency="true"
|
||||
frameborder="0" scrolling="0" width="200" height="20"></iframe></p>
|
||||
|
||||
<p><a href="https://twitter.com/kennethreitz" class="twitter-follow-button" data-show-count="false">Follow
|
||||
@kennethreitz</a>
|
||||
<script>!function (d, s, id) { var js, fjs = d.getElementsByTagName(s)[0], p = /^http:/.test(d.location) ? 'http' : 'https'; if (!d.getElementById(id)) { js = d.createElement(s); js.id = id; js.src = p + '://platform.twitter.com/widgets.js'; fjs.parentNode.insertBefore(js, fjs); } }(document, 'script', 'twitter-wjs');</script>
|
||||
<p>
|
||||
<iframe
|
||||
src="https://ghbtns.com/github-btn.html?user=kennethreitz&type=follow&count=true"
|
||||
allowtransparency="true"
|
||||
frameborder="0"
|
||||
scrolling="0"
|
||||
width="200"
|
||||
height="20"
|
||||
></iframe>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a
|
||||
href="https://x.com/kennethreitz42"
|
||||
class="twitter-follow-button"
|
||||
data-show-count="false"
|
||||
>Follow @kennethreitz</a
|
||||
>
|
||||
<script>
|
||||
!(function (d, s, id) {
|
||||
var js,
|
||||
fjs = d.getElementsByTagName(s)[0],
|
||||
p = /^http:/.test(d.location) ? "http" : "https";
|
||||
if (!d.getElementById(id)) {
|
||||
js = d.createElement(s);
|
||||
js.id = id;
|
||||
js.src = p + "://platform.twitter.com/widgets.js";
|
||||
fjs.parentNode.insertBefore(js, fjs);
|
||||
}
|
||||
})(document, "script", "twitter-wjs");
|
||||
</script>
|
||||
</p>
|
||||
|
||||
<h3>Useful Links</h3>
|
||||
<ul>
|
||||
|
||||
<li><a href="http://github.com/kennethreitz/responder">Responder @ GitHub</a></li>
|
||||
<li><a href="http://pypi.python.org/pypi/responder">Responder @ PyPI</a></li>
|
||||
<li><a href="http://github.com/kennethreitz/responder/issues">Issue Tracker</a></li>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
|
||||
API Documentation
|
||||
=================
|
||||
|
||||
|
||||
Web Service (API) Class
|
||||
-----------------------
|
||||
.. module:: responder
|
||||
|
||||
.. autoclass:: API
|
||||
:inherited-members:
|
||||
|
||||
Requests & Responses
|
||||
--------------------
|
||||
|
||||
|
||||
.. autoclass:: Request
|
||||
:inherited-members:
|
||||
|
||||
.. autoclass:: Response
|
||||
:inherited-members:
|
||||
|
||||
|
||||
Utility Functions
|
||||
-----------------
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_100
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_200
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_300
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_400
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_500
|
||||
@@ -0,0 +1,7 @@
|
||||
# Backlog
|
||||
|
||||
## Future Ideas
|
||||
- Consider adding `after_request` hooks (complement to `before_request`)
|
||||
- Explore WebSocket before_request short-circuit support
|
||||
- Add rate limiting middleware
|
||||
- Consider async template rendering by default
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../CHANGELOG.md
|
||||
@@ -0,0 +1,174 @@
|
||||
Responder CLI
|
||||
=============
|
||||
|
||||
Responder installs a command line program ``responder``. Use it to launch
|
||||
a Responder application from a file or module, either located on a local
|
||||
or remote filesystem, or object store.
|
||||
|
||||
Launch Module Entrypoint
|
||||
------------------------
|
||||
|
||||
For loading a Responder application from a Python module, you will refer to
|
||||
its ``API()`` instance using a `Python entry point object reference`_ that
|
||||
points to a Python object. It is either in the form ``importable.module``,
|
||||
or ``importable.module:object.attr``.
|
||||
|
||||
A basic invocation command to launch a Responder application:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder run acme.app
|
||||
|
||||
The command above assumes a Python package ``acme`` including an ``app``
|
||||
module ``acme/app.py`` that includes an attribute ``api`` that refers
|
||||
to a ``responder.API`` instance, reflecting the typical layout of
|
||||
a standard Responder application.
|
||||
|
||||
Loading a Responder application using an entrypoint specification will
|
||||
inherit the capacities of `Python's import system`_, as implemented by
|
||||
`importlib`_.
|
||||
|
||||
Launch Local File
|
||||
-----------------
|
||||
|
||||
Acquire a minimal example single-file application, ``helloworld.py`` [1]_,
|
||||
to your local filesystem, giving you the chance to edit it, and launch the
|
||||
Responder HTTP service.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
wget https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py
|
||||
responder run helloworld.py
|
||||
|
||||
.. note::
|
||||
|
||||
To validate the example application, invoke a HTTP request, for example using
|
||||
`curl`_, `HTTPie`_, or your favourite browser at hand.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
http http://127.0.0.1:5042/Hello
|
||||
|
||||
The response is no surprise.
|
||||
|
||||
::
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
content-length: 13
|
||||
content-type: text/plain
|
||||
date: Sat, 26 Oct 2024 13:16:55 GMT
|
||||
encoding: utf-8
|
||||
server: uvicorn
|
||||
|
||||
Hello, world!
|
||||
|
||||
.. [1] The Responder application `helloworld.py`_ implements a basic echo handler.
|
||||
|
||||
Launch Remote File
|
||||
------------------
|
||||
|
||||
You can also launch a single-file application where its Python file is stored
|
||||
on a remote location.
|
||||
|
||||
Responder supports all filesystem adapters compatible with `fsspec`_, and
|
||||
installs the adapters for Azure Blob Storage (az), Google Cloud Storage (gs),
|
||||
GitHub, HTTP, and AWS S3 by default.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
# Works 1:1.
|
||||
responder run https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py
|
||||
responder run github://kennethreitz:responder@/examples/helloworld.py
|
||||
|
||||
If you need access other kinds of remote targets, see the `list of
|
||||
fsspec-supported filesystems and protocols`_. The next section enumerates
|
||||
a few synthetic examples. The corresponding storage buckets do not even
|
||||
exist, so don't expect those commands to work.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
# Azure Blob Storage, Google Cloud Storage, and AWS S3.
|
||||
responder run az://kennethreitz-assets/responder/examples/helloworld.py
|
||||
responder run gs://kennethreitz-assets/responder/examples/helloworld.py
|
||||
responder run s3://kennethreitz-assets/responder/examples/helloworld.py
|
||||
|
||||
# Hadoop Distributed File System (hdfs), SSH File Transfer Protocol (sftp),
|
||||
# Common Internet File System (smb), Web-based Distributed Authoring and
|
||||
# Versioning (webdav).
|
||||
responder run hdfs://kennethreitz-assets/responder/examples/helloworld.py
|
||||
responder run sftp://user@host/kennethreitz/responder/examples/helloworld.py
|
||||
responder run smb://workgroup;user:password@server:port/responder/examples/helloworld.py
|
||||
responder run webdav+https://user:password@server:port/responder/examples/helloworld.py
|
||||
|
||||
.. tip::
|
||||
|
||||
In order to install support for all filesystem types supported by fsspec, run:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
uv pip install 'fsspec[full]'
|
||||
|
||||
When using ``uv``, this concludes within an acceptable time of approx.
|
||||
25 seconds. If you need to be more selectively instead of using ``full``,
|
||||
choose from one or multiple of the available `fsspec extras`_, which are:
|
||||
|
||||
abfs, arrow, dask, dropbox, fuse, gcs, git, github, hdfs, http, oci, s3,
|
||||
sftp, smb, ssh.
|
||||
|
||||
Launch with Non-Standard Instance Name
|
||||
--------------------------------------
|
||||
|
||||
By default, Responder will acquire an ``responder.API`` instance using the
|
||||
symbol name ``api`` from the specified Python module.
|
||||
|
||||
If your main application file uses a different name than ``api``, please
|
||||
append the designated symbol name to the launch target address.
|
||||
|
||||
It works like this for module entrypoints and local files:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder run acme.app:service
|
||||
responder run /path/to/acme/app.py:service
|
||||
|
||||
It works like this for URLs:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder run http://app.server.local/path/to/acme/app.py#service
|
||||
|
||||
Within your ``app.py``, the instance would have been defined to use
|
||||
the ``service`` symbol name instead of ``api``, like this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
service = responder.API()
|
||||
|
||||
Build JavaScript Application
|
||||
----------------------------
|
||||
|
||||
The ``build`` subcommand invokes ``npm run build``, optionally accepting
|
||||
a target directory. By default, it uses the current working directory,
|
||||
where it expects a regular NPM ``package.json`` file.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder build
|
||||
|
||||
When specifying a target directory, Responder will change to that
|
||||
directory beforehand.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder build /path/to/project
|
||||
|
||||
|
||||
.. _curl: https://curl.se/
|
||||
.. _fsspec: https://filesystem-spec.readthedocs.io/en/latest/
|
||||
.. _fsspec extras: https://github.com/fsspec/filesystem_spec/blob/2024.12.0/pyproject.toml#L27-L69
|
||||
.. _helloworld.py: https://github.com/kennethreitz/responder/blob/main/examples/helloworld.py
|
||||
.. _HTTPie: https://httpie.io/docs/cli
|
||||
.. _importlib: https://docs.python.org/3/library/importlib.html
|
||||
.. _list of fsspec-supported filesystems and protocols: https://github.com/fsspec/universal_pathlib#currently-supported-filesystems-and-protocols
|
||||
.. _Python entry point object reference: https://packaging.python.org/en/latest/specifications/entry-points/
|
||||
.. _Python's import system: https://docs.python.org/3/reference/import.html
|
||||
+74
-4
@@ -20,7 +20,7 @@
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = "responder"
|
||||
copyright = "2018, A Kenneth Reitz project"
|
||||
copyright = "2018-2026, A Kenneth Reitz project"
|
||||
author = "Kenneth Reitz"
|
||||
|
||||
# The short X.Y version
|
||||
@@ -57,6 +57,11 @@ extensions = [
|
||||
"sphinx.ext.ifconfig",
|
||||
"sphinx.ext.viewcode",
|
||||
"sphinx.ext.githubpages",
|
||||
"myst_parser",
|
||||
"sphinx_copybutton",
|
||||
"sphinx_design",
|
||||
"sphinx_design_elements",
|
||||
"sphinxext.opengraph",
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
@@ -66,7 +71,7 @@ templates_path = ["_templates"]
|
||||
# You can specify multiple suffix as a list of string:
|
||||
#
|
||||
# source_suffix = ['.rst', '.md']
|
||||
source_suffix = ".rst"
|
||||
source_suffix = {".rst": "restructuredtext"}
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = "index"
|
||||
@@ -76,7 +81,7 @@ master_doc = "index"
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
language = "en"
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
@@ -211,12 +216,77 @@ epub_exclude_files = ["search.html"]
|
||||
|
||||
# -- Extension configuration -------------------------------------------------
|
||||
|
||||
# -- Options for link checker ----------------------------------------------
|
||||
linkcheck_ignore = [
|
||||
# Feldroy.com links are ignored because it blocks GHA.
|
||||
r"https://www.feldroy.com/.*",
|
||||
]
|
||||
linkcheck_anchors_ignore_for_url = [
|
||||
# Requires JavaScript.
|
||||
# After opting-in to new GitHub issues, Sphinx can no longer grok the HTML anchor references.
|
||||
r"https://github.com",
|
||||
]
|
||||
|
||||
# -- Options for intersphinx extension ---------------------------------------
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {"https://docs.python.org/": None}
|
||||
intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
|
||||
|
||||
# -- Options for todo extension ----------------------------------------------
|
||||
|
||||
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
||||
todo_include_todos = True
|
||||
|
||||
# -- Options for MyST --------------------------------------------------------
|
||||
|
||||
myst_heading_anchors = 3
|
||||
myst_enable_extensions = [
|
||||
"attrs_block",
|
||||
"attrs_inline",
|
||||
"colon_fence",
|
||||
"deflist",
|
||||
"fieldlist",
|
||||
"html_admonition",
|
||||
"html_image",
|
||||
"linkify",
|
||||
"replacements",
|
||||
"strikethrough",
|
||||
"substitution",
|
||||
"tasklist",
|
||||
]
|
||||
myst_substitutions = {}
|
||||
|
||||
# -- Options for OpenGraph ---------------------------------------------------
|
||||
#
|
||||
# When making changes, check them using the RTD PR preview URL on https://www.opengraph.xyz/.
|
||||
#
|
||||
# About text lengths
|
||||
#
|
||||
# Original documentation says:
|
||||
# - ogp_description_length
|
||||
# Configure the amount of characters taken from a page. The default of 200 is probably good
|
||||
# for most people. If something other than a number is used, it defaults back to 200.
|
||||
# -- https://sphinxext-opengraph.readthedocs.io/en/latest/#options
|
||||
#
|
||||
# Other people say:
|
||||
# - og:title 40 chars
|
||||
# - og:description has 2 max lengths:
|
||||
# When the link is used in a Post, it's 300 chars. When a link is used in a Comment, it's 110 chars.
|
||||
# So you can either treat it as 110, or, write your Descriptions to 300 but make sure the first 110
|
||||
# is the critical part and still makes sense when it gets cut off.
|
||||
# -- https://stackoverflow.com/questions/8914476/facebook-open-graph-meta-tags-maximum-content-length
|
||||
ogp_site_url = "https://responder.kennethreitz.org/"
|
||||
ogp_description_length = 300
|
||||
ogp_site_name = "Responder Documentation"
|
||||
ogp_image = "https://responder.kennethreitz.org/_static/responder.png"
|
||||
ogp_image_alt = False
|
||||
ogp_use_first_image = False
|
||||
ogp_type = "website"
|
||||
ogp_enable_meta_description = True
|
||||
|
||||
# -- Options for sphinx-copybutton -------------------------------------------
|
||||
|
||||
copybutton_remove_prompts = True
|
||||
copybutton_line_continuation_character = "\\"
|
||||
copybutton_prompt_text = r">>> |\.\.\. |\$ |sh\$ |PS> |cr> |mysql> |In \[\d*\]: | {2,5}\.\.\.: | {5,8}: "
|
||||
copybutton_prompt_is_regexp = True
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
Deploying Responder
|
||||
===================
|
||||
|
||||
You can deploy Responder anywhere you can deploy a basic Python application.
|
||||
|
||||
Docker Deployment
|
||||
-----------------
|
||||
|
||||
Assuming an existing ``api.py`` containing your Responder application.
|
||||
|
||||
``Dockerfile``::
|
||||
|
||||
FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN pip install responder
|
||||
ENV PORT=80
|
||||
EXPOSE 80
|
||||
CMD ["python", "api.py"]
|
||||
|
||||
That's it!
|
||||
|
||||
Cloud Deployment
|
||||
----------------
|
||||
|
||||
Responder automatically honors the ``PORT`` environment variable, which is
|
||||
set by most cloud platforms (Fly.io, Railway, Render, Google Cloud Run, etc.).
|
||||
|
||||
The basics::
|
||||
|
||||
$ mkdir my-api
|
||||
$ cd my-api
|
||||
|
||||
Write out an ``api.py``::
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
@api.route("/")
|
||||
async def hello(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
|
||||
Deploy with your platform of choice. Responder will bind to ``0.0.0.0``
|
||||
on the port specified by ``PORT`` automatically.
|
||||
|
||||
Running with Uvicorn Directly
|
||||
-----------------------------
|
||||
|
||||
For production deployments, you can also run your app directly with uvicorn::
|
||||
|
||||
uvicorn api:api --host 0.0.0.0 --port 8000 --workers 4
|
||||
+128
-132
@@ -3,177 +3,173 @@
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
A familar HTTP Service Framework
|
||||
================================
|
||||
A familiar HTTP Service Framework
|
||||
=================================
|
||||
|
||||
The Python world certainly doesn't need more web frameworks. But, it does need more creativity, so I thought I'd bring some of my ideas to the table and see what I could come up with.
|
||||
|ci-tests| |version| |license| |python-versions| |downloads| |contributors| |say-thanks|
|
||||
|
||||
But will it blend?
|
||||
------------------
|
||||
.. |ci-tests| image:: https://github.com/kennethreitz/responder/actions/workflows/test.yaml/badge.svg
|
||||
:target: https://github.com/kennethreitz/responder/actions/workflows/test.yaml
|
||||
.. |ci-docs| image:: https://github.com/kennethreitz/responder/actions/workflows/docs.yaml/badge.svg
|
||||
:target: https://github.com/kennethreitz/responder/actions/workflows/docs.yaml
|
||||
.. |version| image:: https://img.shields.io/pypi/v/responder.svg
|
||||
:target: https://pypi.org/project/responder/
|
||||
.. |license| image:: https://img.shields.io/pypi/l/responder.svg
|
||||
:target: https://pypi.org/project/responder/
|
||||
.. |python-versions| image:: https://img.shields.io/pypi/pyversions/responder.svg
|
||||
:target: https://pypi.org/project/responder/
|
||||
.. |downloads| image:: https://static.pepy.tech/badge/responder/month
|
||||
:target: https://www.pepy.tech/projects/responder
|
||||
.. |contributors| image:: https://img.shields.io/github/contributors/kennethreitz/responder.svg
|
||||
:target: https://github.com/kennethreitz/responder/graphs/contributors
|
||||
.. |say-thanks| image:: https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg
|
||||
:target: https://saythanks.io/to/kennethreitz
|
||||
|
||||
::
|
||||
.. code:: python
|
||||
|
||||
import responder
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
api = responder.API()
|
||||
|
||||
@api.route("/{greeting}")
|
||||
def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
@api.route("/{greeting}")
|
||||
async def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
|
||||
if __name__ == '__main__':
|
||||
api.run()
|
||||
if __name__ == '__main__':
|
||||
api.run()
|
||||
|
||||
Responder is powered by `Starlette`_.
|
||||
|
||||
The example program demonstrates an `ASGI`_ application using `Responder`_,
|
||||
including production-ready components like the `uvicorn`_ webserver, based
|
||||
on `uvloop`_, and the `Jinja`_ templating library pre-installed.
|
||||
The ``async`` declaration within the example program is optional.
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
- A pleasant API, with a single import statement.
|
||||
- Class-based views without inheritance.
|
||||
- `ASGI`_, the future of Python web services.
|
||||
- Asynchronous Python frameworks and applications.
|
||||
- Automatic gzip compression.
|
||||
- WebSocket support!
|
||||
- The ability to mount any ASGI / WSGI app at a subroute.
|
||||
- `f-string syntax`_ route declaration.
|
||||
- Mutable response object, passed into each view. No need to return anything.
|
||||
- Background tasks, spawned off in a ``ThreadPoolExecutor``.
|
||||
- GraphQL (with *GraphiQL*) support!
|
||||
- OpenAPI schema generation, with interactive documentation!
|
||||
- Single-page webapp support!
|
||||
|
||||
Testimonials
|
||||
------------
|
||||
|
||||
“Pleasantly very taken with python-responder.
|
||||
`@kennethreitz`_ at his absolute best.”
|
||||
|
||||
— Rudraksh M.K.
|
||||
|
||||
..
|
||||
|
||||
"ASGI is going to enable all sorts of new high-performance web services. It's awesome to see Responder starting to take advantage of that."
|
||||
|
||||
— Tom Christie, author of `Django REST Framework`_
|
||||
|
||||
..
|
||||
|
||||
“I love that you are exploring new patterns. Go go go!”
|
||||
|
||||
— Danny Greenfield, author of `Two Scoops of Django`_
|
||||
|
||||
|
||||
This gets you a WSGI app, with WhiteNoise pre-installed, jinja2 templating (without additional imports), and a production webserver (ready for slowloris attacks), serving up requests with gzip compression automatically.
|
||||
User Guides
|
||||
-----------
|
||||
|
||||
-------------
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
Class-based views (and setting some headers and stuff)::
|
||||
|
||||
@api.route("/{greeting}")
|
||||
class GreetingResource:
|
||||
def on_request(req, resp, *, greeting): # or on_get...
|
||||
resp.text = f"{greeting}, world!"
|
||||
resp.headers.update({'X-Life': '42'})
|
||||
resp.status_code = api.status_codes.HTTP_416
|
||||
quickstart
|
||||
tour
|
||||
deployment
|
||||
testing
|
||||
api
|
||||
cli
|
||||
|
||||
|
||||
Render a template, with arguments::
|
||||
Installing Responder
|
||||
--------------------
|
||||
|
||||
Use ``uv`` for fast installation.
|
||||
|
||||
@api.route("/{greeting}")
|
||||
def greet_world(req, resp, *, greeting):
|
||||
resp.content = api.template("index.html", greeting=greeting)
|
||||
.. code-block:: shell
|
||||
|
||||
uv pip install --upgrade 'responder'
|
||||
|
||||
The ``api`` instance is available as an object during template rendering.
|
||||
Or use standard pip where ``uv`` is not available.
|
||||
|
||||
Serve a GraphQL API::
|
||||
.. code-block:: shell
|
||||
|
||||
import graphene
|
||||
pip install --upgrade 'responder'
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
Responder supports **Python 3.9+**.
|
||||
|
||||
def resolve_hello(self, info, name):
|
||||
return "Hello " + name
|
||||
Development
|
||||
-----------
|
||||
|
||||
api.add_route("/graph", graphene.Schema(query=Query))
|
||||
If you are looking at installing Responder
|
||||
for hacking on it, please refer to the :ref:`sandbox` documentation.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
We can then send a query to our service::
|
||||
>>> requests = api.session()
|
||||
>>> r = requests.get("http://;/graph", params={"query": "{ hello }"})
|
||||
>>> r.json()
|
||||
{'data': {'hello': 'Hello stranger'}}
|
||||
|
||||
|
||||
Or, request YAML back::
|
||||
|
||||
>>> r = requests.get("http://;/graph", params={"query": "{ hello(name:\"john\") }"}, headers={"Accept": "application/x-yaml"})
|
||||
>>> print(r.text)
|
||||
data: {hello: Hello john}
|
||||
|
||||
|
||||
|
||||
Want HSTS?
|
||||
|
||||
::
|
||||
|
||||
api = responder.API(enable_hsts=True)
|
||||
|
||||
|
||||
Boom. ✨🍰✨
|
||||
changes
|
||||
Sandbox <sandbox>
|
||||
backlog
|
||||
|
||||
|
||||
The Basic Idea
|
||||
--------------
|
||||
|
||||
The primary concept here is to bring the nicities that are brought forth from both Flask and Falcon and unify them into a single framework, along with some new ideas I have. I also wanted to take some of the API primitives that are instilled in the Requests library and put them into a web framework. So, you'll find a lot of parallels here with Requests.
|
||||
The primary concept here is to bring the niceties that are brought forth from both Flask and Falcon and unify them into a single framework, along with some new ideas I have. I also wanted to take some of the API primitives that are instilled in the Requests library and put them into a web framework. So, you'll find a lot of parallels here with Requests.
|
||||
|
||||
- Setting `resp.text` sends back unicode, while setting `resp.content` sends back bytes.
|
||||
- Setting `resp.media` sends back JSON/YAML (`.text`/`.content` override this).
|
||||
- Case-insensitive `req.headers` dict (from Requests directly).
|
||||
- `resp.status_code`, `req.method`, `req.url`, and other familar friends.
|
||||
- Setting ``resp.content`` sends back bytes.
|
||||
- Setting ``resp.text`` sends back unicode, while setting ``resp.html`` sends back HTML.
|
||||
- Setting ``resp.media`` sends back JSON/YAML (``.text``/``.html``/``.content`` override this).
|
||||
- Case-insensitive ``req.headers`` dict (from Requests directly).
|
||||
- ``resp.status_code``, ``req.method``, ``req.url``, and other familiar friends.
|
||||
|
||||
New Ideas
|
||||
---------
|
||||
Ideas
|
||||
-----
|
||||
|
||||
- **A built in testing client that uses the actual Requests you know and love**.
|
||||
- Flask-style route expression, with new capabilities -- using Python's f-string syntax.
|
||||
- I love Falcon's "every request and response is passed into each view and mutated" methodology, especially ``response.media``, and have used it here. In addition to supporting JSON, I have decided to support YAML as well, as Kubernetes is slowly taking over the world, and it uses YAML for all the things. Content-negotiation and all that.
|
||||
- **A built in testing client** powered by Starlette's TestClient.
|
||||
- The ability to mount other WSGI apps easily.
|
||||
- Automatic gzipped-responses (still working on that).
|
||||
- In addition to Falcon's ``on_get``, ``on_post``, etc methods, Responder features an `on_request` method, which gets called on every type of request, much like Requests.
|
||||
- WhiteNoise is built-in, for serving static files.
|
||||
- Waitress built-in as a production web server. I would have chosen Gunicorn, but it doesn't run on Windows. Plus, Waitress serves well to protect against slowloris attacks, making nginx unneccessary in production.
|
||||
- Automatic gzipped-responses.
|
||||
- In addition to Falcon's ``on_get``, ``on_post``, etc methods, Responder features an ``on_request`` method, which gets called on every type of request, much like Requests.
|
||||
- A production static files server is built-in.
|
||||
- `uvicorn`_ is built-in as a production web server. I would have chosen Gunicorn, but it doesn't run on Windows. Plus, uvicorn serves well to protect against `Slowloris`_ attacks, making Nginx unnecessary in production.
|
||||
- GraphQL support, via Graphene. The goal here is to have any GraphQL query exposable at any route, magically.
|
||||
|
||||
|
||||
Old Ideas
|
||||
---------
|
||||
|
||||
- Flask-style route expression, with new capabilities -- primarily, the ability to cast a parameter to integers as well as other types that are missing from Flask, all while using Python 3.6+'s new f-string syntax.
|
||||
|
||||
- I love Falcon's "every request and response is passed into to each view and mutated" methodology, especially `response.media`, and have used it here. In addition to supporting JSON, I have decided to support YAML as well, as Kubernetes is slowly taking over the world, and it uses YAML for all the things. Content-negotiation and all that.
|
||||
|
||||
Future Ideas
|
||||
------------
|
||||
|
||||
- I want to be able to "mount" any WSGI app into a sub-route.
|
||||
- Cooke-based sessions are currently an afterthrought, as this is an API framework, but websites are APIs too.
|
||||
- Potentially support ASGI instead of WSGI. Will the tradeoffs be worth it? This is a question to ask. Procedural code works well for 90% use cases.
|
||||
- If frontend websites are supported, provide an official way to run webpack.
|
||||
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
$ pipenv install responder
|
||||
✨🍰✨
|
||||
|
||||
Only **Python 3.6+** is supported.
|
||||
|
||||
|
||||
API Documentation
|
||||
=================
|
||||
|
||||
|
||||
Web Service (API) Class
|
||||
-----------------------
|
||||
.. module:: responder
|
||||
|
||||
.. autoclass:: API
|
||||
:inherited-members:
|
||||
|
||||
Requests & Responses
|
||||
--------------------
|
||||
|
||||
|
||||
.. autoclass:: Request
|
||||
:inherited-members:
|
||||
|
||||
.. autoclass:: Response
|
||||
:inherited-members:
|
||||
|
||||
|
||||
Utility Functions
|
||||
-----------------
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_100
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_200
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_300
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_400
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_500
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
|
||||
|
||||
.. _@kennethreitz: https://x.com/kennethreitz42
|
||||
.. _ASGI: https://en.wikipedia.org/wiki/Asynchronous_Server_Gateway_Interface
|
||||
.. _Django REST Framework: https://www.django-rest-framework.org/
|
||||
.. _f-string syntax: https://docs.python.org/3/whatsnew/3.6.html#pep-498-formatted-string-literals
|
||||
.. _Jinja: https://jinja.palletsprojects.com/en/stable/
|
||||
.. _ServeStatic: https://archmonger.github.io/ServeStatic/latest/
|
||||
.. _Slowloris: https://en.wikipedia.org/wiki/Slowloris_(computer_security)
|
||||
.. _Starlette: https://www.starlette.io/
|
||||
.. _Responder: https://responder.kennethreitz.org/
|
||||
.. _Two Scoops of Django: https://www.feldroy.com/two-scoops-press#two-scoops-of-django
|
||||
.. _uvicorn: https://www.uvicorn.org/
|
||||
.. _uvloop: https://uvloop.readthedocs.io/
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
Quick Start!
|
||||
============
|
||||
|
||||
This section of the documentation exists to provide an introduction to the Responder interface,
|
||||
as well as educate the user on basic functionality.
|
||||
|
||||
|
||||
Declare a Web Service
|
||||
---------------------
|
||||
|
||||
The first thing you need to do is declare a web service::
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
Hello World!
|
||||
------------
|
||||
|
||||
Then, you can add a view / route to it.
|
||||
|
||||
Here, we'll make the root URL say "hello world!"::
|
||||
|
||||
@api.route("/")
|
||||
def hello_world(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
Run the Server
|
||||
--------------
|
||||
|
||||
Next, we can run our web service easily, with ``api.run()``::
|
||||
|
||||
api.run()
|
||||
|
||||
This will spin up a production web server on port ``5042``, ready for incoming HTTP requests.
|
||||
|
||||
Note: you can pass ``port=5000`` if you want to customize the port. The ``PORT`` environment variable for established web service providers (e.g. Heroku) will automatically be honored and will set the listening address to ``0.0.0.0`` automatically (also configurable through the ``address`` keyword argument).
|
||||
|
||||
|
||||
Accept Route Arguments
|
||||
----------------------
|
||||
|
||||
If you want dynamic URLs, you can use Python's familiar *f-string syntax* to declare variables in your routes::
|
||||
|
||||
@api.route("/hello/{who}")
|
||||
def hello_to(req, resp, *, who):
|
||||
resp.text = f"hello, {who}!"
|
||||
|
||||
A ``GET`` request to ``/hello/brettcannon`` will result in a response of ``hello, brettcannon!``.
|
||||
|
||||
Type convertors are also available::
|
||||
|
||||
@api.route("/add/{a:int}/{b:int}")
|
||||
async def add(req, resp, *, a, b):
|
||||
resp.text = f"{a} + {b} = {a + b}"
|
||||
|
||||
Supported types: ``str``, ``int``, ``float``, ``uuid``, and ``path``.
|
||||
|
||||
Returning JSON / YAML
|
||||
---------------------
|
||||
|
||||
If you want your API to send back JSON, simply set the ``resp.media`` property to a JSON-serializable Python object::
|
||||
|
||||
|
||||
@api.route("/hello/{who}/json")
|
||||
def hello_to(req, resp, *, who):
|
||||
resp.media = {"hello": who}
|
||||
|
||||
A ``GET`` request to ``/hello/guido/json`` will result in a response of ``{'hello': 'guido'}``.
|
||||
|
||||
If the client requests YAML instead (with a header of ``Accept: application/x-yaml``), YAML will be sent.
|
||||
|
||||
Rendering a Template
|
||||
--------------------
|
||||
|
||||
Responder provides a built-in light `Jinja`_ wrapper ``templates.Templates``
|
||||
|
||||
Usage::
|
||||
|
||||
from responder.templates import Templates
|
||||
|
||||
templates = Templates()
|
||||
|
||||
@api.route("/hello/{name}/html")
|
||||
def hello(req, resp, name):
|
||||
resp.html = templates.render("hello.html", name=name)
|
||||
|
||||
|
||||
Also a ``render_async`` is available::
|
||||
|
||||
templates = Templates(enable_async=True)
|
||||
resp.html = await templates.render_async("hello.html", who=who)
|
||||
|
||||
You can also use the existing ``api.template(filename, *args, **kwargs)`` to render templates::
|
||||
|
||||
@api.route("/hello/{who}/html")
|
||||
def hello_html(req, resp, *, who):
|
||||
resp.html = api.template('hello.html', who=who)
|
||||
|
||||
|
||||
Setting Response Status Code
|
||||
----------------------------
|
||||
|
||||
If you want to set the response status code, simply set ``resp.status_code``::
|
||||
|
||||
@api.route("/416")
|
||||
def teapot(req, resp):
|
||||
resp.status_code = api.status_codes.HTTP_416 # ...or 416
|
||||
|
||||
|
||||
Setting Response Headers
|
||||
------------------------
|
||||
|
||||
If you want to set a response header, like ``X-Pizza: 42``, simply modify the ``resp.headers`` dictionary::
|
||||
|
||||
@api.route("/pizza")
|
||||
def pizza_pizza(req, resp):
|
||||
resp.headers['X-Pizza'] = '42'
|
||||
|
||||
That's it!
|
||||
|
||||
|
||||
Receiving Data & Background Tasks
|
||||
---------------------------------
|
||||
|
||||
If you're expecting to read any request data, on the server, you need to declare your view as async and await the content.
|
||||
|
||||
Here, we'll process our data in the background, while responding immediately to the client::
|
||||
|
||||
import time
|
||||
|
||||
@api.route("/incoming")
|
||||
async def receive_incoming(req, resp):
|
||||
|
||||
@api.background.task
|
||||
def process_data(data):
|
||||
"""Just sleeps for three seconds, as a demo."""
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
# Parse the incoming data as form-encoded.
|
||||
# Note: 'json' and 'yaml' formats are also automatically supported.
|
||||
data = await req.media()
|
||||
|
||||
# Process the data (in the background).
|
||||
process_data(data)
|
||||
|
||||
# Immediately respond that upload was successful.
|
||||
resp.media = {'success': True}
|
||||
|
||||
A ``POST`` request to ``/incoming`` will result in an immediate response of ``{'success': true}``.
|
||||
|
||||
|
||||
Here's a sample code to post a file with background::
|
||||
|
||||
@api.route("/")
|
||||
async def upload_file(req, resp):
|
||||
|
||||
@api.background.task
|
||||
def process_data(data):
|
||||
with open(f"./{data['file']['filename']}", 'wb') as f:
|
||||
f.write(data['file']['content'])
|
||||
|
||||
data = await req.media(format='files')
|
||||
process_data(data)
|
||||
|
||||
resp.media = {'success': 'ok'}
|
||||
|
||||
You can test file uploads using the built-in test client::
|
||||
|
||||
files = {'file': ('hello.txt', b'hello, world!', 'text/plain')}
|
||||
r = api.requests.post(api.url_for(upload_file), files=files)
|
||||
print(r.json())
|
||||
|
||||
|
||||
.. _Jinja: https://jinja.palletsprojects.com/en/stable/
|
||||
@@ -0,0 +1,35 @@
|
||||
(sandbox)=
|
||||
# Development Sandbox
|
||||
|
||||
## Setup
|
||||
Set up a development sandbox.
|
||||
|
||||
Acquire sources and create virtualenv.
|
||||
```shell
|
||||
git clone https://github.com/kennethreitz/responder.git
|
||||
cd responder
|
||||
uv venv
|
||||
```
|
||||
|
||||
Install project in editable mode, including
|
||||
all runtime extensions and development tools.
|
||||
```shell
|
||||
uv pip install --upgrade --editable '.[develop,docs,release,test]'
|
||||
```
|
||||
|
||||
## Operations
|
||||
Invoke linter and software tests.
|
||||
```shell
|
||||
source .venv/bin/activate
|
||||
poe check
|
||||
```
|
||||
|
||||
Format code.
|
||||
```shell
|
||||
poe format
|
||||
```
|
||||
|
||||
Documentation authoring.
|
||||
```shell
|
||||
poe docs-autobuild
|
||||
```
|
||||
@@ -0,0 +1,48 @@
|
||||
Building and Testing with Responder
|
||||
===================================
|
||||
|
||||
Responder comes with a first-class, well supported test client for your ASGI web services (powered by Starlette's TestClient).
|
||||
|
||||
Here, we'll go over the basics of setting up and testing a Responder application.
|
||||
|
||||
The Basics
|
||||
----------
|
||||
|
||||
Your project should look like this::
|
||||
|
||||
api.py test_api.py
|
||||
|
||||
``$ cat api.py``::
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
@api.route("/")
|
||||
def hello_world(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
|
||||
Writing Tests
|
||||
-------------
|
||||
|
||||
``$ cat test_api.py``::
|
||||
|
||||
import pytest
|
||||
import api as service
|
||||
|
||||
@pytest.fixture
|
||||
def api():
|
||||
return service.api
|
||||
|
||||
|
||||
def test_hello_world(api):
|
||||
r = api.requests.get("/")
|
||||
assert r.text == "hello, world!"
|
||||
|
||||
``$ pytest``::
|
||||
|
||||
...
|
||||
========================== 1 passed in 0.10 seconds ==========================
|
||||
@@ -0,0 +1,507 @@
|
||||
Feature Tour
|
||||
============
|
||||
|
||||
|
||||
Route Method Filtering
|
||||
----------------------
|
||||
|
||||
You can restrict routes to specific HTTP methods::
|
||||
|
||||
@api.route("/items", methods=["GET"])
|
||||
def list_items(req, resp):
|
||||
resp.media = {"items": [...]}
|
||||
|
||||
@api.route("/items", methods=["POST"], check_existing=False)
|
||||
async def create_item(req, resp):
|
||||
data = await req.media()
|
||||
resp.media = {"created": data}
|
||||
|
||||
|
||||
Class-Based Views
|
||||
-----------------
|
||||
|
||||
Class-based views (and setting some headers and stuff)::
|
||||
|
||||
@api.route("/{greeting}")
|
||||
class GreetingResource:
|
||||
def on_request(self, req, resp, *, greeting): # or on_get...
|
||||
resp.text = f"{greeting}, world!"
|
||||
resp.headers.update({'X-Life': '42'})
|
||||
resp.status_code = api.status_codes.HTTP_416
|
||||
|
||||
|
||||
Lifespan Events
|
||||
---------------
|
||||
|
||||
Use the lifespan context manager for startup and shutdown logic::
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
# Startup: connect to database, etc.
|
||||
print("Starting up...")
|
||||
yield
|
||||
# Shutdown: clean up resources
|
||||
print("Shutting down...")
|
||||
|
||||
api = responder.API(lifespan=lifespan)
|
||||
|
||||
You can also use the traditional event decorators::
|
||||
|
||||
@api.on_event('startup')
|
||||
async def startup():
|
||||
print("Starting up...")
|
||||
|
||||
@api.on_event('shutdown')
|
||||
async def shutdown():
|
||||
print("Shutting down...")
|
||||
|
||||
|
||||
Serving Files
|
||||
-------------
|
||||
|
||||
Serve files from disk with automatic content-type detection::
|
||||
|
||||
@api.route("/download")
|
||||
def download(req, resp):
|
||||
resp.file("reports/annual.pdf")
|
||||
|
||||
You can also specify the content type explicitly::
|
||||
|
||||
@api.route("/image")
|
||||
def image(req, resp):
|
||||
resp.file("photos/cat.jpg", content_type="image/jpeg")
|
||||
|
||||
|
||||
Custom Error Handling
|
||||
---------------------
|
||||
|
||||
Register handlers for specific exception types::
|
||||
|
||||
@api.exception_handler(ValueError)
|
||||
async def handle_value_error(req, resp, exc):
|
||||
resp.status_code = 400
|
||||
resp.media = {"error": str(exc)}
|
||||
|
||||
|
||||
Background Tasks
|
||||
----------------
|
||||
|
||||
Here, you can spawn off a background thread to run any function, out-of-request::
|
||||
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
|
||||
@api.background.task
|
||||
def sleep(s=10):
|
||||
time.sleep(s)
|
||||
print("slept!")
|
||||
|
||||
sleep()
|
||||
resp.content = "processing"
|
||||
|
||||
|
||||
GraphQL
|
||||
-------
|
||||
Responder supports GraphQL, a query language for APIs that enables clients to
|
||||
request exactly the data they need.
|
||||
|
||||
For more information about GraphQL, visit https://graphql.org/.
|
||||
|
||||
Serve a GraphQL API::
|
||||
|
||||
import graphene
|
||||
from responder.ext.graphql import GraphQLView
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
|
||||
def resolve_hello(self, info, name):
|
||||
return f"Hello {name}"
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
view = GraphQLView(api=api, schema=schema)
|
||||
|
||||
api.add_route("/graph", view)
|
||||
|
||||
Visiting the endpoint will render a *GraphiQL* instance, in the browser.
|
||||
|
||||
You can make use of Responder's Request and Response objects in your GraphQL resolvers through ``info.context['request']`` and ``info.context['response']``.
|
||||
|
||||
|
||||
OpenAPI Schema Support
|
||||
----------------------
|
||||
|
||||
Responder comes with built-in support for OpenAPI / marshmallow.
|
||||
|
||||
.. note::
|
||||
|
||||
If you're upgrading from a previous version, note that the OpenAPI module
|
||||
has been renamed from ``responder.ext.schema`` to ``responder.ext.openapi``.
|
||||
Update your imports accordingly.
|
||||
|
||||
New in Responder 1.4.0::
|
||||
|
||||
import responder
|
||||
from responder.ext.openapi import OpenAPISchema
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
contact = {
|
||||
"name": "API Support",
|
||||
"url": "http://www.example.com/support",
|
||||
"email": "support@example.com",
|
||||
}
|
||||
license = {
|
||||
"name": "Apache 2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
|
||||
}
|
||||
|
||||
api = responder.API()
|
||||
|
||||
schema = OpenAPISchema(
|
||||
app=api,
|
||||
title="Web Service",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
description="A simple pet store",
|
||||
terms_of_service="http://example.com/terms/",
|
||||
contact=contact,
|
||||
license=license,
|
||||
)
|
||||
|
||||
@schema.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
"""A cute furry animal endpoint.
|
||||
---
|
||||
get:
|
||||
description: Get a random pet
|
||||
responses:
|
||||
200:
|
||||
description: A pet to be returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Pet'
|
||||
"""
|
||||
resp.media = PetSchema().dump({"name": "little orange"})
|
||||
|
||||
|
||||
Old way *It's recommended to use the code above* ::
|
||||
|
||||
import responder
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
contact = {
|
||||
"name": "API Support",
|
||||
"url": "http://www.example.com/support",
|
||||
"email": "support@example.com",
|
||||
}
|
||||
license = {
|
||||
"name": "Apache 2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
|
||||
}
|
||||
|
||||
api = responder.API(
|
||||
title="Web Service",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
description="A simple pet store",
|
||||
terms_of_service="http://example.com/terms/",
|
||||
contact=contact,
|
||||
license=license,
|
||||
)
|
||||
|
||||
@api.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
"""A cute furry animal endpoint.
|
||||
---
|
||||
get:
|
||||
description: Get a random pet
|
||||
responses:
|
||||
200:
|
||||
description: A pet to be returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Pet'
|
||||
"""
|
||||
resp.media = PetSchema().dump({"name": "little orange"})
|
||||
|
||||
::
|
||||
|
||||
>>> r = api.session().get("http://;/schema.yml")
|
||||
|
||||
>>> print(r.text)
|
||||
components:
|
||||
parameters: {}
|
||||
responses: {}
|
||||
schemas:
|
||||
Pet:
|
||||
properties:
|
||||
name: {type: string}
|
||||
type: object
|
||||
securitySchemes: {}
|
||||
info:
|
||||
contact: {email: support@example.com, name: API Support, url: 'http://www.example.com/support'}
|
||||
description: This is a sample server for a pet store.
|
||||
license: {name: Apache 2.0, url: 'https://www.apache.org/licenses/LICENSE-2.0.html'}
|
||||
termsOfService: http://example.com/terms/
|
||||
title: Web Service
|
||||
version: 1.0
|
||||
openapi: 3.0.2
|
||||
paths:
|
||||
/:
|
||||
get:
|
||||
description: Get a random pet
|
||||
responses:
|
||||
200: {description: A pet to be returned, schema: $ref: "#/components/schemas/Pet"}
|
||||
tags: []
|
||||
|
||||
|
||||
Interactive Documentation
|
||||
-------------------------
|
||||
|
||||
Responder can automatically supply API Documentation for you. Using the example above
|
||||
|
||||
The new and recommended way::
|
||||
|
||||
from responder.ext.openapi import OpenAPISchema
|
||||
|
||||
api = responder.API()
|
||||
|
||||
schema = OpenAPISchema(
|
||||
app=api,
|
||||
title="Web Service",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
...
|
||||
docs_route='/docs',
|
||||
...
|
||||
description=description,
|
||||
terms_of_service=terms_of_service,
|
||||
contact=contact,
|
||||
license=license,
|
||||
)
|
||||
|
||||
|
||||
The old way::
|
||||
|
||||
api = responder.API(
|
||||
title="Web Service",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
docs_route='/docs',
|
||||
description=description,
|
||||
terms_of_service=terms_of_service,
|
||||
contact=contact,
|
||||
license=license,
|
||||
)
|
||||
|
||||
This will make ``/docs`` render interactive documentation for your API.
|
||||
|
||||
Mount a WSGI / ASGI Apps (e.g. Flask, Starlette,...)
|
||||
----------------------------------------------------
|
||||
|
||||
Responder gives you the ability to mount another ASGI / WSGI app at a subroute::
|
||||
|
||||
import responder
|
||||
from flask import Flask
|
||||
|
||||
api = responder.API()
|
||||
flask = Flask(__name__)
|
||||
|
||||
@flask.route('/')
|
||||
def hello():
|
||||
return 'hello'
|
||||
|
||||
api.mount('/flask', flask)
|
||||
|
||||
That's it!
|
||||
|
||||
Single-Page Web Apps
|
||||
--------------------
|
||||
|
||||
If you have a single-page webapp, you can tell Responder to serve up your ``static/index.html`` at a route, like so::
|
||||
|
||||
api.add_route("/", static=True)
|
||||
|
||||
This will make ``index.html`` the default response to all undefined routes.
|
||||
|
||||
Reading / Writing Cookies
|
||||
-------------------------
|
||||
|
||||
Responder makes it very easy to interact with cookies from a Request, or add some to a Response::
|
||||
|
||||
>>> resp.cookies["hello"] = "world"
|
||||
|
||||
>>> req.cookies
|
||||
{"hello": "world"}
|
||||
|
||||
|
||||
To set cookies directives, you should use `resp.set_cookie`::
|
||||
|
||||
>>> resp.set_cookie("hello", value="world", max_age=60)
|
||||
|
||||
Supported directives:
|
||||
|
||||
* ``key`` - **Required**
|
||||
* ``value`` - [OPTIONAL] - Defaults to ``""``.
|
||||
* ``expires`` - Defaults to ``None``.
|
||||
* ``max_age`` - Defaults to ``None``.
|
||||
* ``domain`` - Defaults to ``None``.
|
||||
* ``path`` - Defaults to ``"/"``.
|
||||
* ``secure`` - Defaults to ``False``.
|
||||
* ``httponly`` - Defaults to ``True``.
|
||||
|
||||
For more information see `directives <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie>`_
|
||||
|
||||
|
||||
Using Cookie-Based Sessions
|
||||
---------------------------
|
||||
|
||||
Responder has built-in support for cookie-based sessions. To enable cookie-based sessions, simply add something to the ``resp.session`` dictionary::
|
||||
|
||||
>>> resp.session['username'] = 'kennethreitz'
|
||||
|
||||
A cookie called ``Responder-Session`` will be set, which contains all the data in ``resp.session``. It is signed, for verification purposes.
|
||||
|
||||
You can easily read a Request's session data, that can be trusted to have originated from the API::
|
||||
|
||||
>>> req.session
|
||||
{'username': 'kennethreitz'}
|
||||
|
||||
**Note**: if you are using this in production, you should pass the ``secret_key`` argument to ``API(...)``::
|
||||
|
||||
api = responder.API(secret_key=os.environ['SECRET_KEY'])
|
||||
|
||||
Using ``before_request``
|
||||
------------------------
|
||||
|
||||
If you'd like a view to be executed before every request, simply do the following::
|
||||
|
||||
@api.route(before_request=True)
|
||||
def prepare_response(req, resp):
|
||||
resp.headers["X-Pizza"] = "42"
|
||||
|
||||
Now all requests to your HTTP Service will include an ``X-Pizza`` header.
|
||||
|
||||
For ``websockets``::
|
||||
|
||||
@api.route(before_request=True, websocket=True)
|
||||
def prepare_response(ws):
|
||||
await ws.accept()
|
||||
|
||||
|
||||
WebSocket Support
|
||||
-----------------
|
||||
|
||||
Responder supports WebSockets::
|
||||
|
||||
@api.route('/ws', websocket=True)
|
||||
async def websocket(ws):
|
||||
await ws.accept()
|
||||
while True:
|
||||
name = await ws.receive_text()
|
||||
await ws.send_text(f"Hello {name}!")
|
||||
await ws.close()
|
||||
|
||||
Accepting the connection::
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
Sending and receiving data::
|
||||
|
||||
await websocket.send_{format}(data)
|
||||
await websocket.receive_{format}(data)
|
||||
|
||||
Supported formats: ``text``, ``json``, ``bytes``.
|
||||
|
||||
Closing the connection::
|
||||
|
||||
await websocket.close()
|
||||
|
||||
Using Requests Test Client
|
||||
--------------------------
|
||||
|
||||
Responder comes with a first-class, well supported test client for your ASGI web services (powered by Starlette's TestClient).
|
||||
|
||||
Here's an example of a test (written with pytest)::
|
||||
|
||||
import myapi
|
||||
|
||||
@pytest.fixture
|
||||
def api():
|
||||
return myapi.api
|
||||
|
||||
def test_response(api):
|
||||
hello = "hello, world!"
|
||||
|
||||
@api.route('/some-url')
|
||||
def some_view(req, resp):
|
||||
resp.text = hello
|
||||
|
||||
r = api.requests.get(url=api.url_for(some_view))
|
||||
assert r.text == hello
|
||||
|
||||
HSTS (Redirect to HTTPS)
|
||||
------------------------
|
||||
|
||||
Want HSTS (to redirect all traffic to HTTPS)?
|
||||
|
||||
::
|
||||
|
||||
api = responder.API(enable_hsts=True)
|
||||
|
||||
|
||||
Boom.
|
||||
|
||||
CORS
|
||||
----
|
||||
|
||||
Want `CORS <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/>`_ ?
|
||||
|
||||
::
|
||||
|
||||
api = responder.API(cors=True)
|
||||
|
||||
|
||||
The default parameters used by **Responder** are restrictive by default, so you'll need to explicitly enable particular origins, methods, or headers, in order for browsers to be permitted to use them in a Cross-Domain context.
|
||||
|
||||
In order to set custom parameters, you need to set the ``cors_params`` argument of ``api``, a dictionary containing the following entries:
|
||||
|
||||
* ``allow_origins`` - A list of origins that should be permitted to make cross-origin requests. eg. ``['https://example.org', 'https://www.example.org']``. You can use ``['*']`` to allow any origin.
|
||||
* ``allow_origin_regex`` - A regex string to match against origins that should be permitted to make cross-origin requests. eg. ``'https://.*\.example\.org'``.
|
||||
* ``allow_methods`` - A list of HTTP methods that should be allowed for cross-origin requests. Defaults to `['GET']`. You can use ``['*']`` to allow all standard methods.
|
||||
* ``allow_headers`` - A list of HTTP request headers that should be supported for cross-origin requests. Defaults to ``[]``. You can use ``['*']`` to allow all headers. The ``Accept``, ``Accept-Language``, ``Content-Language`` and ``Content-Type`` headers are always allowed for CORS requests.
|
||||
* ``allow_credentials`` - Indicate that cookies should be supported for cross-origin requests. Defaults to ``False``.
|
||||
* ``expose_headers`` - Indicate any response headers that should be made accessible to the browser. Defaults to ``[]``.
|
||||
* ``max_age`` - Sets a maximum time in seconds for browsers to cache CORS responses. Defaults to ``60``.
|
||||
|
||||
Trusted Hosts
|
||||
-------------
|
||||
|
||||
Make sure that all the incoming requests headers have a valid ``host``, that matches one of the provided patterns in the ``allowed_hosts`` attribute, in order to prevent HTTP Host Header attacks.
|
||||
|
||||
A 400 response will be raised, if a request does not match any of the provided patterns in the ``allowed_hosts`` attribute.
|
||||
|
||||
::
|
||||
|
||||
api = responder.API(allowed_hosts=['example.com', 'tenant.example.com'])
|
||||
|
||||
* ``allowed_hosts`` - A list of allowed hostnames.
|
||||
|
||||
Note:
|
||||
|
||||
* By default, all hostnames are allowed.
|
||||
* Wildcard domains such as ``*.example.com`` are supported.
|
||||
* To allow any hostname use ``allowed_hosts=["*"]``.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Example HTTP service definition, using Responder.
|
||||
# https://pypi.org/project/responder/
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
|
||||
@api.route("/")
|
||||
async def index(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
|
||||
@api.route("/{greeting}")
|
||||
async def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
@@ -0,0 +1,26 @@
|
||||
# Example showing the lifespan context manager pattern.
|
||||
# https://pypi.org/project/responder/
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import responder
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
# Startup: initialize resources
|
||||
print("Starting up...")
|
||||
yield
|
||||
# Shutdown: clean up resources
|
||||
print("Shutting down...")
|
||||
|
||||
|
||||
api = responder.API(lifespan=lifespan)
|
||||
|
||||
|
||||
@api.route("/{greeting}")
|
||||
async def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
@@ -0,0 +1,25 @@
|
||||
# Example HTTP service definition, using Responder.
|
||||
# https://pypi.org/project/responder/
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
|
||||
@api.route("/")
|
||||
async def index(req, resp):
|
||||
resp.text = "Welcome"
|
||||
|
||||
|
||||
@api.route("/user")
|
||||
async def user_create(req, resp):
|
||||
data = await req.media()
|
||||
resp.text = f"Hello, {data['username']}"
|
||||
|
||||
|
||||
@api.route("/user/{identifier}")
|
||||
async def user_get(req, resp, *, identifier):
|
||||
resp.text = f"Hello, user {identifier}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
[build-system]
|
||||
build-backend = "setuptools.build_meta"
|
||||
requires = [
|
||||
"setuptools>=42",
|
||||
]
|
||||
|
||||
[project]
|
||||
name = "responder"
|
||||
description = "A familiar HTTP Service Framework for Python."
|
||||
readme = "README.md"
|
||||
license = {text = "Apache 2.0"}
|
||||
authors = [
|
||||
{ name = "Kenneth Reitz", email = "me@kennethreitz.org" },
|
||||
]
|
||||
requires-python = ">=3.9"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Web Environment",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
dependencies = [
|
||||
"a2wsgi",
|
||||
"apispec>=1.0.0",
|
||||
"chardet",
|
||||
"docopt-ng",
|
||||
"graphene>=3",
|
||||
"graphql-core>=3.1",
|
||||
"marshmallow",
|
||||
"pueblo[sfa-full]>=0.0.11",
|
||||
"python-multipart",
|
||||
"starlette[full]>=0.40",
|
||||
"uvicorn[standard]",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
develop = [
|
||||
"poethepoet",
|
||||
"pyproject-fmt",
|
||||
"ruff",
|
||||
"validate-pyproject",
|
||||
]
|
||||
docs = [
|
||||
"alabaster<1.1",
|
||||
"myst-parser[linkify]",
|
||||
"sphinx>=5,<9",
|
||||
"sphinx-autobuild",
|
||||
"sphinx-copybutton",
|
||||
"sphinx-design-elements",
|
||||
"sphinxext.opengraph",
|
||||
]
|
||||
release = ["build", "twine"]
|
||||
test = [
|
||||
"flask",
|
||||
"mypy",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-mock",
|
||||
"pytest-rerunfailures",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
responder = "responder.ext.cli:cli"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/kennethreitz/responder"
|
||||
Documentation = "https://responder.kennethreitz.org"
|
||||
Repository = "https://github.com/kennethreitz/responder"
|
||||
Issues = "https://github.com/kennethreitz/responder/issues"
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "responder.__version__.__version__"}
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
responder = ["py.typed"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
exclude = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 90
|
||||
|
||||
extend-exclude = [
|
||||
"docs/source/conf.py",
|
||||
]
|
||||
|
||||
lint.select = [
|
||||
# Builtins
|
||||
"A",
|
||||
# Bugbear
|
||||
"B",
|
||||
# comprehensions
|
||||
"C4",
|
||||
# Pycodestyle
|
||||
"E",
|
||||
# eradicate
|
||||
"ERA",
|
||||
# Pyflakes
|
||||
"F",
|
||||
# isort
|
||||
"I",
|
||||
# pandas-vet
|
||||
"PD",
|
||||
# return
|
||||
"RET",
|
||||
# Bandit
|
||||
"S",
|
||||
# print
|
||||
"T20",
|
||||
"W",
|
||||
# flake8-2020
|
||||
"YTT",
|
||||
]
|
||||
|
||||
lint.extend-ignore = [
|
||||
"S101", # Allow use of `assert`.
|
||||
]
|
||||
|
||||
lint.per-file-ignores."responder/util/cmd.py" = [ "A005" ] # Module shadows a Python standard-library module
|
||||
|
||||
lint.per-file-ignores."tests/*" = [
|
||||
"ERA001", # Found commented-out code.
|
||||
"S101", # Allow use of `assert`, and `print`.
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = """
|
||||
-rfEXs -p pytester --strict-markers --verbosity=3
|
||||
--cov --cov-report=term-missing --cov-report=xml
|
||||
"""
|
||||
filterwarnings = [
|
||||
"error::UserWarning",
|
||||
]
|
||||
log_level = "DEBUG"
|
||||
log_cli_level = "DEBUG"
|
||||
log_format = "%(asctime)-15s [%(name)-36s] %(levelname)-8s: %(message)s"
|
||||
minversion = "2.0"
|
||||
testpaths = [
|
||||
"responder",
|
||||
"tests",
|
||||
]
|
||||
markers = [
|
||||
]
|
||||
xfail_strict = true
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = false
|
||||
omit = [
|
||||
"*.html",
|
||||
"tests/*",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 0
|
||||
show_missing = true
|
||||
exclude_lines = [
|
||||
"# pragma: no cover",
|
||||
"raise NotImplemented",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
packages = [
|
||||
"responder",
|
||||
]
|
||||
exclude = [
|
||||
]
|
||||
check_untyped_defs = true
|
||||
explicit_package_bases = true
|
||||
ignore_missing_imports = true
|
||||
implicit_optional = true
|
||||
install_types = true
|
||||
namespace_packages = true
|
||||
non_interactive = true
|
||||
|
||||
[tool.poe.tasks]
|
||||
|
||||
check = [
|
||||
"test",
|
||||
]
|
||||
|
||||
docs-autobuild = [
|
||||
{ cmd = "sphinx-autobuild --open-browser --watch docs/source docs/source docs/build" },
|
||||
]
|
||||
docs-html = [
|
||||
{ cmd = "sphinx-build -W --keep-going docs/source docs/build" },
|
||||
]
|
||||
docs-linkcheck = [
|
||||
{ cmd = "sphinx-build -W --keep-going -b linkcheck docs/source docs/build" },
|
||||
]
|
||||
|
||||
format = [
|
||||
{ cmd = "ruff format ." },
|
||||
# Configure Ruff not to auto-fix (remove!):
|
||||
# unused imports (F401), unused variables (F841), `print` statements (T201), and commented-out code (ERA001).
|
||||
{ cmd = "ruff check --fix --ignore=ERA --ignore=F401 --ignore=F841 --ignore=T20 --ignore=ERA001 ." },
|
||||
{ cmd = "pyproject-fmt --keep-full-version pyproject.toml" },
|
||||
]
|
||||
|
||||
lint = [
|
||||
{ cmd = "ruff format --check ." },
|
||||
{ cmd = "ruff check ." },
|
||||
{ cmd = "validate-pyproject pyproject.toml" },
|
||||
{ cmd = "mypy" },
|
||||
]
|
||||
|
||||
release = [
|
||||
{ cmd = "python -m build" },
|
||||
{ cmd = "twine upload --skip-existing dist/*" },
|
||||
]
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
cmd = "pytest"
|
||||
help = "Invoke software tests"
|
||||
|
||||
[tool.poe.tasks.test.args.expression]
|
||||
options = [ "-k" ]
|
||||
|
||||
[tool.poe.tasks.test.args.marker]
|
||||
options = [ "-m" ]
|
||||
@@ -1,5 +0,0 @@
|
||||
build:
|
||||
image: latest
|
||||
|
||||
python:
|
||||
version: 3.6
|
||||
+18
-1
@@ -1 +1,18 @@
|
||||
from .core import *
|
||||
"""
|
||||
Responder - a familiar HTTP Service Framework.
|
||||
|
||||
This module exports the core functionality of the Responder framework,
|
||||
including the API, Request, and Response classes.
|
||||
"""
|
||||
|
||||
from . import ext
|
||||
from .__version__ import __version__
|
||||
from .core import API, Request, Response
|
||||
|
||||
__all__ = [
|
||||
"API",
|
||||
"Request",
|
||||
"Response",
|
||||
"__version__",
|
||||
"ext",
|
||||
]
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.0.1"
|
||||
__version__ = "3.0.0"
|
||||
|
||||
+343
-252
@@ -1,243 +1,340 @@
|
||||
import asyncio
|
||||
import os
|
||||
import json
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
import waitress
|
||||
__all__ = ["API"]
|
||||
|
||||
import jinja2
|
||||
from whitenoise import WhiteNoise
|
||||
from wsgiadapter import WSGIAdapter as RequestsWSGIAdapter
|
||||
from requests import Session as RequestsSession
|
||||
from werkzeug.wsgi import DispatcherMiddleware
|
||||
from graphql_server import encode_execution_results, json_encode, default_format_error
|
||||
import uvicorn
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.errors import ServerErrorMiddleware
|
||||
from starlette.middleware.exceptions import ExceptionMiddleware
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
|
||||
from . import models
|
||||
from .status_codes import HTTP_404
|
||||
from . import status_codes
|
||||
from .routes import Route
|
||||
from .background import BackgroundQueue
|
||||
from .formats import get_formats
|
||||
from .models import Request, Response
|
||||
from .routes import Router
|
||||
from .staticfiles import StaticFiles
|
||||
from .statics import DEFAULT_CORS_PARAMS, DEFAULT_OPENAPI_THEME, DEFAULT_SECRET_KEY
|
||||
from .templates import Templates
|
||||
|
||||
|
||||
# TODO: consider moving status codes here
|
||||
class API:
|
||||
"""The primary web-service class.
|
||||
|
||||
:param static_dir: The directory to use for static files. Will be created for you if it doesn't already exist.
|
||||
:param templates_dir: The directory to use for templates. Will be created for you if it doesn't already exist.
|
||||
:param enable_hsts: If ``True``, send all responses to HTTPS URLs.
|
||||
"""
|
||||
:param static_dir: The directory to use for static files. Will be created for you if it doesn't already exist.
|
||||
:param templates_dir: The directory to use for templates. Will be created for you if it doesn't already exist.
|
||||
:param auto_escape: If ``True``, HTML and XML templates will automatically be escaped.
|
||||
:param enable_hsts: If ``True``, send all responses to HTTPS URLs.
|
||||
:param openapi_theme: OpenAPI documentation theme, must be one of ``elements``, ``rapidoc``, ``redoc``, ``swagger_ui``
|
||||
""" # noqa: E501
|
||||
|
||||
status_codes = status_codes
|
||||
|
||||
def __init__(
|
||||
self, static_dir="static", templates_dir="templates", enable_hsts=False
|
||||
self,
|
||||
*,
|
||||
debug=False,
|
||||
title=None,
|
||||
version=None,
|
||||
description=None,
|
||||
terms_of_service=None,
|
||||
contact=None,
|
||||
license=None, # noqa: A002
|
||||
openapi=None,
|
||||
openapi_route="/schema.yml",
|
||||
static_dir="static",
|
||||
static_route="/static",
|
||||
templates_dir="templates",
|
||||
auto_escape=True,
|
||||
secret_key=DEFAULT_SECRET_KEY,
|
||||
enable_hsts=False,
|
||||
docs_route=None,
|
||||
cors=False,
|
||||
cors_params=DEFAULT_CORS_PARAMS,
|
||||
allowed_hosts=None,
|
||||
openapi_theme=DEFAULT_OPENAPI_THEME,
|
||||
lifespan=None,
|
||||
):
|
||||
self.static_dir = Path(os.path.abspath(static_dir))
|
||||
self.templates_dir = Path(os.path.abspath(templates_dir))
|
||||
self.routes = {}
|
||||
self.background = BackgroundQueue()
|
||||
|
||||
self.secret_key = secret_key
|
||||
|
||||
self.router = Router(lifespan=lifespan)
|
||||
|
||||
if static_dir is not None:
|
||||
if static_route is None:
|
||||
static_route = ""
|
||||
static_dir = Path(static_dir).resolve()
|
||||
|
||||
self.static_dir = static_dir
|
||||
self.static_route = static_route
|
||||
|
||||
self.hsts_enabled = enable_hsts
|
||||
self.apps = {"/": self._wsgi_app}
|
||||
self.cors = cors
|
||||
self.cors_params = cors_params
|
||||
self.debug = debug
|
||||
|
||||
if not allowed_hosts:
|
||||
allowed_hosts = ["*"]
|
||||
self.allowed_hosts = allowed_hosts
|
||||
|
||||
if self.static_dir is not None:
|
||||
self.static_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.mount(self.static_route, self.static_app)
|
||||
|
||||
self.formats = get_formats()
|
||||
|
||||
# Make the static/templates directory if they don't exist.
|
||||
for _dir in (self.static_dir, self.templates_dir):
|
||||
os.makedirs(_dir, exist_ok=True)
|
||||
|
||||
# Mount the whitenoise application.
|
||||
self.whitenoise = WhiteNoise(self.__wsgi_app, root=str(self.static_dir))
|
||||
|
||||
# Cached requests session.
|
||||
self._session = None
|
||||
|
||||
def __wsgi_app(self, environ, start_response):
|
||||
# def wsgi_app(self, request):
|
||||
"""The actual WSGI application. This is not implemented in
|
||||
:meth:`__call__` so that middlewares can be applied without
|
||||
losing a reference to the app object. Instead of doing this::
|
||||
self.default_endpoint = None
|
||||
self.app = ExceptionMiddleware(self.router, debug=debug)
|
||||
self.add_middleware(GZipMiddleware)
|
||||
|
||||
app = MyMiddleware(app)
|
||||
if self.hsts_enabled:
|
||||
self.add_middleware(HTTPSRedirectMiddleware)
|
||||
|
||||
It's a better idea to do this instead::
|
||||
self.add_middleware(TrustedHostMiddleware, allowed_hosts=self.allowed_hosts)
|
||||
|
||||
app.wsgi_app = MyMiddleware(app.wsgi_app)
|
||||
if self.cors:
|
||||
self.add_middleware(CORSMiddleware, **self.cors_params)
|
||||
self.add_middleware(ServerErrorMiddleware, debug=debug)
|
||||
self.add_middleware(SessionMiddleware, secret_key=self.secret_key)
|
||||
|
||||
Then you still have the original application object around and
|
||||
can continue to call methods on it.
|
||||
if openapi or docs_route:
|
||||
try:
|
||||
from .ext.openapi import OpenAPISchema
|
||||
except ImportError as ex:
|
||||
raise ImportError(
|
||||
"The dependencies for the OpenAPI extension are not installed. "
|
||||
"Install them using: pip install responder"
|
||||
) from ex
|
||||
|
||||
.. versionchanged:: 0.7
|
||||
Teardown events for the request and app contexts are called
|
||||
even if an unhandled error occurs. Other events may not be
|
||||
called depending on when an error occurs during dispatch.
|
||||
See :ref:`callbacks-and-errors`.
|
||||
self.openapi = OpenAPISchema(
|
||||
app=self,
|
||||
title=title,
|
||||
version=version,
|
||||
openapi=openapi,
|
||||
docs_route=docs_route,
|
||||
description=description,
|
||||
terms_of_service=terms_of_service,
|
||||
contact=contact,
|
||||
license=license,
|
||||
openapi_route=openapi_route,
|
||||
static_route=static_route,
|
||||
openapi_theme=openapi_theme,
|
||||
)
|
||||
|
||||
self.templates = Templates(directory=templates_dir)
|
||||
|
||||
@property
|
||||
def requests(self):
|
||||
"""A test client connected to the ASGI app. Lazily initialized."""
|
||||
return self.session()
|
||||
|
||||
@property
|
||||
def static_app(self):
|
||||
if not hasattr(self, "_static_app"):
|
||||
assert self.static_dir is not None
|
||||
self._static_app = StaticFiles(directory=self.static_dir)
|
||||
return self._static_app
|
||||
|
||||
def before_request(self, websocket=False):
|
||||
def decorator(f):
|
||||
self.router.before_request(f, websocket=websocket)
|
||||
return f
|
||||
|
||||
return decorator
|
||||
|
||||
def add_middleware(self, middleware_cls, **middleware_config):
|
||||
self.app = middleware_cls(self.app, **middleware_config)
|
||||
|
||||
def exception_handler(self, exception_cls):
|
||||
"""Register a handler for a specific exception type.
|
||||
|
||||
Usage::
|
||||
|
||||
@api.exception_handler(ValueError)
|
||||
async def handle_value_error(req, resp, exc):
|
||||
resp.status_code = 400
|
||||
resp.media = {"error": str(exc)}
|
||||
|
||||
:param environ: A WSGI environment.
|
||||
:param start_response: A callable accepting a status code,
|
||||
a list of headers, and an optional exception context to
|
||||
start the response.
|
||||
"""
|
||||
|
||||
req = models.Request(environ, start_response)
|
||||
# if not req.dispatched:
|
||||
resp = self._dispatch_request(req)
|
||||
return resp(environ, start_response)
|
||||
def decorator(func):
|
||||
async def _handler(request, exc):
|
||||
from starlette.responses import Response as StarletteResp
|
||||
|
||||
def _wsgi_app(self, environ, start_response):
|
||||
return self.whitenoise(environ, start_response)
|
||||
req = Request(request.scope, request.receive, formats=get_formats())
|
||||
resp = Response(req=req, formats=get_formats())
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
await func(req, resp, exc)
|
||||
else:
|
||||
func(req, resp, exc)
|
||||
if resp.status_code is None:
|
||||
resp.status_code = 500
|
||||
body, headers = await resp.body
|
||||
return StarletteResp(
|
||||
content=body, status_code=resp.status_code, headers=headers
|
||||
)
|
||||
|
||||
def wsgi_app(self, environ, start_response):
|
||||
"""Returns the WSGI app for this application (including all mounted WSGI apps)."""
|
||||
apps = self.apps.copy()
|
||||
main = apps.pop("/")
|
||||
# Register with the ExceptionMiddleware
|
||||
self.router._exception_handlers = getattr(
|
||||
self.router, "_exception_handlers", {}
|
||||
)
|
||||
self.router._exception_handlers[exception_cls] = _handler
|
||||
# Also register on the ASGI app chain
|
||||
from starlette.middleware.exceptions import ExceptionMiddleware as EM
|
||||
|
||||
return DispatcherMiddleware(main, apps)(environ, start_response)
|
||||
app = self.app
|
||||
while app is not None:
|
||||
if isinstance(app, EM):
|
||||
app.add_exception_handler(exception_cls, _handler)
|
||||
break
|
||||
app = getattr(app, "app", None)
|
||||
return func
|
||||
|
||||
def __call__(self, environ, start_response=None):
|
||||
"""The WSGI server calls the Flask application object as the
|
||||
WSGI application. This calls :meth:`wsgi_app` which can be
|
||||
wrapped to applying middleware."""
|
||||
return self.wsgi_app(environ, start_response)
|
||||
return decorator
|
||||
|
||||
def schema(self, name, **options):
|
||||
"""
|
||||
Decorator for creating new routes around function and class definitions.
|
||||
|
||||
Usage::
|
||||
|
||||
from marshmallow import Schema, fields
|
||||
@api.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
self.openapi.add_schema(name=name, schema=f, **options)
|
||||
return f
|
||||
|
||||
return decorator
|
||||
|
||||
def path_matches_route(self, path):
|
||||
"""Given a path portion of a URL, tests that it matches against any registered route.
|
||||
|
||||
:param path: The path portion of a URL, to test all known routes against.
|
||||
"""
|
||||
for (route, route_object) in self.routes.items():
|
||||
if route_object.does_match(path):
|
||||
""" # noqa: E501 (Line too long)
|
||||
for route in self.router.routes:
|
||||
match, _ = route.matches(path)
|
||||
if match:
|
||||
return route
|
||||
return None
|
||||
|
||||
def _dispatch_request(self, req):
|
||||
# Set formats on Request object.
|
||||
req.formats = self.formats
|
||||
|
||||
route = self.path_matches_route(req.path)
|
||||
resp = models.Response(req=req, formats=self.formats)
|
||||
|
||||
if self.hsts_enabled:
|
||||
if req.url.startswith("http://"):
|
||||
url = req.url.replace("http://", "https://", 1)
|
||||
self.redirect(resp, location=url)
|
||||
|
||||
if route:
|
||||
try:
|
||||
params = self.routes[route].incoming_matches(req.path)
|
||||
self.routes[route].endpoint(req, resp, **params)
|
||||
# The request is using class-based views.
|
||||
except TypeError:
|
||||
try:
|
||||
view = self.routes[route].endpoint(**params)
|
||||
except TypeError:
|
||||
view = self.routes[route].endpoint
|
||||
try:
|
||||
# GraphQL Schema.
|
||||
assert hasattr(view, "execute")
|
||||
self.graphql_response(req, resp, schema=view)
|
||||
except AssertionError:
|
||||
# WSGI App.
|
||||
try:
|
||||
req.dispatched = True
|
||||
return view(
|
||||
environ=req._environ, start_response=req._start_response
|
||||
)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
# Run on_request first.
|
||||
try:
|
||||
getattr(view, "on_request")(req, resp)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
# Then on_get.
|
||||
method = req.method
|
||||
|
||||
try:
|
||||
getattr(view, f"on_{method}")(req, resp)
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
self.default_response(req, resp)
|
||||
|
||||
return resp
|
||||
|
||||
def add_route(self, route, endpoint, *, check_existing=True):
|
||||
# TODO: add graphiql
|
||||
"""Add a route to the API.
|
||||
def add_route(
|
||||
self,
|
||||
route=None,
|
||||
endpoint=None,
|
||||
*,
|
||||
default=False,
|
||||
static=True,
|
||||
check_existing=True,
|
||||
websocket=False,
|
||||
before_request=False,
|
||||
methods=None,
|
||||
):
|
||||
"""Adds a route to the API.
|
||||
|
||||
:param route: A string representation of the route.
|
||||
:param endpoint: The endpoint for the route -- can be a callable, a class, a WSGI application, or graphene schema (GraphQL).
|
||||
:param check_existing: If ``True``, an AssertionError will be raised, if the route is already defined.
|
||||
"""
|
||||
if check_existing:
|
||||
assert route not in self.routes
|
||||
:param endpoint: The endpoint for the route -- can be a callable, or a class.
|
||||
:param default: If ``True``, all unknown requests will route to this view.
|
||||
:param static: If ``True``, and no endpoint was passed, render "static/index.html".
|
||||
Also, it will become a default route.
|
||||
:param methods: Optional list of HTTP methods (e.g. ``["GET", "POST"]``).
|
||||
""" # noqa: E501
|
||||
|
||||
# TODO: Support grpahiql.
|
||||
self.routes[route] = Route(route, endpoint)
|
||||
if static:
|
||||
assert self.static_dir is not None
|
||||
if not endpoint:
|
||||
endpoint = self._static_response
|
||||
default = True
|
||||
|
||||
def default_response(self, req, resp):
|
||||
resp.status_code = HTTP_404
|
||||
resp.text = "Not found."
|
||||
self.router.add_route(
|
||||
route,
|
||||
endpoint,
|
||||
default=default,
|
||||
websocket=websocket,
|
||||
before_request=before_request,
|
||||
check_existing=check_existing,
|
||||
methods=methods,
|
||||
)
|
||||
|
||||
async def _static_response(self, req, resp):
|
||||
assert self.static_dir is not None
|
||||
|
||||
index = (self.static_dir / "index.html").resolve()
|
||||
if index.exists():
|
||||
resp.html = index.read_text()
|
||||
else:
|
||||
resp.status_code = status_codes.HTTP_404 # type: ignore[attr-defined]
|
||||
resp.text = "Not found."
|
||||
|
||||
def redirect(
|
||||
self, resp, location, *, set_text=True, status_code=status_codes.HTTP_301
|
||||
self,
|
||||
resp,
|
||||
location,
|
||||
*,
|
||||
set_text=True,
|
||||
status_code=status_codes.HTTP_301, # type: ignore[attr-defined]
|
||||
):
|
||||
"""Redirects a given response to a given location.
|
||||
"""
|
||||
Redirects a given response to a given location.
|
||||
|
||||
:param resp: The Response to mutate.
|
||||
:param location: The location of the redirect.
|
||||
:param set_text: If ``True``, sets the Redirect body content automatically.
|
||||
:param status_code: an `API.status_codes` attribute, or an integer, representing the HTTP status code of the redirect.
|
||||
:param status_code: an `API.status_codes` attribute, or an integer,
|
||||
representing the HTTP status code of the redirect.
|
||||
"""
|
||||
resp.redirect(location, set_text=set_text, status_code=status_code)
|
||||
|
||||
def on_event(self, event_type: str, **args):
|
||||
"""Decorator for registering functions or coroutines to run at certain events
|
||||
Supported events: startup, shutdown
|
||||
|
||||
Usage::
|
||||
|
||||
@api.on_event('startup')
|
||||
async def open_database_connection_pool():
|
||||
...
|
||||
|
||||
@api.on_event('shutdown')
|
||||
async def close_database_connection_pool():
|
||||
...
|
||||
|
||||
"""
|
||||
|
||||
assert resp.status_code.is_300(status_code)
|
||||
def decorator(func):
|
||||
self.add_event_handler(event_type, func, **args)
|
||||
return func
|
||||
|
||||
resp.status_code = status_code
|
||||
if set_text:
|
||||
resp.text = f"Redirecting to: {location}"
|
||||
resp.headers.update({"Location": location})
|
||||
return decorator
|
||||
|
||||
@staticmethod
|
||||
def _resolve_graphql_query(req):
|
||||
if "json" in req.mimetype:
|
||||
return req.json()["query"]
|
||||
def add_event_handler(self, event_type, handler):
|
||||
"""Adds an event handler to the API.
|
||||
|
||||
# Support query/q in form data.
|
||||
if "query" in req.media("form"):
|
||||
return req.media("form")["query"]
|
||||
if "q" in req.media("form"):
|
||||
return req.media("form")["q"]
|
||||
:param event_type: A string in ("startup", "shutdown")
|
||||
:param handler: The function to run. Can be either a function or a coroutine.
|
||||
"""
|
||||
|
||||
# Support query/q in params.
|
||||
if "query" in req.params:
|
||||
return req.params["query"]
|
||||
if "q" in req.params:
|
||||
return req.params["q"]
|
||||
self.router.add_event_handler(event_type, handler)
|
||||
|
||||
# Otherwise, the request text is used (typical).
|
||||
# TODO: Make some assertions about content-type here.
|
||||
return req.text
|
||||
|
||||
def graphql_response(self, req, resp, schema):
|
||||
query = self._resolve_graphql_query(req)
|
||||
result = schema.execute(query)
|
||||
result, status_code = encode_execution_results(
|
||||
[result],
|
||||
is_batch=False,
|
||||
format_error=default_format_error,
|
||||
encode=partial(json_encode, pretty=False),
|
||||
)
|
||||
resp.media = json.loads(result)
|
||||
return (query, result, status_code)
|
||||
|
||||
def route(self, route, **options):
|
||||
"""Decorator for creating new routes around function and class defenitions.
|
||||
def route(self, route=None, **options):
|
||||
"""Decorator for creating new routes around function and class definitions.
|
||||
|
||||
Usage::
|
||||
|
||||
@api.route("/hello")
|
||||
def hello(req, resp):
|
||||
req.text = "hello, world!"
|
||||
resp.text = "hello, world!"
|
||||
|
||||
"""
|
||||
|
||||
@@ -247,112 +344,106 @@ class API:
|
||||
|
||||
return decorator
|
||||
|
||||
def mount(self, route, wsgi_app):
|
||||
"""Mounts a WSGI application at a given route.
|
||||
def graphql(self, route="/graphql", *, schema):
|
||||
"""Mount a GraphQL API at the given route.
|
||||
|
||||
:param route: String representation of the route to be used (shouldn't be parameterized).
|
||||
:param wsgi_app: The other WSGI app (e.g. a Flask app).
|
||||
Usage::
|
||||
|
||||
import graphene
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
def resolve_hello(self, info, name):
|
||||
return f"Hello {name}"
|
||||
|
||||
api.graphql("/graphql", schema=graphene.Schema(query=Query))
|
||||
|
||||
:param route: The URL path for the GraphQL endpoint.
|
||||
:param schema: A Graphene schema instance.
|
||||
"""
|
||||
self.apps.update({route: wsgi_app})
|
||||
from .ext.graphql import GraphQLView
|
||||
|
||||
self.add_route(route, GraphQLView(api=self, schema=schema))
|
||||
|
||||
def mount(self, route, app):
|
||||
"""Mounts an WSGI / ASGI application at a given route.
|
||||
|
||||
:param route: String representation of the route to be used
|
||||
(shouldn't be parameterized).
|
||||
:param app: The other WSGI / ASGI app.
|
||||
"""
|
||||
self.router.apps.update({route: app})
|
||||
|
||||
def session(self, base_url="http://;"):
|
||||
"""Testing HTTP client. Returns a Requests session object, able to send HTTP requests to the WSGI application.
|
||||
"""Testing HTTP client. Returns a Starlette TestClient instance,
|
||||
able to send HTTP requests to the Responder application.
|
||||
|
||||
:param base_url: The URL to mount the connection adaptor to.
|
||||
:param base_url: The base URL for the test client.
|
||||
"""
|
||||
|
||||
if self._session is None:
|
||||
session = RequestsSession()
|
||||
session.mount(base_url, RequestsWSGIAdapter(self))
|
||||
self._session = session
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
self._session = TestClient(self, base_url=base_url)
|
||||
return self._session
|
||||
|
||||
def url_for(self, endpoint, absolute_url=False, **params):
|
||||
# TODO: Absolute_url
|
||||
def url_for(self, endpoint, **params):
|
||||
"""Given an endpoint, returns a rendered URL for its route.
|
||||
|
||||
:param view: The route endpoint you're searching for.
|
||||
:param endpoint: The route endpoint you're searching for.
|
||||
:param params: Data to pass into the URL generator (for parameterized URLs).
|
||||
"""
|
||||
for (route, route_object) in self.routes.items():
|
||||
if route_object.endpoint == endpoint:
|
||||
return route_object.url(**params)
|
||||
raise ValueError
|
||||
return self.router.url_for(endpoint, **params)
|
||||
|
||||
def template(self, name, auto_escape=True, **values):
|
||||
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template, with provided values supplied.
|
||||
def template(self, filename, *args, **kwargs):
|
||||
r"""Render a Jinja2 template file with the provided values.
|
||||
|
||||
Note: The current ``api`` instance is always passed into the view.
|
||||
|
||||
:param name: The filename of the jinja2 template, in ``templates_dir``.
|
||||
:param auto_escape: If ``True``, HTML and XML will automatically be escaped.
|
||||
:param values: Data to pass into the template.
|
||||
:param filename: The filename of the jinja2 template, in ``templates_dir``.
|
||||
:param \*args: Data to pass into the template.
|
||||
:param \*\*kwargs: Data to pass into the template.
|
||||
"""
|
||||
# Give reference to self.
|
||||
values.update(api=self)
|
||||
return self.templates.render(filename, *args, **kwargs)
|
||||
|
||||
if auto_escape:
|
||||
env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(
|
||||
str(self.templates_dir), followlinks=True
|
||||
),
|
||||
autoescape=jinja2.select_autoescape(["html", "xml"]),
|
||||
)
|
||||
else:
|
||||
env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(
|
||||
str(self.templates_dir), followlinks=True
|
||||
),
|
||||
autoescape=jinja2.select_autoescape([]),
|
||||
)
|
||||
def template_string(self, source, *args, **kwargs):
|
||||
r"""Render a Jinja2 template string with the provided values.
|
||||
|
||||
template = env.get_template(name)
|
||||
return template.render(**values)
|
||||
|
||||
def template_string(self, s, auto_escape=True, **values):
|
||||
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template string, with provided values supplied.
|
||||
|
||||
Note: The current ``api`` instance is always passed into the view.
|
||||
|
||||
:param s: The template to use.
|
||||
:param auto_escape: If ``True``, HTML and XML will automatically be escaped.
|
||||
:param values: Data to pass into the template.
|
||||
:param source: The template to use, a Jinja2 template string.
|
||||
:param \*args: Data to pass into the template.
|
||||
:param \*\*kwargs: Data to pass into the template.
|
||||
"""
|
||||
# Give reference to self.
|
||||
values.update(api=self)
|
||||
return self.templates.render_string(source, *args, **kwargs)
|
||||
|
||||
if auto_escape:
|
||||
env = jinja2.Environment(
|
||||
loader=jinja2.BaseLoader,
|
||||
autoescape=jinja2.select_autoescape(["html", "xml"]),
|
||||
)
|
||||
else:
|
||||
env = jinja2.Environment(
|
||||
loader=jinja2.BaseLoader, autoescape=jinja2.select_autoescape([])
|
||||
)
|
||||
def serve(self, *, address=None, port=None, debug=False, **options):
|
||||
"""
|
||||
Run the application with uvicorn.
|
||||
|
||||
template = env.from_string(s)
|
||||
return template.render(**values)
|
||||
|
||||
def run(self, address=None, port=None, **kwargs):
|
||||
"""Runs the application with Waitress. If the ``PORT`` environment
|
||||
variable is set, requests will be served on that port automatically to all
|
||||
known hosts.
|
||||
If the ``PORT`` environment variable is set, requests will be served on that port
|
||||
automatically to all known hosts.
|
||||
|
||||
:param address: The address to bind to.
|
||||
:param port: The port to bind to. If none is provided, one will be selected at random.
|
||||
:param kwargs: Additional keyword arguments to send to ``waitress.serve()``.
|
||||
"""
|
||||
:param debug: Whether to run application in debug mode.
|
||||
:param options: Additional keyword arguments to send to ``uvicorn.run()``.
|
||||
""" # noqa: E501
|
||||
|
||||
if "PORT" in os.environ:
|
||||
if address is None:
|
||||
address = "0.0.0.0"
|
||||
port = os.environ["PORT"]
|
||||
address = "0.0.0.0" # noqa: S104
|
||||
port = int(os.environ["PORT"])
|
||||
|
||||
if address is None:
|
||||
address = "127.0.0.1"
|
||||
if port is None:
|
||||
port = 0
|
||||
port = 5042
|
||||
if debug:
|
||||
options["log_level"] = "debug"
|
||||
|
||||
bind_to = f"{address}:{port}"
|
||||
uvicorn.run(self, host=address, port=port, **options)
|
||||
|
||||
waitress.serve(app=self, listen=bind_to, **kwargs)
|
||||
def run(self, **kwargs):
|
||||
if "debug" not in kwargs:
|
||||
kwargs.update({"debug": self.debug})
|
||||
self.serve(**kwargs)
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import multiprocessing
|
||||
import traceback
|
||||
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
__all__ = ["BackgroundQueue"]
|
||||
|
||||
|
||||
class BackgroundQueue:
|
||||
def __init__(self, n=None):
|
||||
if n is None:
|
||||
n = multiprocessing.cpu_count()
|
||||
|
||||
self.n = n
|
||||
self.pool = concurrent.futures.ThreadPoolExecutor(max_workers=n)
|
||||
self.results = []
|
||||
|
||||
def run(self, f, *args, **kwargs):
|
||||
f = self.pool.submit(f, *args, **kwargs)
|
||||
self.results.append(f)
|
||||
return f
|
||||
|
||||
def task(self, f):
|
||||
def on_future_done(fs):
|
||||
try:
|
||||
fs.result()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
def do_task(*args, **kwargs):
|
||||
result = self.run(f, *args, **kwargs)
|
||||
result.add_done_callback(on_future_done)
|
||||
return result
|
||||
|
||||
return do_task
|
||||
|
||||
async def __call__(self, func, *args, **kwargs) -> None:
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return await asyncio.create_task(func(*args, **kwargs))
|
||||
return await run_in_threadpool(func, *args, **kwargs)
|
||||
@@ -1 +0,0 @@
|
||||
import docopt
|
||||
@@ -1,2 +1,8 @@
|
||||
from .api import API
|
||||
from .models import Request, Response
|
||||
|
||||
__all__ = [
|
||||
"API",
|
||||
"Request",
|
||||
"Response",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Responder CLI.
|
||||
|
||||
A web framework for Python.
|
||||
|
||||
Commands:
|
||||
run Start the application server
|
||||
build Build frontend assets using npm
|
||||
|
||||
Usage:
|
||||
responder
|
||||
responder run [--debug] [--limit-max-requests=] <target>
|
||||
responder build [<target>]
|
||||
responder --version
|
||||
|
||||
Options:
|
||||
-h --help Show this screen.
|
||||
-v --version Show version.
|
||||
--debug Enable debug mode with verbose logging.
|
||||
--limit-max-requests=<n> Maximum number of requests to handle before shutting down.
|
||||
|
||||
Arguments:
|
||||
<target> For run: Python module specifier (e.g., "app:api" loads api from app.py)
|
||||
Format: "module.submodule:variable_name" where variable_name is your API instance
|
||||
For build: Directory containing package.json (default: current directory)
|
||||
|
||||
Examples:
|
||||
responder run app:api # Run the 'api' instance from app.py
|
||||
responder run myapp/core.py:application # Run the 'application' instance from myapp/core.py
|
||||
responder build # Build frontend assets
|
||||
""" # noqa: E501
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import typing as t
|
||||
from pathlib import Path
|
||||
|
||||
import docopt
|
||||
|
||||
from responder.__version__ import __version__
|
||||
from responder.util.python import InvalidTarget, load_target
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cli() -> None:
|
||||
"""
|
||||
Main entry point for the Responder CLI.
|
||||
|
||||
Parses command line arguments and executes the appropriate command.
|
||||
Supports running the application, building assets, and displaying version info.
|
||||
"""
|
||||
args = docopt.docopt(__doc__, argv=None, version=__version__, options_first=False)
|
||||
setup_logging(args["--debug"])
|
||||
|
||||
target: t.Optional[str] = args["<target>"]
|
||||
build: bool = args["build"]
|
||||
debug: bool = args["--debug"]
|
||||
run: bool = args["run"]
|
||||
|
||||
if build:
|
||||
target_path = Path(target).resolve() if target else Path.cwd()
|
||||
if not target_path.is_dir() or not (target_path / "package.json").exists():
|
||||
logger.error(
|
||||
f"Invalid target directory or missing package.json: {target_path}"
|
||||
)
|
||||
sys.exit(1)
|
||||
npm_cmd = "npm.cmd" if platform.system() == "Windows" else "npm"
|
||||
try:
|
||||
logger.info("Starting frontend asset build")
|
||||
# S603, S607 are addressed by validating the target directory.
|
||||
subprocess.check_call( # noqa: S603, S607
|
||||
[npm_cmd, "run", "build"],
|
||||
cwd=target_path,
|
||||
timeout=300,
|
||||
)
|
||||
logger.info("Frontend asset build completed successfully")
|
||||
except FileNotFoundError:
|
||||
logger.error("npm not found. Please install Node.js and npm.")
|
||||
sys.exit(1)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Build failed with exit code {e.returncode}")
|
||||
sys.exit(1)
|
||||
|
||||
if run:
|
||||
if not target:
|
||||
logger.error("Target argument is required for run command")
|
||||
sys.exit(1)
|
||||
|
||||
# Maximum request limit. Terminating afterward. Suitable for software testing.
|
||||
limit_max_requests = args["--limit-max-requests"]
|
||||
if limit_max_requests is not None:
|
||||
try:
|
||||
limit_max_requests = int(limit_max_requests)
|
||||
if limit_max_requests <= 0:
|
||||
logger.error("limit-max-requests must be a positive integer")
|
||||
sys.exit(1)
|
||||
except ValueError:
|
||||
logger.error("limit-max-requests must be a valid integer")
|
||||
sys.exit(1)
|
||||
|
||||
# Load application from target.
|
||||
try:
|
||||
api = load_target(target=target)
|
||||
except InvalidTarget as ex:
|
||||
raise ValueError(
|
||||
f"{ex}. "
|
||||
"Use either a Python module entrypoint specification, "
|
||||
"a filesystem path, or a remote URL. "
|
||||
"See also https://responder.kennethreitz.org/cli.html."
|
||||
) from ex
|
||||
|
||||
# Launch Responder API server (uvicorn).
|
||||
api.run(debug=debug, limit_max_requests=limit_max_requests)
|
||||
|
||||
|
||||
def setup_logging(debug: bool) -> None:
|
||||
"""
|
||||
Configure logging based on debug mode.
|
||||
|
||||
Args:
|
||||
debug: When True, sets logging level to DEBUG; otherwise, sets to INFO
|
||||
"""
|
||||
log_level = logging.DEBUG if debug else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=log_level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
import json
|
||||
|
||||
from .templates import GRAPHIQL
|
||||
|
||||
|
||||
class GraphQLView:
|
||||
def __init__(self, *, api, schema):
|
||||
self.api = api
|
||||
self.schema = schema
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_graphql_query(req, resp):
|
||||
if "json" in req.mimetype:
|
||||
json_media = await req.media("json")
|
||||
if "query" not in json_media:
|
||||
resp.status_code = 400
|
||||
resp.media = {"errors": ["'query' key is required in the JSON payload"]}
|
||||
return None, None, None
|
||||
return (
|
||||
json_media["query"],
|
||||
json_media.get("variables"),
|
||||
json_media.get("operationName"),
|
||||
)
|
||||
|
||||
# Support query/q in params.
|
||||
if "query" in req.params:
|
||||
return req.params["query"], None, None
|
||||
if "q" in req.params:
|
||||
return req.params["q"], None, None
|
||||
|
||||
# Otherwise, the request text is used (typical).
|
||||
return await req.text, None, None
|
||||
|
||||
async def graphql_response(self, req, resp):
|
||||
show_graphiql = req.method == "get" and req.accepts("text/html")
|
||||
|
||||
if show_graphiql:
|
||||
resp.content = self.api.templates.render_string(
|
||||
GRAPHIQL, endpoint=req.url.path
|
||||
)
|
||||
return None
|
||||
|
||||
query, variables, operation_name = await self._resolve_graphql_query(req, resp)
|
||||
if query is None:
|
||||
return None
|
||||
|
||||
context = {"request": req, "response": resp}
|
||||
result = self.schema.execute(
|
||||
query, variables=variables, operation_name=operation_name, context=context
|
||||
)
|
||||
|
||||
response_data = {}
|
||||
if result.errors:
|
||||
response_data["errors"] = [{"message": str(e)} for e in result.errors]
|
||||
if result.data is not None:
|
||||
response_data["data"] = result.data
|
||||
|
||||
resp.media = response_data
|
||||
status_code = 200 if not result.errors else 400
|
||||
return (query, json.dumps(response_data), status_code)
|
||||
|
||||
async def on_request(self, req, resp):
|
||||
await self.graphql_response(req, resp)
|
||||
|
||||
async def __call__(self, req, resp):
|
||||
await self.on_request(req, resp)
|
||||
@@ -0,0 +1,34 @@
|
||||
# ruff: noqa: E501
|
||||
GRAPHIQL = """
|
||||
{% set GRAPHIQL_VERSION = '3.0.6' %}
|
||||
{% set REACT_VERSION = '18.2.0' %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
#graphiql {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
<link href="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="graphiql">Loading...</div>
|
||||
<script crossorigin src="//cdn.jsdelivr.net/npm/react@{{ REACT_VERSION }}/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="//cdn.jsdelivr.net/npm/react-dom@{{ REACT_VERSION }}/umd/react-dom.production.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.js"></script>
|
||||
<script>
|
||||
const fetcher = GraphiQL.createFetcher({ url: '{{ endpoint }}' });
|
||||
const root = ReactDOM.createRoot(document.getElementById('graphiql'));
|
||||
root.render(React.createElement(GraphiQL, { fetcher: fetcher }));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".strip()
|
||||
@@ -0,0 +1,138 @@
|
||||
from pathlib import Path
|
||||
|
||||
from apispec import APISpec, yaml_utils
|
||||
from apispec.ext.marshmallow import MarshmallowPlugin
|
||||
|
||||
from responder import status_codes
|
||||
from responder.statics import API_THEMES, DEFAULT_OPENAPI_THEME
|
||||
from responder.templates import Templates
|
||||
|
||||
|
||||
class OpenAPISchema:
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
title,
|
||||
version,
|
||||
plugins=None,
|
||||
description=None,
|
||||
terms_of_service=None,
|
||||
contact=None,
|
||||
license=None, # noqa: A002
|
||||
openapi=None,
|
||||
openapi_route="/schema.yml",
|
||||
docs_route="/docs/",
|
||||
static_route="/static",
|
||||
openapi_theme=DEFAULT_OPENAPI_THEME,
|
||||
):
|
||||
self.app = app
|
||||
self.schemas = {}
|
||||
self.title = title
|
||||
self.version = version
|
||||
self.description = description
|
||||
self.terms_of_service = terms_of_service
|
||||
self.contact = contact
|
||||
self.license = license
|
||||
|
||||
self.openapi_version = openapi
|
||||
self.openapi_route = openapi_route
|
||||
|
||||
self.docs_theme = (
|
||||
openapi_theme if openapi_theme in API_THEMES else DEFAULT_OPENAPI_THEME
|
||||
)
|
||||
self.docs_route = docs_route
|
||||
|
||||
self.plugins = [MarshmallowPlugin()] if plugins is None else plugins
|
||||
|
||||
if self.openapi_version is not None:
|
||||
self.app.add_route(self.openapi_route, self.schema_response)
|
||||
|
||||
if self.docs_route is not None:
|
||||
self.app.add_route(self.docs_route, self.docs_response)
|
||||
|
||||
theme_path = (Path(__file__).parent / "docs").resolve()
|
||||
self.templates = Templates(directory=theme_path)
|
||||
|
||||
self.static_route = static_route
|
||||
|
||||
@property
|
||||
def _apispec(self):
|
||||
info = {}
|
||||
if self.description is not None:
|
||||
info["description"] = self.description
|
||||
if self.terms_of_service is not None:
|
||||
info["termsOfService"] = self.terms_of_service
|
||||
if self.contact is not None:
|
||||
info["contact"] = self.contact
|
||||
if self.license is not None:
|
||||
info["license"] = self.license
|
||||
|
||||
spec = APISpec(
|
||||
title=self.title,
|
||||
version=self.version,
|
||||
openapi_version=self.openapi_version,
|
||||
plugins=self.plugins,
|
||||
info=info,
|
||||
)
|
||||
|
||||
for route in self.app.router.routes:
|
||||
if route.description:
|
||||
operations = yaml_utils.load_operations_from_docstring(route.description)
|
||||
spec.path(path=route.route, operations=operations)
|
||||
|
||||
for name, schema in self.schemas.items():
|
||||
spec.components.schema(name, schema=schema)
|
||||
|
||||
return spec
|
||||
|
||||
@property
|
||||
def openapi(self):
|
||||
return self._apispec.to_yaml()
|
||||
|
||||
def add_schema(self, name, schema, check_existing=True):
|
||||
"""Adds a marshmallow schema to the API specification."""
|
||||
if check_existing:
|
||||
assert name not in self.schemas
|
||||
|
||||
self.schemas[name] = schema
|
||||
|
||||
def schema(self, name, **options):
|
||||
"""Decorator for creating new routes around function and class definitions.
|
||||
|
||||
Usage::
|
||||
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
@api.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
self.add_schema(name=name, schema=f, **options)
|
||||
return f
|
||||
|
||||
return decorator
|
||||
|
||||
@property
|
||||
def docs(self):
|
||||
return self.templates.render(
|
||||
f"{self.docs_theme}.html",
|
||||
title=self.title,
|
||||
version=self.version,
|
||||
schema_url="/schema.yml",
|
||||
)
|
||||
|
||||
def static_url(self, asset):
|
||||
"""Given a static asset, return its URL path."""
|
||||
assert self.static_route is not None
|
||||
return f"{self.static_route}/{str(asset)}"
|
||||
|
||||
def docs_response(self, req, resp):
|
||||
resp.html = self.docs
|
||||
|
||||
def schema_response(self, req, resp):
|
||||
resp.status_code = status_codes.HTTP_200 # type: ignore[attr-defined]
|
||||
resp.headers["Content-Type"] = "application/x-yaml"
|
||||
resp.content = self.openapi
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<title>{{ title }} {{ version }}</title>
|
||||
<!-- Embed elements Elements via Web Component -->
|
||||
<script src="https://unpkg.com/@stoplight/elements/web-components.min.js"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/@stoplight/elements/styles.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<elements-api
|
||||
apiDescriptionUrl="{{ schema_url }}"
|
||||
router="hash"
|
||||
layout="sidebar"
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Important: must specify -->
|
||||
<html>
|
||||
<head>
|
||||
<title>{{ title }} {{ version }}</title>
|
||||
<meta charset="utf-8" />
|
||||
<!-- Important: rapi-doc uses utf8 characters -->
|
||||
<script
|
||||
type="module"
|
||||
src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"
|
||||
></script>
|
||||
</head>
|
||||
<body>
|
||||
<rapi-doc spec-url="{{ schema_url }}" show-header="false"> </rapi-doc>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{ title }} {{ version }}</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url="{{ schema_url }}"></redoc>
|
||||
<script src="https://cdn.jsdelivr.net/npm/redoc@latest/bundles/redoc.standalone.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>{{ title }} {{ version }}</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href="https://unpkg.com/swagger-ui-dist/swagger-ui.css"
|
||||
/>
|
||||
<style>
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "{{ schema_url }}",
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
||||
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
|
||||
layout: "BaseLayout",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+133
-12
@@ -1,27 +1,148 @@
|
||||
import yaml
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import yaml
|
||||
from python_multipart import MultipartParser
|
||||
|
||||
from .models import QueryDict
|
||||
|
||||
|
||||
def format_form(r, encode=False):
|
||||
if not encode:
|
||||
return r._wz.form
|
||||
class _PartData:
|
||||
__slots__ = ("headers", "body", "header_field")
|
||||
|
||||
def __init__(self):
|
||||
self.headers: dict[str, str] = {}
|
||||
self.body = b""
|
||||
self.header_field = ""
|
||||
|
||||
|
||||
def format_yaml(r, encode=False):
|
||||
def _parse_multipart(content: bytes, content_type: str) -> list[_PartData]:
|
||||
"""Parse multipart form data into a list of parts with headers and body."""
|
||||
boundary = None
|
||||
for segment in content_type.split(";"):
|
||||
segment = segment.strip()
|
||||
if segment.startswith("boundary="):
|
||||
boundary = segment.split("=", 1)[1].strip('"')
|
||||
break
|
||||
|
||||
if boundary is None:
|
||||
return []
|
||||
|
||||
parts: list[_PartData] = []
|
||||
current: list[_PartData | None] = [None]
|
||||
|
||||
def on_part_begin():
|
||||
current[0] = _PartData()
|
||||
|
||||
def on_part_data(data, start, end):
|
||||
current[0].body += data[start:end] # type: ignore[union-attr]
|
||||
|
||||
def on_header_field(data, start, end):
|
||||
current[0].header_field = data[start:end].decode("utf-8") # type: ignore[union-attr]
|
||||
|
||||
def on_header_value(data, start, end):
|
||||
part = current[0]
|
||||
assert part is not None
|
||||
part.headers[part.header_field] = data[start:end].decode("utf-8")
|
||||
|
||||
def on_part_end():
|
||||
parts.append(current[0]) # type: ignore[arg-type]
|
||||
|
||||
parser = MultipartParser(
|
||||
boundary.encode(),
|
||||
{ # type: ignore[arg-type]
|
||||
"on_part_begin": on_part_begin,
|
||||
"on_part_data": on_part_data,
|
||||
"on_header_field": on_header_field,
|
||||
"on_header_value": on_header_value,
|
||||
"on_part_end": on_part_end,
|
||||
},
|
||||
)
|
||||
parser.write(content)
|
||||
parser.finalize()
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
async def format_form(r, encode=False):
|
||||
if encode:
|
||||
return None
|
||||
if "multipart/form-data" in r.headers.get("Content-Type"):
|
||||
parts = _parse_multipart(await r.content, r.mimetype)
|
||||
queries = []
|
||||
for part in parts:
|
||||
header = part.headers.get("Content-Disposition", "")
|
||||
text = part.body.decode("utf-8")
|
||||
|
||||
for section in [h.strip() for h in header.split(";")]:
|
||||
split = section.split("=")
|
||||
if len(split) > 1:
|
||||
key = split[1]
|
||||
key = key[1:-1]
|
||||
queries.append((key, text))
|
||||
|
||||
content = urlencode(queries)
|
||||
return QueryDict(content)
|
||||
return QueryDict(await r.text)
|
||||
|
||||
|
||||
async def format_yaml(r, encode=False):
|
||||
if encode:
|
||||
r.headers.update({"Content-Type": "application/x-yaml"})
|
||||
return yaml.dump(r.media)
|
||||
else:
|
||||
return yaml.load(r.content)
|
||||
return yaml.safe_dump(r.media)
|
||||
return yaml.safe_load(await r.content)
|
||||
|
||||
|
||||
def format_json(r, encode=False):
|
||||
async def format_json(r, encode=False):
|
||||
if encode:
|
||||
r.headers.update({"Content-Type": "application/json"})
|
||||
return json.dumps(r.media)
|
||||
else:
|
||||
return json.loads(r.content)
|
||||
return json.loads(await r.content)
|
||||
|
||||
|
||||
async def format_files(r, encode=False):
|
||||
if encode:
|
||||
return None
|
||||
parts = _parse_multipart(await r.content, r.mimetype)
|
||||
dump = {}
|
||||
for part in parts:
|
||||
header = part.headers.get("Content-Disposition", "")
|
||||
mimetype = part.headers.get("Content-Type", None)
|
||||
filename = None
|
||||
formname = None
|
||||
|
||||
for section in [h.strip() for h in header.split(";")]:
|
||||
split = section.split("=")
|
||||
if len(split) > 1:
|
||||
key = split[0]
|
||||
value = split[1]
|
||||
value = value[1:-1]
|
||||
|
||||
if key == "filename":
|
||||
filename = value
|
||||
elif key == "name":
|
||||
formname = value
|
||||
|
||||
if formname is None:
|
||||
continue
|
||||
|
||||
if mimetype is None:
|
||||
dump[formname] = part.body
|
||||
else:
|
||||
dump[formname] = {
|
||||
"filename": filename,
|
||||
"content": part.body,
|
||||
"content-type": mimetype,
|
||||
}
|
||||
return dump
|
||||
|
||||
|
||||
def get_formats():
|
||||
return {"json": format_json, "yaml": format_yaml, "form": format_form}
|
||||
return {
|
||||
"json": format_json,
|
||||
"yaml": format_yaml,
|
||||
"form": format_form,
|
||||
"files": format_files,
|
||||
}
|
||||
|
||||
+420
-122
@@ -1,168 +1,466 @@
|
||||
import io
|
||||
import json
|
||||
import gzip
|
||||
from __future__ import annotations
|
||||
|
||||
import graphene
|
||||
import yaml
|
||||
from requests.models import Request as RequestsRequest
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
from werkzeug.wrappers import Request as WerkzeugRequest
|
||||
from werkzeug.wrappers import BaseResponse as WerkzeugResponse
|
||||
import functools
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from http.cookies import SimpleCookie
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
__all__ = ["Request", "Response", "QueryDict"]
|
||||
|
||||
try:
|
||||
import chardet
|
||||
except ImportError:
|
||||
chardet = None # type: ignore[assignment]
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
from starlette.requests import State
|
||||
from starlette.responses import (
|
||||
Response as StarletteResponse,
|
||||
)
|
||||
from starlette.responses import (
|
||||
StreamingResponse as StarletteStreamingResponse,
|
||||
)
|
||||
|
||||
from .statics import DEFAULT_ENCODING
|
||||
from .status_codes import HTTP_301 # type: ignore[attr-defined]
|
||||
|
||||
|
||||
from urllib.parse import parse_qs
|
||||
class CaseInsensitiveDict(dict):
|
||||
"""A case-insensitive dict for HTTP headers."""
|
||||
|
||||
from .status_codes import HTTP_200
|
||||
def __setitem__(self, key, value):
|
||||
super().__setitem__(key.lower(), value)
|
||||
|
||||
# @staticmethod
|
||||
# def funcname(parameter_list):
|
||||
# pass
|
||||
def __getitem__(self, key):
|
||||
return super().__getitem__(key.lower())
|
||||
|
||||
def __contains__(self, key):
|
||||
return super().__contains__(key.lower())
|
||||
|
||||
def get(self, key, default=None):
|
||||
return super().get(key.lower(), default)
|
||||
|
||||
def update(self, other=None, **kwargs):
|
||||
if other:
|
||||
for key, value in other.items():
|
||||
self[key] = value
|
||||
for key, value in kwargs.items():
|
||||
self[key] = value
|
||||
|
||||
|
||||
def flatten(d):
|
||||
for key, value in d.copy().items():
|
||||
if len(value) == 1:
|
||||
d[key] = value[0]
|
||||
class QueryDict(dict):
|
||||
def __init__(self, query_string):
|
||||
self.update(parse_qs(query_string))
|
||||
|
||||
return d
|
||||
def __getitem__(self, key):
|
||||
"""
|
||||
Return the last data value for this key, or [] if it's an empty list;
|
||||
raise KeyError if not found.
|
||||
"""
|
||||
list_ = super().__getitem__(key)
|
||||
try:
|
||||
return list_[-1]
|
||||
except IndexError:
|
||||
return []
|
||||
|
||||
def get(self, key, default=None):
|
||||
"""
|
||||
Return the last data value for the passed key. If key doesn't exist
|
||||
or value is an empty list, return `default`.
|
||||
"""
|
||||
try:
|
||||
val = self[key]
|
||||
except KeyError:
|
||||
return default
|
||||
if val == []:
|
||||
return default
|
||||
return val
|
||||
|
||||
def _get_list(self, key, default=None, force_list=False):
|
||||
"""
|
||||
Return a list of values for the key.
|
||||
|
||||
Used internally to manipulate values list. If force_list is True,
|
||||
return a new copy of values.
|
||||
"""
|
||||
try:
|
||||
values = super().__getitem__(key)
|
||||
except KeyError:
|
||||
if default is None:
|
||||
return []
|
||||
return default
|
||||
else:
|
||||
if force_list:
|
||||
values = list(values) if values is not None else None
|
||||
return values
|
||||
|
||||
def get_list(self, key, default=None):
|
||||
"""
|
||||
Return the list of values for the key. If key doesn't exist, return a
|
||||
default value.
|
||||
"""
|
||||
return self._get_list(key, default, force_list=True)
|
||||
|
||||
def items(self):
|
||||
"""
|
||||
Yield (key, value) pairs, where value is the last item in the list
|
||||
associated with the key.
|
||||
"""
|
||||
for key in self:
|
||||
yield key, self[key]
|
||||
|
||||
def items_list(self):
|
||||
"""
|
||||
Yield (key, value) pairs, where value is the the list.
|
||||
"""
|
||||
yield from super().items()
|
||||
|
||||
|
||||
# TODO: add slots
|
||||
class Request:
|
||||
def __init__(self, environ, start_response=None):
|
||||
self._wz = WerkzeugRequest(environ)
|
||||
self.start_response = start_response
|
||||
self.headers = CaseInsensitiveDict(
|
||||
self._wz.headers.to_wsgi_list()
|
||||
) #: A case-insensitive dictionary, containg all headers sent in the Request.
|
||||
self.method = (
|
||||
self._wz.method.lower()
|
||||
) #: The incoming HTTP method used for the request, lower-cased.
|
||||
self.full_url = (
|
||||
self._wz.url
|
||||
) #: The full URL of the Request, query parameters and all.
|
||||
self.url = (
|
||||
self._wz.base_url
|
||||
) #: The URL of the Request, without query parameters.
|
||||
self.full_path = (
|
||||
self._wz.full_path
|
||||
) #: The full path portion of the URL of the Request, query parameters and all.
|
||||
self.path = (
|
||||
self._wz.path
|
||||
) #: The path portion of the URL of the Request, without query parameters.
|
||||
self.params = flatten(
|
||||
parse_qs(self._wz.query_string.decode("utf-8"))
|
||||
) #: A dictionary of the parsed query paramaters used for the Request.
|
||||
self.query = self._wz.query_string.decode(
|
||||
"utf-8"
|
||||
) #: A string containing only the query paramaters of the Request.
|
||||
self.raw = self._wz.stream #: A raw file-like stream of the incoming Request.
|
||||
self.content = self._wz.get_data(
|
||||
cache=True, as_text=False
|
||||
) #: The Request body, as bytes.
|
||||
self.mimetype = self._wz.mimetype #: The mimetype of the incoming Request.
|
||||
# TODO: rip that out
|
||||
self.text = self._wz.get_data(
|
||||
cache=False, as_text=True
|
||||
) #: The Request body, as unicode.
|
||||
self.formats = None
|
||||
__slots__ = [
|
||||
"_starlette",
|
||||
"formats",
|
||||
"_headers",
|
||||
"_encoding",
|
||||
"api",
|
||||
"_content",
|
||||
"_cookies",
|
||||
]
|
||||
|
||||
def __init__(self, scope, receive, api=None, formats=None):
|
||||
self._starlette = StarletteRequest(scope, receive)
|
||||
self.formats = formats
|
||||
self._encoding = None
|
||||
self.api = api
|
||||
self._content = None
|
||||
|
||||
headers: CaseInsensitiveDict = CaseInsensitiveDict()
|
||||
for key, value in self._starlette.headers.items():
|
||||
headers[key] = value
|
||||
|
||||
self._headers = headers
|
||||
self._cookies = None
|
||||
|
||||
@property
|
||||
def session(self):
|
||||
"""The session data, in dict form, from the Request."""
|
||||
return self._starlette.session
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
"""A case-insensitive dictionary, containing all headers sent in the Request."""
|
||||
return self._headers
|
||||
|
||||
@property
|
||||
def mimetype(self):
|
||||
return self.headers.get("Content-Type", "")
|
||||
|
||||
@property
|
||||
def is_json(self):
|
||||
"""Returns ``True`` if the request content type is JSON."""
|
||||
return "json" in self.mimetype
|
||||
|
||||
@property
|
||||
def method(self):
|
||||
"""The incoming HTTP method used for the request, lower-cased."""
|
||||
return self._starlette.method.lower()
|
||||
|
||||
@property
|
||||
def full_url(self):
|
||||
"""The full URL of the Request, query parameters and all."""
|
||||
return str(self._starlette.url)
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""The parsed URL of the Request."""
|
||||
return urlparse(self.full_url)
|
||||
|
||||
@property
|
||||
def cookies(self):
|
||||
"""The cookies sent in the Request, as a dictionary."""
|
||||
if self._cookies is None:
|
||||
cookies = {}
|
||||
cookie_header = self.headers.get("Cookie", "")
|
||||
|
||||
bc: SimpleCookie = SimpleCookie(cookie_header)
|
||||
for key, morsel in bc.items():
|
||||
cookies[key] = morsel.value
|
||||
|
||||
self._cookies = cookies
|
||||
|
||||
return self._cookies
|
||||
|
||||
@property
|
||||
def params(self):
|
||||
"""A dictionary of the parsed query parameters used for the Request."""
|
||||
try:
|
||||
return QueryDict(self.url.query)
|
||||
except AttributeError:
|
||||
return QueryDict({})
|
||||
|
||||
@property
|
||||
def path_params(self) -> dict:
|
||||
"""The path parameters extracted from the URL route."""
|
||||
return self._starlette.path_params
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
"""The client's address as a (host, port) named tuple, or None."""
|
||||
return self._starlette.client
|
||||
|
||||
@property
|
||||
def state(self) -> State:
|
||||
"""
|
||||
Use the state to store additional information.
|
||||
|
||||
This can be a very helpful feature, if you want to hand over
|
||||
information from a middelware or a route decorator to the
|
||||
actual route handler.
|
||||
|
||||
Usage: ``request.state.time_started = time.time()``
|
||||
"""
|
||||
return self._starlette.state
|
||||
|
||||
@property
|
||||
async def encoding(self):
|
||||
"""The encoding of the Request's body. Can be set, manually. Must be awaited."""
|
||||
# Use the user-set encoding first.
|
||||
if self._encoding:
|
||||
return self._encoding
|
||||
|
||||
return await self.apparent_encoding
|
||||
|
||||
@encoding.setter
|
||||
def encoding(self, value):
|
||||
self._encoding = value
|
||||
|
||||
@property
|
||||
async def content(self):
|
||||
"""The Request body, as bytes. Must be awaited."""
|
||||
if not self._content:
|
||||
self._content = await self._starlette.body()
|
||||
return self._content
|
||||
|
||||
@property
|
||||
async def text(self):
|
||||
"""The Request body, as unicode. Must be awaited."""
|
||||
return (await self.content).decode(await self.encoding)
|
||||
|
||||
@property
|
||||
async def declared_encoding(self):
|
||||
if "Encoding" in self.headers:
|
||||
return self.headers["Encoding"]
|
||||
return None
|
||||
|
||||
@property
|
||||
async def apparent_encoding(self):
|
||||
"""The apparent encoding, detected automatically. Must be awaited.
|
||||
|
||||
Uses chardet for detection if installed, otherwise falls back to UTF-8.
|
||||
"""
|
||||
declared_encoding = await self.declared_encoding
|
||||
|
||||
if declared_encoding:
|
||||
return declared_encoding
|
||||
|
||||
if chardet is not None:
|
||||
return chardet.detect(await self.content)["encoding"] or DEFAULT_ENCODING
|
||||
|
||||
return DEFAULT_ENCODING
|
||||
|
||||
@property
|
||||
def is_secure(self):
|
||||
"""Returns ``True`` if the incoming Request was securely made."""
|
||||
return self._wz.is_secure
|
||||
return self.url.scheme == "https"
|
||||
|
||||
def accepts(self, content_type):
|
||||
"""Returns ``True`` if the incoming Request accepts the given ``content_type``."""
|
||||
return content_type in self.headers["Accept"]
|
||||
return content_type in self.headers.get("Accept", [])
|
||||
|
||||
def media(self, format=None):
|
||||
"""Renders incoming json/yaml/form data as Python objects.
|
||||
async def media(self, format: str | Callable = None): # noqa: A002
|
||||
"""Renders incoming json/yaml/form data as Python objects. Must be awaited.
|
||||
|
||||
:param format: The name of the format being used. Alternatively accepts a custom callable for the format type.
|
||||
:param format: The name of the format being used.
|
||||
Alternatively, accepts a custom callable for the format type.
|
||||
"""
|
||||
|
||||
if format is None:
|
||||
format = "yaml" if "yaml" in self.mimetype or "" else "json"
|
||||
format = "yaml" if "yaml" in self.mimetype or "" else "json" # noqa: A001
|
||||
format = "form" if "form" in self.mimetype or "" else format # noqa: A001
|
||||
|
||||
formatter: Callable
|
||||
if isinstance(format, str):
|
||||
try:
|
||||
formatter = self.formats[format]
|
||||
except KeyError as ex:
|
||||
raise ValueError(f"Unable to process data in '{format}' format") from ex
|
||||
|
||||
elif callable(format):
|
||||
formatter = format
|
||||
|
||||
if format in self.formats:
|
||||
return self.formats[format](self)
|
||||
else:
|
||||
return format(self)
|
||||
raise TypeError(f"Invalid 'format' argument: {format}")
|
||||
|
||||
return await formatter(self)
|
||||
|
||||
|
||||
def content_setter(mimetype):
|
||||
def getter(instance):
|
||||
return instance.content
|
||||
|
||||
def setter(instance, value):
|
||||
instance.content = value
|
||||
instance.mimetype = mimetype
|
||||
|
||||
return property(fget=getter, fset=setter)
|
||||
|
||||
|
||||
class Response:
|
||||
__slots__ = [
|
||||
"req",
|
||||
"status_code",
|
||||
"content",
|
||||
"encoding",
|
||||
"media",
|
||||
"headers",
|
||||
"formats",
|
||||
"cookies",
|
||||
"session",
|
||||
"mimetype",
|
||||
"_stream",
|
||||
]
|
||||
|
||||
text = content_setter("text/plain")
|
||||
html = content_setter("text/html")
|
||||
|
||||
def __init__(self, req, *, formats):
|
||||
self.req = req
|
||||
self.status_code = HTTP_200 #: The HTTP Status Code to use for the Response.
|
||||
self.text = None #: A unicode representation of the response body.
|
||||
#: The HTTP Status Code to use for the Response.
|
||||
self.status_code: int | None = None
|
||||
self.content = None #: A bytes representation of the response body.
|
||||
self.encoding = "utf-8"
|
||||
self.media = (
|
||||
None
|
||||
) #: A Python object that will be content-negotiated and sent back to the client. Typically, in JSON formatting.
|
||||
self.headers = (
|
||||
{}
|
||||
) #: A Python dictionary of {Key: value}, representing the headers of the response.
|
||||
self.mimetype = None
|
||||
self.encoding = DEFAULT_ENCODING
|
||||
self.media = None #: A Python object that will be content-negotiated and
|
||||
#: sent back to the client. Typically, in JSON formatting.
|
||||
self._stream = None
|
||||
self.headers = {} #: A Python dictionary of ``{key: value}``,
|
||||
#: representing the headers of the response.
|
||||
self.formats = formats
|
||||
self.cookies: SimpleCookie = SimpleCookie() #: The cookies set in the Response
|
||||
self.session = (
|
||||
req.session
|
||||
) #: The cookie-based session data, in dict form, to add to the Response.
|
||||
|
||||
def stream(self, func, *args, **kwargs):
|
||||
assert inspect.isasyncgenfunction(func)
|
||||
|
||||
self._stream = functools.partial(func, *args, **kwargs)
|
||||
|
||||
return func
|
||||
|
||||
def file(self, path, *, content_type=None):
|
||||
"""Serve a file from disk as the response.
|
||||
|
||||
:param path: Path to the file to serve.
|
||||
:param content_type: Optional MIME type override.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(path)
|
||||
self.content = path.read_bytes()
|
||||
|
||||
if content_type:
|
||||
self.mimetype = content_type
|
||||
else:
|
||||
import mimetypes
|
||||
|
||||
guessed = mimetypes.guess_type(str(path))[0]
|
||||
self.mimetype = guessed or "application/octet-stream"
|
||||
|
||||
def redirect(self, location, *, set_text=True, status_code=HTTP_301):
|
||||
self.status_code = status_code
|
||||
if set_text:
|
||||
self.text = f"Redirecting to: {location}"
|
||||
self.headers.update({"Location": location})
|
||||
|
||||
@property
|
||||
def body(self):
|
||||
if self.content:
|
||||
return (self.content, self.mimetype, {})
|
||||
async def body(self):
|
||||
if self._stream is not None:
|
||||
return (self._stream(), {})
|
||||
|
||||
if self.text:
|
||||
return (self.text.encode(self.encoding), {"Encoding": self.encoding})
|
||||
if self.content is not None:
|
||||
headers = {}
|
||||
content = self.content
|
||||
if self.mimetype is not None:
|
||||
headers["Content-Type"] = self.mimetype
|
||||
if self.mimetype == "text/plain" and self.encoding is not None:
|
||||
headers["Encoding"] = self.encoding
|
||||
if isinstance(content, str):
|
||||
content = content.encode(self.encoding)
|
||||
return (content, headers)
|
||||
|
||||
for format in self.formats:
|
||||
if self.req.accepts(format):
|
||||
return self.formats[format](self, encode=True), {}
|
||||
for format_ in self.formats:
|
||||
if self.req.accepts(format_):
|
||||
return (await self.formats[format_](self, encode=True)), {}
|
||||
|
||||
# Default to JSON anyway.
|
||||
else:
|
||||
return (json.dumps(self.media), {"Content-Type": "application/json"})
|
||||
return (
|
||||
await self.formats["json"](self, encode=True),
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
@property
|
||||
def gzipped_body(self):
|
||||
def set_cookie(
|
||||
self,
|
||||
key,
|
||||
value="",
|
||||
expires=None,
|
||||
path="/",
|
||||
domain=None,
|
||||
max_age=None,
|
||||
secure=False,
|
||||
httponly=True,
|
||||
):
|
||||
self.cookies[key] = value
|
||||
morsel = self.cookies[key]
|
||||
if expires is not None:
|
||||
morsel["expires"] = expires
|
||||
if path is not None:
|
||||
morsel["path"] = path
|
||||
if domain is not None:
|
||||
morsel["domain"] = domain
|
||||
if max_age is not None:
|
||||
morsel["max-age"] = max_age
|
||||
morsel["secure"] = secure
|
||||
morsel["httponly"] = httponly
|
||||
|
||||
body, headers = self.body
|
||||
def _prepare_cookies(self, starlette_response):
|
||||
cookie_header = (
|
||||
(b"set-cookie", morsel.output(header="").lstrip().encode("latin-1"))
|
||||
for morsel in self.cookies.values()
|
||||
)
|
||||
starlette_response.raw_headers.extend(cookie_header)
|
||||
|
||||
if isinstance(body, str):
|
||||
body = body.encode(self.encoding)
|
||||
|
||||
if "gzip" in self.req.headers["Accept-Encoding"].lower():
|
||||
gzip_buffer = io.BytesIO()
|
||||
gzip_file = gzip.GzipFile(mode="wb", fileobj=gzip_buffer)
|
||||
gzip_file.write(body)
|
||||
gzip_file.close()
|
||||
|
||||
new_headers = {
|
||||
"Content-Encoding": "gzip",
|
||||
"Vary": "Accept-Encoding",
|
||||
"Content-Length": str(len(body)),
|
||||
}
|
||||
headers.update(new_headers)
|
||||
|
||||
return (gzip_buffer.getvalue(), headers)
|
||||
else:
|
||||
return (body, headers)
|
||||
|
||||
@property
|
||||
def _wz(self):
|
||||
body, headers = self.body
|
||||
if len(self.body) > 500:
|
||||
body, headers = self.gzipped_body
|
||||
async def __call__(self, scope, receive, send):
|
||||
body, headers = await self.body
|
||||
if self.headers:
|
||||
headers.update(self.headers)
|
||||
|
||||
r = WerkzeugResponse(body, status=self.status_code, direct_passthrough=False)
|
||||
r.headers = headers
|
||||
return r
|
||||
response_cls: type[StarletteResponse] | type[StarletteStreamingResponse]
|
||||
if self._stream is not None:
|
||||
response_cls = StarletteStreamingResponse
|
||||
else:
|
||||
response_cls = StarletteResponse
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
return self._wz(environ, start_response)
|
||||
response = response_cls(body, status_code=self.status_code_safe, headers=headers)
|
||||
self._prepare_cookies(response)
|
||||
|
||||
await response(scope, receive, send)
|
||||
|
||||
class Schema(graphene.Schema):
|
||||
def on_request(self, req, resp):
|
||||
pass
|
||||
@property
|
||||
def ok(self):
|
||||
return 200 <= self.status_code_safe < 300
|
||||
|
||||
@property
|
||||
def status_code_safe(self) -> int:
|
||||
if self.status_code is None:
|
||||
raise RuntimeError("HTTP status code has not been defined")
|
||||
return self.status_code
|
||||
|
||||
+378
-22
@@ -1,38 +1,394 @@
|
||||
from parse import parse, search
|
||||
import asyncio
|
||||
import inspect
|
||||
import re
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
|
||||
__all__ = ["Route", "WebSocketRoute", "Router"]
|
||||
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.types import ASGIApp
|
||||
from starlette.websockets import WebSocket, WebSocketClose
|
||||
|
||||
from . import status_codes
|
||||
from .formats import get_formats
|
||||
from .models import Request, Response
|
||||
|
||||
_UUID_RE = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
||||
|
||||
_CONVERTORS = {
|
||||
"int": (int, r"\d+"),
|
||||
"str": (str, r"[^/]+"),
|
||||
"float": (float, r"\d+(.\d+)?"),
|
||||
"path": (str, r".+"),
|
||||
"uuid": (str, _UUID_RE),
|
||||
}
|
||||
|
||||
PARAM_RE = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}")
|
||||
|
||||
|
||||
class Route:
|
||||
def __init__(self, route, endpoint):
|
||||
def compile_path(path):
|
||||
path_re = "^"
|
||||
param_convertors = {}
|
||||
idx = 0
|
||||
|
||||
for match in PARAM_RE.finditer(path):
|
||||
param_name, convertor_type = match.groups(default="str")
|
||||
convertor_type = convertor_type.lstrip(":")
|
||||
assert convertor_type in _CONVERTORS.keys(), (
|
||||
f"Unknown path convertor '{convertor_type}'"
|
||||
)
|
||||
convertor, convertor_re = _CONVERTORS[convertor_type]
|
||||
|
||||
path_re += path[idx : match.start()]
|
||||
path_re += rf"(?P<{param_name}>{convertor_re})"
|
||||
|
||||
param_convertors[param_name] = convertor
|
||||
|
||||
idx = match.end()
|
||||
|
||||
path_re += path[idx:] + "$"
|
||||
|
||||
return re.compile(path_re), param_convertors
|
||||
|
||||
|
||||
class BaseRoute:
|
||||
def matches(self, scope):
|
||||
raise NotImplementedError()
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class Route(BaseRoute):
|
||||
def __init__(self, route, endpoint, *, before_request=False, methods=None):
|
||||
assert route.startswith("/"), "Route path must start with '/'"
|
||||
self.route = route
|
||||
self.endpoint = endpoint
|
||||
self.before_request = before_request
|
||||
self.methods = {m.upper() for m in methods} if methods else None
|
||||
|
||||
self.path_re, self.param_convertors = compile_path(route)
|
||||
# Strip type annotations for URL generation (e.g. {id:int} -> {id})
|
||||
self._url_template = PARAM_RE.sub(r"{\1}", route)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Route {self.route!r}={self.endpoint!r}>"
|
||||
|
||||
def __eq__(self, other):
|
||||
if hasattr(other, "route"):
|
||||
# Being compared to other routes.
|
||||
return self.route == other.route
|
||||
else:
|
||||
# Strings.
|
||||
return self.does_match(other)
|
||||
def url(self, **params):
|
||||
return self._url_template.format(**params)
|
||||
|
||||
@property
|
||||
def has_parameters(self):
|
||||
return all([("{" in self.route), ("}" in self.route)])
|
||||
def endpoint_name(self):
|
||||
return self.endpoint.__name__
|
||||
|
||||
def does_match(self, s):
|
||||
if s == self.route:
|
||||
return True
|
||||
@property
|
||||
def description(self):
|
||||
return self.endpoint.__doc__
|
||||
|
||||
named = self.incoming_matches(s)
|
||||
return bool(len(named))
|
||||
def matches(self, scope):
|
||||
if scope["type"] != "http":
|
||||
return False, {}
|
||||
|
||||
def incoming_matches(self, s):
|
||||
results = parse(self.route, s)
|
||||
return results.named if results else {}
|
||||
if self.methods and scope.get("method", "").upper() not in self.methods:
|
||||
return False, {}
|
||||
|
||||
path = scope["path"]
|
||||
match = self.path_re.match(path)
|
||||
|
||||
if match is None:
|
||||
return False, {}
|
||||
|
||||
matched_params = match.groupdict()
|
||||
for key, value in matched_params.items():
|
||||
matched_params[key] = self.param_convertors[key](value)
|
||||
|
||||
return True, {"path_params": {**matched_params}}
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
request = Request(scope, receive, formats=get_formats())
|
||||
response = Response(req=request, formats=get_formats())
|
||||
|
||||
path_params = scope.get("path_params", {})
|
||||
before_requests = scope.get("before_requests", [])
|
||||
|
||||
for before_request in before_requests.get("http", []):
|
||||
if asyncio.iscoroutinefunction(before_request):
|
||||
await before_request(request, response)
|
||||
else:
|
||||
await run_in_threadpool(before_request, request, response)
|
||||
# If a before_request hook set a status code, short-circuit
|
||||
if response.status_code is not None:
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
views = []
|
||||
|
||||
if inspect.isclass(self.endpoint):
|
||||
endpoint = self.endpoint()
|
||||
on_request = getattr(endpoint, "on_request", None)
|
||||
if on_request:
|
||||
views.append(on_request)
|
||||
|
||||
method_name = f"on_{request.method}"
|
||||
try:
|
||||
view = getattr(endpoint, method_name)
|
||||
views.append(view)
|
||||
except AttributeError as ex:
|
||||
if on_request is None:
|
||||
raise HTTPException(status_code=status_codes.HTTP_405) from ex # type: ignore[attr-defined]
|
||||
else:
|
||||
views.append(self.endpoint)
|
||||
|
||||
for view in views:
|
||||
# Check __call__ for class-based views (e.g. GraphQL)
|
||||
if asyncio.iscoroutinefunction(view) or asyncio.iscoroutinefunction(
|
||||
view.__call__
|
||||
):
|
||||
await view(request, response, **path_params)
|
||||
else:
|
||||
await run_in_threadpool(view, request, response, **path_params)
|
||||
|
||||
if response.status_code is None:
|
||||
response.status_code = status_codes.HTTP_200 # type: ignore[attr-defined]
|
||||
|
||||
await response(scope, receive, send)
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.route == other.route and self.endpoint == other.endpoint
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.route) ^ hash(self.endpoint) ^ hash(self.before_request)
|
||||
|
||||
|
||||
class WebSocketRoute(BaseRoute):
|
||||
def __init__(self, route, endpoint, *, before_request=False):
|
||||
assert route.startswith("/"), "Route path must start with '/'"
|
||||
self.route = route
|
||||
self.endpoint = endpoint
|
||||
self.before_request = before_request
|
||||
|
||||
self.path_re, self.param_convertors = compile_path(route)
|
||||
self._url_template = PARAM_RE.sub(r"{\1}", route)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Route {self.route!r}={self.endpoint!r}>"
|
||||
|
||||
def url(self, **params):
|
||||
return self.route.format(**params)
|
||||
return self._url_template.format(**params)
|
||||
|
||||
# def is_graphql, is_wsgi
|
||||
@property
|
||||
def endpoint_name(self):
|
||||
return self.endpoint.__name__
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return self.endpoint.__doc__
|
||||
|
||||
def matches(self, scope):
|
||||
if scope["type"] != "websocket":
|
||||
return False, {}
|
||||
|
||||
path = scope["path"]
|
||||
match = self.path_re.match(path)
|
||||
|
||||
if match is None:
|
||||
return False, {}
|
||||
|
||||
matched_params = match.groupdict()
|
||||
for key, value in matched_params.items():
|
||||
matched_params[key] = self.param_convertors[key](value)
|
||||
|
||||
return True, {"path_params": {**matched_params}}
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
ws = WebSocket(scope, receive, send)
|
||||
|
||||
before_requests = scope.get("before_requests", [])
|
||||
for before_request in before_requests.get("ws", []):
|
||||
await before_request(ws)
|
||||
|
||||
await self.endpoint(ws)
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.route == other.route and self.endpoint == other.endpoint
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.route) ^ hash(self.endpoint) ^ hash(self.before_request)
|
||||
|
||||
|
||||
class Router:
|
||||
def __init__(
|
||||
self, routes=None, default_response=None, before_requests=None, lifespan=None
|
||||
):
|
||||
self.routes = [] if routes is None else list(routes)
|
||||
|
||||
self.apps: dict[str, ASGIApp] = {}
|
||||
self.default_endpoint = (
|
||||
self.default_response if default_response is None else default_response
|
||||
)
|
||||
self.before_requests = (
|
||||
{"http": [], "ws": []} if before_requests is None else before_requests
|
||||
)
|
||||
self.events = defaultdict(list)
|
||||
self._lifespan_handler = lifespan
|
||||
|
||||
def add_route(
|
||||
self,
|
||||
route=None,
|
||||
endpoint=None,
|
||||
*,
|
||||
default=False,
|
||||
websocket=False,
|
||||
before_request=False,
|
||||
check_existing=False,
|
||||
methods=None,
|
||||
):
|
||||
"""Adds a route to the router.
|
||||
:param route: A string representation of the route
|
||||
:param endpoint: The endpoint for the route -- can be callable, or class.
|
||||
:param default: If ``True``, all unknown requests will route to this view.
|
||||
:param methods: Optional list of HTTP methods (e.g. ["GET", "POST"]).
|
||||
"""
|
||||
if before_request:
|
||||
if websocket:
|
||||
self.before_requests.setdefault("ws", []).append(endpoint)
|
||||
else:
|
||||
self.before_requests.setdefault("http", []).append(endpoint)
|
||||
return
|
||||
|
||||
if check_existing:
|
||||
assert not self.routes or route not in (item.route for item in self.routes), (
|
||||
f"Route '{route}' already exists"
|
||||
)
|
||||
|
||||
if default:
|
||||
self.default_endpoint = endpoint
|
||||
|
||||
if websocket:
|
||||
route = WebSocketRoute(route, endpoint)
|
||||
else:
|
||||
route = Route(route, endpoint, methods=methods)
|
||||
|
||||
self.routes.append(route)
|
||||
|
||||
def mount(self, route, app):
|
||||
"""Mounts ASGI / WSGI applications at a given route"""
|
||||
self.apps.update({route: app})
|
||||
|
||||
def add_event_handler(self, event_type, handler):
|
||||
assert event_type in (
|
||||
"startup",
|
||||
"shutdown",
|
||||
), f"Only 'startup' and 'shutdown' events are supported, not {event_type}."
|
||||
self.events[event_type].append(handler)
|
||||
|
||||
async def trigger_event(self, event_type):
|
||||
for handler in self.events.get(event_type, []):
|
||||
if asyncio.iscoroutinefunction(handler):
|
||||
await handler()
|
||||
else:
|
||||
handler()
|
||||
|
||||
def before_request(self, endpoint, websocket=False):
|
||||
if websocket:
|
||||
self.before_requests.setdefault("ws", []).append(endpoint)
|
||||
else:
|
||||
self.before_requests.setdefault("http", []).append(endpoint)
|
||||
|
||||
def url_for(self, endpoint, **params):
|
||||
for route in self.routes:
|
||||
if endpoint in (route.endpoint, route.endpoint.__name__):
|
||||
return route.url(**params)
|
||||
return None
|
||||
|
||||
async def default_response(self, scope, receive, send):
|
||||
if scope["type"] == "websocket":
|
||||
websocket_close = WebSocketClose()
|
||||
await websocket_close(scope, receive, send)
|
||||
return
|
||||
|
||||
request = Request(scope, receive)
|
||||
response = Response(request, formats=get_formats()) # noqa: F841
|
||||
|
||||
raise HTTPException(status_code=status_codes.HTTP_404) # type: ignore[attr-defined]
|
||||
|
||||
def _resolve_route(self, scope):
|
||||
for route in self.routes:
|
||||
matches, child_scope = route.matches(scope)
|
||||
if matches:
|
||||
scope.update(child_scope)
|
||||
return route
|
||||
return None
|
||||
|
||||
async def lifespan(self, scope, receive, send):
|
||||
message = await receive()
|
||||
assert message["type"] == "lifespan.startup"
|
||||
|
||||
if self._lifespan_handler is not None:
|
||||
# Modern lifespan context manager pattern
|
||||
try:
|
||||
ctx = self._lifespan_handler(scope.get("app"))
|
||||
await ctx.__aenter__()
|
||||
except BaseException:
|
||||
msg = traceback.format_exc()
|
||||
await send({"type": "lifespan.startup.failed", "message": msg})
|
||||
raise
|
||||
|
||||
await send({"type": "lifespan.startup.complete"})
|
||||
message = await receive()
|
||||
assert message["type"] == "lifespan.shutdown"
|
||||
|
||||
await ctx.__aexit__(None, None, None)
|
||||
else:
|
||||
# Legacy on_event("startup") / on_event("shutdown") pattern
|
||||
try:
|
||||
await self.trigger_event("startup")
|
||||
except BaseException:
|
||||
msg = traceback.format_exc()
|
||||
await send({"type": "lifespan.startup.failed", "message": msg})
|
||||
raise
|
||||
|
||||
await send({"type": "lifespan.startup.complete"})
|
||||
message = await receive()
|
||||
assert message["type"] == "lifespan.shutdown"
|
||||
await self.trigger_event("shutdown")
|
||||
|
||||
await send({"type": "lifespan.shutdown.complete"})
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
assert scope["type"] in ("http", "websocket", "lifespan")
|
||||
|
||||
if scope["type"] == "lifespan":
|
||||
await self.lifespan(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope["path"]
|
||||
root_path = scope.get("root_path", "")
|
||||
|
||||
# Check "primary" mounted routes first (before submounted apps)
|
||||
route = self._resolve_route(scope)
|
||||
|
||||
scope["before_requests"] = self.before_requests
|
||||
|
||||
if route is not None:
|
||||
await route(scope, receive, send)
|
||||
return
|
||||
|
||||
# Call into a submounted app, if one exists.
|
||||
for path_prefix, app in self.apps.items():
|
||||
if path.startswith(path_prefix):
|
||||
scope["path"] = path[len(path_prefix) :]
|
||||
scope["root_path"] = root_path + path_prefix
|
||||
try:
|
||||
await app(scope, receive, send)
|
||||
return
|
||||
except TypeError:
|
||||
from a2wsgi import WSGIMiddleware
|
||||
|
||||
app = WSGIMiddleware(app)
|
||||
await app(scope, receive, send)
|
||||
return
|
||||
|
||||
await self.default_endpoint(scope, receive, send)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from starlette.staticfiles import StaticFiles as StarletteStaticFiles
|
||||
|
||||
|
||||
class StaticFiles(StarletteStaticFiles):
|
||||
"""Extension to Starlette's StaticFiles with support for multiple directories."""
|
||||
|
||||
def add_directory(self, directory: str) -> None:
|
||||
self.all_directories = [*self.all_directories, *self.get_directories(directory)]
|
||||
@@ -0,0 +1,15 @@
|
||||
API_THEMES = ["elements", "rapidoc", "redoc", "swagger_ui"]
|
||||
DEFAULT_ENCODING = "utf-8"
|
||||
DEFAULT_OPENAPI_THEME = "swagger_ui"
|
||||
DEFAULT_SESSION_COOKIE = "Responder-Session"
|
||||
DEFAULT_SECRET_KEY = "NOTASECRET" # noqa: S105
|
||||
|
||||
DEFAULT_CORS_PARAMS = {
|
||||
"allow_origins": (),
|
||||
"allow_methods": ("GET",),
|
||||
"allow_headers": (),
|
||||
"allow_credentials": False,
|
||||
"allow_origin_regex": None,
|
||||
"expose_headers": (),
|
||||
"max_age": 600,
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
# from: https://github.com/requests/requests/blob/master/requests/status_codes.py
|
||||
|
||||
codes = {
|
||||
# Informational.
|
||||
100: ("continue",),
|
||||
@@ -26,11 +24,7 @@ codes = {
|
||||
305: ("use_proxy",),
|
||||
306: ("switch_proxy",),
|
||||
307: ("temporary_redirect", "temporary_moved", "temporary"),
|
||||
308: (
|
||||
"permanent_redirect",
|
||||
"resume_incomplete",
|
||||
"resume",
|
||||
), # These 2 to be removed in 3.0
|
||||
308: ("permanent_redirect",),
|
||||
# Client Error.
|
||||
400: ("bad_request", "bad"),
|
||||
401: ("unauthorized",),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
from contextlib import contextmanager
|
||||
|
||||
import jinja2
|
||||
|
||||
__all__ = ["Templates"]
|
||||
|
||||
|
||||
class Templates:
|
||||
def __init__(
|
||||
self, directory="templates", autoescape=True, context=None, enable_async=False
|
||||
):
|
||||
self.directory = directory
|
||||
self._env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader([str(self.directory)]),
|
||||
autoescape=autoescape, # noqa: S701
|
||||
enable_async=enable_async,
|
||||
)
|
||||
self.default_context = {} if context is None else {**context}
|
||||
self._env.globals.update(self.default_context)
|
||||
|
||||
@property
|
||||
def context(self):
|
||||
return self._env.globals
|
||||
|
||||
@context.setter
|
||||
def context(self, context):
|
||||
self._env.globals = {**self.default_context, **context}
|
||||
|
||||
def get_template(self, name):
|
||||
return self._env.get_template(name)
|
||||
|
||||
def render(self, template, *args, **kwargs):
|
||||
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template, with provided values supplied.
|
||||
|
||||
:param template: The filename of the jinja2 template.
|
||||
:param **kwargs: Data to pass into the template.
|
||||
:param **kwargs: Data to pass into the template.
|
||||
""" # noqa: E501
|
||||
return self.get_template(template).render(*args, **kwargs)
|
||||
|
||||
@contextmanager
|
||||
def _async(self):
|
||||
self._env.is_async = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._env.is_async = False
|
||||
|
||||
async def render_async(self, template, *args, **kwargs):
|
||||
with self._async():
|
||||
return await self.get_template(template).render_async(*args, **kwargs)
|
||||
|
||||
def render_string(self, source, *args, **kwargs):
|
||||
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template string, with provided values supplied.
|
||||
|
||||
:param source: The template to use.
|
||||
:param *args, **kwargs: Data to pass into the template.
|
||||
:param **kwargs: Data to pass into the template.
|
||||
""" # noqa: E501
|
||||
template = self._env.from_string(source)
|
||||
return template.render(*args, **kwargs)
|
||||
@@ -0,0 +1,242 @@
|
||||
# ruff: noqa: S603 # Subprocess call - output not captured
|
||||
# ruff: noqa: S607 # Starting a process with a partial executable path
|
||||
# Security considerations for subprocess usage:
|
||||
# 1. Only execute the 'responder' binary from PATH
|
||||
# 2. Validate all user inputs before passing to subprocess
|
||||
# 3. Use Path.resolve() to prevent path traversal
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResponderProgram:
|
||||
"""
|
||||
Utility class for managing Responder program execution.
|
||||
|
||||
This class provides methods for:
|
||||
- Locating the responder executable in PATH
|
||||
- Building frontend assets using npm
|
||||
|
||||
Example:
|
||||
>>> program_path = ResponderProgram.path()
|
||||
>>> build_status = ResponderProgram.build(Path("app_dir"))
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def path():
|
||||
name = "responder"
|
||||
if sys.platform == "win32":
|
||||
name = "responder.exe"
|
||||
program = shutil.which(name)
|
||||
if program is None:
|
||||
paths = os.environ.get("PATH", "").split(os.pathsep)
|
||||
raise RuntimeError(
|
||||
f"Could not find '{name}' executable in PATH. "
|
||||
f"Please install Responder with 'pip install --upgrade responder'. "
|
||||
f"Searched in: {', '.join(paths)}"
|
||||
)
|
||||
logger.debug(f"Found responder program: {program}")
|
||||
return program
|
||||
|
||||
@classmethod
|
||||
def build(cls, path: Path) -> int:
|
||||
"""
|
||||
Invoke `responder build` command.
|
||||
|
||||
Args:
|
||||
path: Path to the application to build
|
||||
|
||||
Returns:
|
||||
int: The return code from the build process
|
||||
|
||||
Raises:
|
||||
ValueError: If the path is invalid
|
||||
RuntimeError: If the responder executable is not found
|
||||
subprocess.SubprocessError: If the build process fails
|
||||
"""
|
||||
|
||||
if not isinstance(path, Path):
|
||||
raise ValueError(f"Expected a Path object, got {type(path).__name__}")
|
||||
if not path.exists():
|
||||
raise ValueError(f"Path does not exist: {path}")
|
||||
if not path.is_dir():
|
||||
raise FileNotFoundError(f"Path is not a directory: {path}")
|
||||
|
||||
command = [
|
||||
cls.path(),
|
||||
"build",
|
||||
str(path),
|
||||
]
|
||||
return subprocess.call(command)
|
||||
|
||||
|
||||
class ResponderServer(threading.Thread):
|
||||
"""
|
||||
A threaded wrapper around the `responder run` command for testing purposes.
|
||||
|
||||
This class allows running a Responder application in a separate thread,
|
||||
making it suitable for integration testing scenarios.
|
||||
|
||||
Args:
|
||||
target (str): The path to the Responder application to run
|
||||
port (int, optional): The port to run the server on. Defaults to 5042.
|
||||
limit_max_requests (int, optional): Maximum number of requests to handle
|
||||
before shutting down. Useful for testing scenarios.
|
||||
|
||||
Example:
|
||||
>>> server = ResponderServer("app.py", port=8000)
|
||||
>>> server.start()
|
||||
>>> # Run tests
|
||||
>>> server.stop()
|
||||
"""
|
||||
|
||||
def __init__(self, target: str, port: int = 5042, limit_max_requests: int = None):
|
||||
super().__init__()
|
||||
self._stopping = False
|
||||
|
||||
# Validate input variables.
|
||||
if not target or not isinstance(target, str):
|
||||
raise ValueError("Target must be a non-empty string")
|
||||
if not isinstance(port, int) or port < 1:
|
||||
raise ValueError("Port must be a positive integer")
|
||||
if limit_max_requests is not None and (
|
||||
not isinstance(limit_max_requests, int) or limit_max_requests < 1
|
||||
):
|
||||
raise ValueError("limit_max_requests must be a positive integer if specified")
|
||||
|
||||
# Check if port is available.
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("localhost", port))
|
||||
except OSError as ex:
|
||||
raise ValueError(f"Port {port} is already in use") from ex
|
||||
|
||||
# Instance variables after validation.
|
||||
self.target = target
|
||||
self.port = port
|
||||
self.limit_max_requests = limit_max_requests
|
||||
self.shutdown_timeout = 5 # seconds
|
||||
|
||||
# Allow the thread to be terminated when the main program exits.
|
||||
self.process: subprocess.Popen
|
||||
self.daemon = True
|
||||
self._process_lock = threading.Lock()
|
||||
|
||||
# Setup signal handlers.
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
|
||||
def run(self):
|
||||
command = [
|
||||
ResponderProgram.path(),
|
||||
"run",
|
||||
self.target,
|
||||
]
|
||||
if self.limit_max_requests is not None:
|
||||
command += [f"--limit-max-requests={self.limit_max_requests}"]
|
||||
|
||||
# Preserve existing environment
|
||||
env = os.environ.copy()
|
||||
|
||||
if self.port is not None:
|
||||
env["PORT"] = str(self.port)
|
||||
|
||||
with self._process_lock:
|
||||
self.process = subprocess.Popen(
|
||||
command,
|
||||
env=env,
|
||||
universal_newlines=True,
|
||||
)
|
||||
self.process.wait()
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
Gracefully stop the process (API).
|
||||
"""
|
||||
if self._stopping:
|
||||
return
|
||||
with self._process_lock:
|
||||
self._stop()
|
||||
|
||||
def _stop(self):
|
||||
"""
|
||||
Gracefully stop the process (impl).
|
||||
"""
|
||||
self._stopping = True
|
||||
if self.process and self.process.poll() is None:
|
||||
logger.info("Attempting to terminate server process...")
|
||||
self.process.terminate()
|
||||
try:
|
||||
# Wait for graceful shutdown.
|
||||
self.process.wait(timeout=self.shutdown_timeout)
|
||||
logger.info("Server process terminated gracefully")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
"Server process did not terminate gracefully, forcing kill"
|
||||
)
|
||||
self.process.kill() # Force kill if not terminated
|
||||
|
||||
def _signal_handler(self, signum, frame):
|
||||
"""
|
||||
Handle termination signals gracefully.
|
||||
"""
|
||||
logger.info("Received signal %d, shutting down...", signum)
|
||||
self.stop()
|
||||
|
||||
def wait_until_ready(self, timeout=30, request_timeout=1, delay=0.1) -> bool:
|
||||
"""
|
||||
Wait until the server is ready to accept connections.
|
||||
|
||||
Args:
|
||||
timeout (int, optional): Maximum time to wait in seconds. Defaults to 30.
|
||||
|
||||
Returns:
|
||||
bool: True if server is ready and accepting connections, False otherwise.
|
||||
"""
|
||||
start_time = time.time()
|
||||
last_error = None
|
||||
while time.time() - start_time < timeout:
|
||||
if not self.is_running():
|
||||
if self.process is None:
|
||||
logger.error("Server process was never started")
|
||||
else:
|
||||
returncode = self.process.poll()
|
||||
logger.error("Server process exited with code: %d", returncode)
|
||||
return False
|
||||
try:
|
||||
with socket.create_connection(
|
||||
("localhost", self.port), timeout=request_timeout
|
||||
):
|
||||
return True
|
||||
except (
|
||||
socket.timeout,
|
||||
ConnectionRefusedError,
|
||||
socket.gaierror,
|
||||
OSError,
|
||||
) as ex:
|
||||
last_error = ex
|
||||
logger.debug(f"Server not ready yet: {ex}")
|
||||
time.sleep(delay)
|
||||
logger.error(
|
||||
"Server failed to start within %d seconds. Last error: %s",
|
||||
timeout,
|
||||
last_error,
|
||||
)
|
||||
return False
|
||||
|
||||
def is_running(self):
|
||||
"""
|
||||
Check if the server process is still running.
|
||||
"""
|
||||
return self.process is not None and self.process.poll() is None
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user