mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 23:00:17 +00:00
Compare commits
431 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| a1bfbda05b | |||
| f309ad7746 |
@@ -1,6 +1,7 @@
|
||||
.vscode/
|
||||
.cache
|
||||
.idea
|
||||
.python-version
|
||||
.coverage
|
||||
.pytest_cache
|
||||
.DS_Store
|
||||
|
||||
@@ -10,3 +10,4 @@ install:
|
||||
# command to run the dependencies
|
||||
script:
|
||||
- "pytest"
|
||||
- "black responder tests setup.py --check"
|
||||
|
||||
+89
-1
@@ -1,3 +1,91 @@
|
||||
# v1.3.0
|
||||
|
||||
## Fixed
|
||||
- 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.1
|
||||
- Run sync views in a threadpoolexecutor.
|
||||
|
||||
# v1.1.0
|
||||
- Support for `before_request`.
|
||||
|
||||
# v1.0.4
|
||||
- Potential bufix for cookies.
|
||||
|
||||
# v1.0.3
|
||||
- Bugfix for redirects.
|
||||
|
||||
# v1.0.2
|
||||
- Improvement for static file hosting.
|
||||
|
||||
# v1.0.1
|
||||
- Improve cors configuration settings.
|
||||
|
||||
# v1.0.0
|
||||
- Move GraphQL support into a built-in plugin.
|
||||
|
||||
# v0.3.3
|
||||
- Improved exceptions.
|
||||
- CORS support.
|
||||
|
||||
# v0.3.2
|
||||
- Subtle improvements.
|
||||
|
||||
# v0.3.1
|
||||
- Packaging fix.
|
||||
|
||||
# v0.3.0
|
||||
- Interactive Documentation endpoint.
|
||||
- Minor improvements.
|
||||
|
||||
# v0.2.3
|
||||
- Overall improvements.
|
||||
|
||||
# v0.2.2
|
||||
- Show traceback info when background tasks raise exceptions.
|
||||
|
||||
# v0.2.1
|
||||
- api.requests.
|
||||
|
||||
# v0.2.0
|
||||
- WebSocket support.
|
||||
|
||||
# v0.1.6
|
||||
- 500 support.
|
||||
|
||||
# v0.1.5
|
||||
- Improvements to sequential media reading.
|
||||
- File upload support.
|
||||
|
||||
# v0.1.4
|
||||
- Stability.
|
||||
|
||||
# v0.1.3
|
||||
- Sessions support.
|
||||
|
||||
# v0.1.2
|
||||
- Cookies support.
|
||||
|
||||
# v0.1.1
|
||||
- Default routes.
|
||||
|
||||
# v0.1.0
|
||||
- Prototype of static application support.
|
||||
|
||||
# v0.0.10
|
||||
- Bugfix for async class-based views.
|
||||
|
||||
# v0.0.9
|
||||
- Bugfix for async class-based views.
|
||||
|
||||
# v0.0.8
|
||||
- GraphiQL Support.
|
||||
- Improvement to route selection.
|
||||
@@ -14,7 +102,7 @@
|
||||
- Safe load/dump yaml.
|
||||
|
||||
# v0.0.4:
|
||||
- Asyncronous support for data uploads.
|
||||
- Asynchronous support for data uploads.
|
||||
- Bug fixes.
|
||||
|
||||
# v0.0.3:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
include LICENSE
|
||||
@@ -14,6 +14,7 @@ twine = "*"
|
||||
flask = "*"
|
||||
sphinx = "*"
|
||||
marshmallow = "*"
|
||||
pytest-cov = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.7"
|
||||
|
||||
Generated
+329
-226
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "9b959d9507c521f6088646507633207db03afec6ac31aeab07adf0d737dbb45b"
|
||||
"sha256": "7bbe1f0addd73250027de73d6fb749aa2be3149af9744b107820c5e10498428e"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@@ -32,10 +32,16 @@
|
||||
},
|
||||
"apispec": {
|
||||
"hashes": [
|
||||
"sha256:c2e6ac6471aaf7c6ec6d12714821898910c6b3c87c189de9a2e3754786b86ada",
|
||||
"sha256:fa7dfa8a292bae9b1e70c44a50bf61901805821726c5b804568c9f2501f57ebb"
|
||||
"sha256:57a7b81fd19fff0663a7e5ffd196eaea79b5364151ed2b65533be36d55e0229c",
|
||||
"sha256:b45def53903516e67e8584ee41f34bc60c3e4acace6892b69340293ea20f3caa"
|
||||
],
|
||||
"version": "==1.0.0b3"
|
||||
"version": "==1.0.0"
|
||||
},
|
||||
"apistar": {
|
||||
"hashes": [
|
||||
"sha256:4338b24468b49526ceac4a8f84046056081ee747f373ca8d0647bd6b2344c895"
|
||||
],
|
||||
"version": "==0.6.0"
|
||||
},
|
||||
"asgiref": {
|
||||
"hashes": [
|
||||
@@ -53,10 +59,10 @@
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:339dc09518b07e2fa7eda5450740925974815557727d6bd35d319c1524a04a4c",
|
||||
"sha256:6d58c986d22b038c8c0df30d639f23a3e6d172a05c3583e766f4c0b785c0986a"
|
||||
"sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7",
|
||||
"sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033"
|
||||
],
|
||||
"version": "==2018.10.15"
|
||||
"version": "==2018.11.29"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
@@ -72,6 +78,12 @@
|
||||
],
|
||||
"version": "==7.0"
|
||||
},
|
||||
"docopt": {
|
||||
"hashes": [
|
||||
"sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"
|
||||
],
|
||||
"version": "==0.6.2"
|
||||
},
|
||||
"graphene": {
|
||||
"hashes": [
|
||||
"sha256:b8ec446d17fa68721636eaad3d6adc1a378cb6323e219814c8f98c9928fc9642",
|
||||
@@ -105,12 +117,25 @@
|
||||
],
|
||||
"version": "==0.8.1"
|
||||
},
|
||||
"httptools": {
|
||||
"hashes": [
|
||||
"sha256:04c7703bbef0e8ca28b09811547352b8c7c20549eab70dc24e536bb24fd2b7c5"
|
||||
],
|
||||
"version": "==0.0.11"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e",
|
||||
"sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"
|
||||
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
|
||||
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
|
||||
],
|
||||
"version": "==2.7"
|
||||
"version": "==2.8"
|
||||
},
|
||||
"itsdangerous": {
|
||||
"hashes": [
|
||||
"sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19",
|
||||
"sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"
|
||||
],
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
@@ -121,22 +146,49 @@
|
||||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
"sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665"
|
||||
"sha256:048ef924c1623740e70204aa7143ec592504045ae4429b59c30054cb31e3c432",
|
||||
"sha256:130f844e7f5bdd8e9f3f42e7102ef1d49b2e6fdf0d7526df3f87281a532d8c8b",
|
||||
"sha256:19f637c2ac5ae9da8bfd98cef74d64b7e1bb8a63038a3505cd182c3fac5eb4d9",
|
||||
"sha256:1b8a7a87ad1b92bd887568ce54b23565f3fd7018c4180136e1cf412b405a47af",
|
||||
"sha256:1c25694ca680b6919de53a4bb3bdd0602beafc63ff001fea2f2fc16ec3a11834",
|
||||
"sha256:1f19ef5d3908110e1e891deefb5586aae1b49a7440db952454b4e281b41620cd",
|
||||
"sha256:1fa6058938190ebe8290e5cae6c351e14e7bb44505c4a7624555ce57fbbeba0d",
|
||||
"sha256:31cbb1359e8c25f9f48e156e59e2eaad51cd5242c05ed18a8de6dbe85184e4b7",
|
||||
"sha256:3e835d8841ae7863f64e40e19477f7eb398674da6a47f09871673742531e6f4b",
|
||||
"sha256:4e97332c9ce444b0c2c38dd22ddc61c743eb208d916e4265a2a3b575bdccb1d3",
|
||||
"sha256:525396ee324ee2da82919f2ee9c9e73b012f23e7640131dd1b53a90206a0f09c",
|
||||
"sha256:52b07fbc32032c21ad4ab060fec137b76eb804c4b9a1c7c7dc562549306afad2",
|
||||
"sha256:52ccb45e77a1085ec5461cde794e1aa037df79f473cbc69b974e73940655c8d7",
|
||||
"sha256:5c3fbebd7de20ce93103cb3183b47671f2885307df4a17a0ad56a1dd51273d36",
|
||||
"sha256:5e5851969aea17660e55f6a3be00037a25b96a9b44d2083651812c99d53b14d1",
|
||||
"sha256:5edfa27b2d3eefa2210fb2f5d539fbed81722b49f083b2c6566455eb7422fd7e",
|
||||
"sha256:7d263e5770efddf465a9e31b78362d84d015cc894ca2c131901a4445eaa61ee1",
|
||||
"sha256:83381342bfc22b3c8c06f2dd93a505413888694302de25add756254beee8449c",
|
||||
"sha256:857eebb2c1dc60e4219ec8e98dfa19553dae33608237e107db9c6078b1167856",
|
||||
"sha256:98e439297f78fca3a6169fd330fbe88d78b3bb72f967ad9961bcac0d7fdd1550",
|
||||
"sha256:bf54103892a83c64db58125b3f2a43df6d2cb2d28889f14c78519394feb41492",
|
||||
"sha256:d9ac82be533394d341b41d78aca7ed0e0f4ba5a2231602e2f05aa87f25c51672",
|
||||
"sha256:e982fe07ede9fada6ff6705af70514a52beb1b2c3d25d4e873e82114cf3c5401",
|
||||
"sha256:edce2ea7f3dfc981c4ddc97add8a61381d9642dc3273737e756517cc03e84dd6",
|
||||
"sha256:efdc45ef1afc238db84cb4963aa689c0408912a0239b0721cb172b4016eb31d6",
|
||||
"sha256:f137c02498f8b935892d5c0172560d7ab54bc45039de8805075e19079c639a9c",
|
||||
"sha256:f82e347a72f955b7017a39708a3667f106e6ad4d10b25f237396a7115d8ed5fd",
|
||||
"sha256:fb7c206e01ad85ce57feeaaa0bf784b97fa3cad0d4a5737bc5295785f5c613a1"
|
||||
],
|
||||
"version": "==1.0"
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"marshmallow": {
|
||||
"hashes": [
|
||||
"sha256:82b201ad767eb54de371c08cb1db6ca4ad2a728fa41b831e3781bf944815eb38",
|
||||
"sha256:c250f37ac0e249a8287394a60d91f6240b674642ad999e66cd09463dbccd1d4f"
|
||||
"sha256:7f9aba737a59dd3c6c6c79846f1df2fbfe036c17f038bbc2c83911b7304a90e1",
|
||||
"sha256:b41cc52fe0491bdb8aa3e2186ca57d478d9ef69dba87fe37d309aa8a08fd30dd"
|
||||
],
|
||||
"version": "==3.0.0b18"
|
||||
"version": "==3.0.0rc4"
|
||||
},
|
||||
"parse": {
|
||||
"hashes": [
|
||||
"sha256:9dd6048ea212cd032a342f9f6aa2b7bc222f7407c7e37bdc2777fecd36897437"
|
||||
"sha256:870dd675c1ee8951db3e29b81ebe44fd131e3eb8c03a79483a58ea574f3145c2"
|
||||
],
|
||||
"version": "==1.9.0"
|
||||
"version": "==1.11.1"
|
||||
},
|
||||
"promise": {
|
||||
"hashes": [
|
||||
@@ -163,10 +215,17 @@
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1",
|
||||
"sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a"
|
||||
"sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e",
|
||||
"sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b"
|
||||
],
|
||||
"version": "==2.19.1"
|
||||
"version": "==2.21.0"
|
||||
},
|
||||
"requests-toolbelt": {
|
||||
"hashes": [
|
||||
"sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f",
|
||||
"sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"
|
||||
],
|
||||
"version": "==0.9.1"
|
||||
},
|
||||
"responder": {
|
||||
"editable": true,
|
||||
@@ -174,69 +233,91 @@
|
||||
},
|
||||
"rfc3986": {
|
||||
"hashes": [
|
||||
"sha256:632b8fcd2ac37f24334316227f909be4f9d0738cbf409404cff6fa5f69a24093",
|
||||
"sha256:8458571c4c57e1cf23593ad860bb601b6a604df6217f829c2bc70dc4b5af941b"
|
||||
"sha256:5ad82677b02b88c8d24f6511b4ee9baa5e7da675599b479fbbc5c9c578b5b737",
|
||||
"sha256:bc3ae4b7cd88a99eff2d3900fcb858d44562fd7f273fc07aeef568b9bb6fc4e1"
|
||||
],
|
||||
"version": "==1.1.0"
|
||||
"version": "==1.2.0"
|
||||
},
|
||||
"rx": {
|
||||
"hashes": [
|
||||
"sha256:13a1d8d9e252625c173dc795471e614eadfe1cf40ffc684e08b8fff0d9748c23",
|
||||
"sha256:7357592bc7e881a95e0c2013b73326f704953301ab551fbc8133a6fadab84105"
|
||||
"sha256:7e6919a3159d6c6cee266fdeeb48783118bab20177e38fa5d6a8ebd24a132f72",
|
||||
"sha256:e7ccf18bb8e76f8a44557febd5c149c2ad36df19442f678be529c710b0553d85"
|
||||
],
|
||||
"version": "==1.6.1"
|
||||
"version": "==3.0.0a2"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
|
||||
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
|
||||
"sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
|
||||
"sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
|
||||
],
|
||||
"version": "==1.11.0"
|
||||
"version": "==1.12.0"
|
||||
},
|
||||
"starlette": {
|
||||
"hashes": [
|
||||
"sha256:2c7ec085440fce7146a9be2b6d53b7110c3866ce6fa03d901efdc1fbe97e0f36"
|
||||
"sha256:8bc2e41f7638290379ae91450413796f92d6c97b88a6b754f3c1a7f8bc7a07d6"
|
||||
],
|
||||
"version": "==0.4.2"
|
||||
"version": "==0.10.7"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf",
|
||||
"sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5"
|
||||
"sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39",
|
||||
"sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22"
|
||||
],
|
||||
"version": "==1.23"
|
||||
"version": "==1.24.1"
|
||||
},
|
||||
"uvicorn": {
|
||||
"hashes": [
|
||||
"sha256:8de03999a936d8704f07cc3b1d3a3edb6922a068b64d84b4f5e49604c8b70a11"
|
||||
"sha256:f27889a332ee5c55b4841b11b2392d00dac079f39063fabc1e13e18ada3eb7ba"
|
||||
],
|
||||
"version": "==0.3.12"
|
||||
"version": "==0.4.5"
|
||||
},
|
||||
"uvloop": {
|
||||
"hashes": [
|
||||
"sha256:198fe0c196056930ec6c4a0a878e531a66d15467ca7c74a875aa90271f0c6e3f",
|
||||
"sha256:1c175f47d34b84e33c0e312f4987c927ea004afc3a5f05d2f0f610d71d0e4c89",
|
||||
"sha256:1c47f197be8f0a3c651dd20be1e1bd43268186246f246d4e86c91e95a89e4865",
|
||||
"sha256:3fd4943570d20e8cd4d9f0a3190ebd5cf040e5610b685e05c878128a11f7ad14",
|
||||
"sha256:435e232869923fd2248e4ca0ad73e24a5b4debf40bed9dcde133cfe1bef98a7a",
|
||||
"sha256:9cfdb966ae804c46b96c92207dfd2174935ffc70e706e42e1c94c60d16dbe860",
|
||||
"sha256:a585781443eeb2edb858f8c08c503aac237a5f1bebf0c84ea8340cc337afa408",
|
||||
"sha256:b296493e033846e46488a6aa227a75c790091f5ee5456ec637bb0badad1e8851",
|
||||
"sha256:c684047c6cf6d697ba37872fb1b4489012ea91f3f802c8fbb9c367c4902e88dc",
|
||||
"sha256:da5a59d8812188b57b5783c7fb78891d14dd1050b6259680e0dbd4253d7d0f64"
|
||||
],
|
||||
"version": "==0.12.1"
|
||||
},
|
||||
"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"
|
||||
"sha256:04b42a1b57096ffa5627d6a78ea1ff7fad3bc2c0331ffc17bc32a4024da7fea0",
|
||||
"sha256:08e3c3e0535befa4f0c4443824496c03ecc25062debbcf895874f8a0b4c97c9f",
|
||||
"sha256:10d89d4326045bf5e15e83e9867c85d686b612822e4d8f149cf4840aab5f46e0",
|
||||
"sha256:232fac8a1978fc1dead4b1c2fa27c7756750fb393eb4ac52f6bc87ba7242b2fa",
|
||||
"sha256:4bf4c8097440eff22bc78ec76fe2a865a6e658b6977a504679aaf08f02c121da",
|
||||
"sha256:51642ea3a00772d1e48fb0c492f0d3ae3b6474f34d20eca005a83f8c9c06c561",
|
||||
"sha256:55d86102282a636e195dad68aaaf85b81d0bef449d7e2ef2ff79ac450bb25d53",
|
||||
"sha256:564d2675682bd497b59907d2205031acbf7d3fadf8c763b689b9ede20300b215",
|
||||
"sha256:5d13bf5197a92149dc0badcc2b699267ff65a867029f465accfca8abab95f412",
|
||||
"sha256:5eda665f6789edb9b57b57a159b9c55482cbe5b046d7db458948370554b16439",
|
||||
"sha256:5edb2524d4032be4564c65dc4f9d01e79fe8fad5f966e5b552f4e5164fef0885",
|
||||
"sha256:79691794288bc51e2a3b8de2bc0272ca8355d0b8503077ea57c0716e840ebaef",
|
||||
"sha256:7fcc8681e9981b9b511cdee7c580d5b005f3bb86b65bde2188e04a29f1d63317",
|
||||
"sha256:8e447e05ec88b1b408a4c9cde85aa6f4b04f06aa874b9f0b8e8319faf51b1fee",
|
||||
"sha256:90ea6b3e7787620bb295a4ae050d2811c807d65b1486749414f78cfd6fb61489",
|
||||
"sha256:9e13239952694b8b831088431d15f771beace10edfcf9ef230cefea14f18508f",
|
||||
"sha256:d40f081187f7b54d7a99d8a5c782eaa4edc335a057aa54c85059272ed826dc09",
|
||||
"sha256:e1df1a58ed2468c7b7ce9a2f9752a32ad08eac2bcd56318625c3647c2cd2da6f",
|
||||
"sha256:e98d0cec437097f09c7834a11c69d79fe6241729b23f656cfc227e93294fc242",
|
||||
"sha256:f8d59627702d2ff27cb495ca1abdea8bd8d581de425c56e93bff6517134e0a9b",
|
||||
"sha256:fc30cdf2e949a2225b012a7911d1d031df3d23e99b7eda7dfc982dc4a860dae9"
|
||||
],
|
||||
"version": "==6.0"
|
||||
"version": "==7.0"
|
||||
},
|
||||
"whitenoise": {
|
||||
"hashes": [
|
||||
"sha256:118ab3e5f815d380171b100b05b76de2a07612f422368a201a9ffdeefb2251c1",
|
||||
"sha256:42133ddd5229eeb6a0c9899496bdbe56c292394bf8666da77deeb27454c0456a"
|
||||
],
|
||||
"version": "==4.1.2"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
@@ -256,10 +337,10 @@
|
||||
},
|
||||
"atomicwrites": {
|
||||
"hashes": [
|
||||
"sha256:0312ad34fcad8fac3704d441f7b317e50af620823353ec657a53e981f92920c0",
|
||||
"sha256:ec9ae8adaae229e4f8446952d204a3e4b5fdd2d099f9be3aaf556120135fb3ee"
|
||||
"sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4",
|
||||
"sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"
|
||||
],
|
||||
"version": "==1.2.1"
|
||||
"version": "==1.3.0"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
@@ -285,54 +366,17 @@
|
||||
},
|
||||
"bleach": {
|
||||
"hashes": [
|
||||
"sha256:48d39675b80a75f6d1c3bdbffec791cf0bbbab665cf01e20da701c77de278718",
|
||||
"sha256:73d26f018af5d5adcdabf5c1c974add4361a9c76af215fe32fdec8a6fc5fb9b9"
|
||||
"sha256:213336e49e102af26d9cde77dd2d0397afabc5a6bf2fed985dc35b5d1e285a16",
|
||||
"sha256:3fdf7f77adcf649c9911387df51254b813185e32b2c6619f690b593a617e19fa"
|
||||
],
|
||||
"version": "==3.0.2"
|
||||
"version": "==3.1.0"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:339dc09518b07e2fa7eda5450740925974815557727d6bd35d319c1524a04a4c",
|
||||
"sha256:6d58c986d22b038c8c0df30d639f23a3e6d172a05c3583e766f4c0b785c0986a"
|
||||
"sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7",
|
||||
"sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033"
|
||||
],
|
||||
"version": "==2018.10.15"
|
||||
},
|
||||
"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"
|
||||
"version": "==2018.11.29"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
@@ -348,46 +392,39 @@
|
||||
],
|
||||
"version": "==7.0"
|
||||
},
|
||||
"cmarkgfm": {
|
||||
"coverage": {
|
||||
"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"
|
||||
"sha256:029c69deaeeeae1b15bc6c59f0ffa28aa8473721c614a23f2c2976dec245cd12",
|
||||
"sha256:02abbbebc6e9d5abe13cd28b5e963dedb6ffb51c146c916d17b18f141acd9947",
|
||||
"sha256:1bbfe5b82a3921d285e999c6d256c1e16b31c554c29da62d326f86c173d30337",
|
||||
"sha256:210c02f923df33a8d0e461c86fdcbbb17228ff4f6d92609fc06370a98d283c2d",
|
||||
"sha256:2d0807ba935f540d20b49d5bf1c0237b90ce81e133402feda906e540003f2f7a",
|
||||
"sha256:35d7a013874a7c927ce997350d314144ffc5465faf787bb4e46e6c4f381ef562",
|
||||
"sha256:3636f9d0dcb01aed4180ef2e57a4e34bb4cac3ecd203c2a23db8526d86ab2fb4",
|
||||
"sha256:42f4be770af2455a75e4640f033a82c62f3fb0d7a074123266e143269d7010ef",
|
||||
"sha256:48440b25ba6cda72d4c638f3a9efa827b5b87b489c96ab5f4ff597d976413156",
|
||||
"sha256:4dac8dfd1acf6a3ac657475dfdc66c621f291b1b7422a939cc33c13ac5356473",
|
||||
"sha256:4e8474771c69c2991d5eab65764289a7dd450bbea050bc0ebb42b678d8222b42",
|
||||
"sha256:551f10ddfeff56a1325e5a34eff304c5892aa981fd810babb98bfee77ee2fb17",
|
||||
"sha256:5b104982f1809c1577912519eb249f17d9d7e66304ad026666cb60a5ef73309c",
|
||||
"sha256:5c62aef73dfc87bfcca32cee149a1a7a602bc74bac72223236b0023543511c88",
|
||||
"sha256:633151f8d1ad9467b9f7e90854a7f46ed8f2919e8bc7d98d737833e8938fc081",
|
||||
"sha256:772207b9e2d5bf3f9d283b88915723e4e92d9a62c83f44ec92b9bd0cd685541b",
|
||||
"sha256:7d5e02f647cd727afc2659ec14d4d1cc0508c47e6cfb07aea33d7aa9ca94d288",
|
||||
"sha256:a9798a4111abb0f94584000ba2a2c74841f2cfe5f9254709756367aabbae0541",
|
||||
"sha256:b38ea741ab9e35bfa7015c93c93bbd6a1623428f97a67083fc8ebd366238b91f",
|
||||
"sha256:b6a5478c904236543c0347db8a05fac6fc0bd574c870e7970faa88e1d9890044",
|
||||
"sha256:c6248bfc1de36a3844685a2e10ba17c18119ba6252547f921062a323fb31bff1",
|
||||
"sha256:c705ab445936457359b1424ef25ccc0098b0491b26064677c39f1d14a539f056",
|
||||
"sha256:d95a363d663ceee647291131dbd213af258df24f41350246842481ec3709bd33",
|
||||
"sha256:e27265eb80cdc5dab55a40ef6f890e04ecc618649ad3da5265f128b141f93f78",
|
||||
"sha256:ebc276c9cb5d917bd2ae959f84ffc279acafa9c9b50b0fa436ebb70bbe2166ea",
|
||||
"sha256:f4d229866d030863d0fe3bf297d6d11e6133ca15bbb41ed2534a8b9a3d6bd061",
|
||||
"sha256:f95675bd88b51474d4fe5165f3266f419ce754ffadfb97f10323931fa9ac95e5",
|
||||
"sha256:f95bc54fb6d61b9f9ff09c4ae8ff6a3f5edc937cda3ca36fc937302a7c152bf1",
|
||||
"sha256:fd0f6be53de40683584e5331c341e65a679dbe5ec489a0697cec7c2ef1a48cda"
|
||||
],
|
||||
"version": "==0.4.2"
|
||||
},
|
||||
"colorama": {
|
||||
"hashes": [
|
||||
"sha256:a3d89af5db9e9806a779a50296b5fdb466e281147c2c235e8225ecc6dbf7bbf3",
|
||||
"sha256:c9b54bebe91a6a803e0772c8561d53f2926bfeb17cd141fbabcb08424086595c"
|
||||
],
|
||||
"markers": "sys_platform == 'win32'",
|
||||
"version": "==0.4.0"
|
||||
"version": "==5.0a4"
|
||||
},
|
||||
"docutils": {
|
||||
"hashes": [
|
||||
@@ -397,13 +434,20 @@
|
||||
],
|
||||
"version": "==0.14"
|
||||
},
|
||||
"entrypoints": {
|
||||
"hashes": [
|
||||
"sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19",
|
||||
"sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"
|
||||
],
|
||||
"version": "==0.3"
|
||||
},
|
||||
"flake8": {
|
||||
"hashes": [
|
||||
"sha256:7253265f7abd8b313e3892944044a365e3f4ac3fcdcfb4298f55ee9ddf188ba0",
|
||||
"sha256:c7841163e2b576d435799169b78703ad6ac1bbb0f199994fc05f700b2a90ea37"
|
||||
"sha256:6d8c66a65635d46d54de59b027a1dda40abbe2275b3164b634835ac9c13fd048",
|
||||
"sha256:6eab21c6e34df2c05416faa40d0c59963008fff29b6f0ccfe8fa28152ab3e383"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.5.0"
|
||||
"version": "==3.7.6"
|
||||
},
|
||||
"flask": {
|
||||
"hashes": [
|
||||
@@ -413,18 +457,12 @@
|
||||
"index": "pypi",
|
||||
"version": "==1.0.2"
|
||||
},
|
||||
"future": {
|
||||
"hashes": [
|
||||
"sha256:e39ced1ab767b5936646cedba8bcce582398233d6a627067d4c6a454c90cfedb"
|
||||
],
|
||||
"version": "==0.16.0"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e",
|
||||
"sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"
|
||||
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
|
||||
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
|
||||
],
|
||||
"version": "==2.7"
|
||||
"version": "==2.8"
|
||||
},
|
||||
"imagesize": {
|
||||
"hashes": [
|
||||
@@ -435,9 +473,10 @@
|
||||
},
|
||||
"itsdangerous": {
|
||||
"hashes": [
|
||||
"sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519"
|
||||
"sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19",
|
||||
"sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"
|
||||
],
|
||||
"version": "==0.24"
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
@@ -448,16 +487,43 @@
|
||||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
"sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665"
|
||||
"sha256:048ef924c1623740e70204aa7143ec592504045ae4429b59c30054cb31e3c432",
|
||||
"sha256:130f844e7f5bdd8e9f3f42e7102ef1d49b2e6fdf0d7526df3f87281a532d8c8b",
|
||||
"sha256:19f637c2ac5ae9da8bfd98cef74d64b7e1bb8a63038a3505cd182c3fac5eb4d9",
|
||||
"sha256:1b8a7a87ad1b92bd887568ce54b23565f3fd7018c4180136e1cf412b405a47af",
|
||||
"sha256:1c25694ca680b6919de53a4bb3bdd0602beafc63ff001fea2f2fc16ec3a11834",
|
||||
"sha256:1f19ef5d3908110e1e891deefb5586aae1b49a7440db952454b4e281b41620cd",
|
||||
"sha256:1fa6058938190ebe8290e5cae6c351e14e7bb44505c4a7624555ce57fbbeba0d",
|
||||
"sha256:31cbb1359e8c25f9f48e156e59e2eaad51cd5242c05ed18a8de6dbe85184e4b7",
|
||||
"sha256:3e835d8841ae7863f64e40e19477f7eb398674da6a47f09871673742531e6f4b",
|
||||
"sha256:4e97332c9ce444b0c2c38dd22ddc61c743eb208d916e4265a2a3b575bdccb1d3",
|
||||
"sha256:525396ee324ee2da82919f2ee9c9e73b012f23e7640131dd1b53a90206a0f09c",
|
||||
"sha256:52b07fbc32032c21ad4ab060fec137b76eb804c4b9a1c7c7dc562549306afad2",
|
||||
"sha256:52ccb45e77a1085ec5461cde794e1aa037df79f473cbc69b974e73940655c8d7",
|
||||
"sha256:5c3fbebd7de20ce93103cb3183b47671f2885307df4a17a0ad56a1dd51273d36",
|
||||
"sha256:5e5851969aea17660e55f6a3be00037a25b96a9b44d2083651812c99d53b14d1",
|
||||
"sha256:5edfa27b2d3eefa2210fb2f5d539fbed81722b49f083b2c6566455eb7422fd7e",
|
||||
"sha256:7d263e5770efddf465a9e31b78362d84d015cc894ca2c131901a4445eaa61ee1",
|
||||
"sha256:83381342bfc22b3c8c06f2dd93a505413888694302de25add756254beee8449c",
|
||||
"sha256:857eebb2c1dc60e4219ec8e98dfa19553dae33608237e107db9c6078b1167856",
|
||||
"sha256:98e439297f78fca3a6169fd330fbe88d78b3bb72f967ad9961bcac0d7fdd1550",
|
||||
"sha256:bf54103892a83c64db58125b3f2a43df6d2cb2d28889f14c78519394feb41492",
|
||||
"sha256:d9ac82be533394d341b41d78aca7ed0e0f4ba5a2231602e2f05aa87f25c51672",
|
||||
"sha256:e982fe07ede9fada6ff6705af70514a52beb1b2c3d25d4e873e82114cf3c5401",
|
||||
"sha256:edce2ea7f3dfc981c4ddc97add8a61381d9642dc3273737e756517cc03e84dd6",
|
||||
"sha256:efdc45ef1afc238db84cb4963aa689c0408912a0239b0721cb172b4016eb31d6",
|
||||
"sha256:f137c02498f8b935892d5c0172560d7ab54bc45039de8805075e19079c639a9c",
|
||||
"sha256:f82e347a72f955b7017a39708a3667f106e6ad4d10b25f237396a7115d8ed5fd",
|
||||
"sha256:fb7c206e01ad85ce57feeaaa0bf784b97fa3cad0d4a5737bc5295785f5c613a1"
|
||||
],
|
||||
"version": "==1.0"
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"marshmallow": {
|
||||
"hashes": [
|
||||
"sha256:82b201ad767eb54de371c08cb1db6ca4ad2a728fa41b831e3781bf944815eb38",
|
||||
"sha256:c250f37ac0e249a8287394a60d91f6240b674642ad999e66cd09463dbccd1d4f"
|
||||
"sha256:7f9aba737a59dd3c6c6c79846f1df2fbfe036c17f038bbc2c83911b7304a90e1",
|
||||
"sha256:b41cc52fe0491bdb8aa3e2186ca57d478d9ef69dba87fe37d309aa8a08fd30dd"
|
||||
],
|
||||
"version": "==3.0.0b18"
|
||||
"version": "==3.0.0rc4"
|
||||
},
|
||||
"mccabe": {
|
||||
"hashes": [
|
||||
@@ -468,32 +534,32 @@
|
||||
},
|
||||
"more-itertools": {
|
||||
"hashes": [
|
||||
"sha256:c187a73da93e7a8acc0001572aebc7e3c69daf7bf6881a2cea10650bd4420092",
|
||||
"sha256:c476b5d3a34e12d40130bc2f935028b5f636df8f372dc2c1c01dc19681b2039e",
|
||||
"sha256:fcbfeaea0be121980e15bc97b3817b5202ca73d0eae185b4550cbfce2a3ebb3d"
|
||||
"sha256:0125e8f60e9e031347105eb1682cef932f5e97d7b9a1a28d9bf00c22a5daef40",
|
||||
"sha256:590044e3942351a1bdb1de960b739ff4ce277960f2425ad4509446dbace8d9d1"
|
||||
],
|
||||
"version": "==4.3.0"
|
||||
"markers": "python_version > '2.7'",
|
||||
"version": "==6.0.0"
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:0886227f54515e592aaa2e5a553332c73962917f2831f1b0f9b9f4380a4b9807",
|
||||
"sha256:f95a1e147590f204328170981833854229bb2912ac3d5f89e2a8ccd2834800c9"
|
||||
"sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af",
|
||||
"sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3"
|
||||
],
|
||||
"version": "==18.0"
|
||||
"version": "==19.0"
|
||||
},
|
||||
"pkginfo": {
|
||||
"hashes": [
|
||||
"sha256:5878d542a4b3f237e359926384f1dde4e099c9f5525d236b1840cf704fa8d474",
|
||||
"sha256:a39076cb3eb34c333a0dd390b568e9e1e881c7bf2cc0aee12120636816f55aee"
|
||||
"sha256:7424f2c8511c186cd5424bbf31045b77435b37a8d604990b79d4e70d741148bb",
|
||||
"sha256:a6d9e40ca61ad3ebd0b72fbadd4fba16e4c0e4df0428c041e01e06eb6ee71f32"
|
||||
],
|
||||
"version": "==1.4.2"
|
||||
"version": "==1.5.0.1"
|
||||
},
|
||||
"pluggy": {
|
||||
"hashes": [
|
||||
"sha256:6e3836e39f4d36ae72840833db137f7b7d35105079aee6ec4a62d9f80d594dd1",
|
||||
"sha256:95eb8364a4708392bae89035f45341871286a333f749c3141c20573d2b3876e1"
|
||||
"sha256:8ddc32f03971bfdf900a81961a48ccf2fb677cf7715108f85295c67405798616",
|
||||
"sha256:980710797ff6a041e9a73a5787804f848996ecaa6f8a1b1e08224a5894f2074a"
|
||||
],
|
||||
"version": "==0.7.1"
|
||||
"version": "==0.8.1"
|
||||
},
|
||||
"py": {
|
||||
"hashes": [
|
||||
@@ -504,80 +570,82 @@
|
||||
},
|
||||
"pycodestyle": {
|
||||
"hashes": [
|
||||
"sha256:682256a5b318149ca0d2a9185d365d8864a768a28db66a84a2ea946bcc426766",
|
||||
"sha256:6c4245ade1edfad79c3446fadfc96b0de2759662dc29d07d80a6f27ad1ca6ba9"
|
||||
"sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56",
|
||||
"sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"
|
||||
],
|
||||
"version": "==2.3.1"
|
||||
},
|
||||
"pycparser": {
|
||||
"hashes": [
|
||||
"sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3"
|
||||
],
|
||||
"version": "==2.19"
|
||||
"version": "==2.5.0"
|
||||
},
|
||||
"pyflakes": {
|
||||
"hashes": [
|
||||
"sha256:08bd6a50edf8cffa9fa09a463063c425ecaaf10d1eb0335a7e8b1401aef89e6f",
|
||||
"sha256:8d616a382f243dbf19b54743f280b80198be0bca3a5396f1d2e1fca6223e8805"
|
||||
"sha256:5e8c00e30c464c99e0b501dc160b13a14af7f27d4dffb529c556e30a159e231d",
|
||||
"sha256:f277f9ca3e55de669fba45b7393a1449009cff5a37d1af10ebb76c52765269cd"
|
||||
],
|
||||
"version": "==1.6.0"
|
||||
"version": "==2.1.0"
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d",
|
||||
"sha256:dbae1046def0efb574852fab9e90209b23f556367b5a320c0bcb871c77c3e8cc"
|
||||
"sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a",
|
||||
"sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d"
|
||||
],
|
||||
"version": "==2.2.0"
|
||||
"version": "==2.3.1"
|
||||
},
|
||||
"pyparsing": {
|
||||
"hashes": [
|
||||
"sha256:bc6c7146b91af3f567cf6daeaec360bc07d45ffec4cf5353f4d7a208ce7ca30a",
|
||||
"sha256:d29593d8ebe7b57d6967b62494f8c72b03ac0262b1eed63826c6f788b3606401"
|
||||
"sha256:66c9268862641abcac4a96ba74506e594c884e3f57690a696d21ad8210ed667a",
|
||||
"sha256:f6c5ef0d7480ad048c054c37632c67fca55299990fff127850181659eea33fc3"
|
||||
],
|
||||
"version": "==2.2.2"
|
||||
"version": "==2.3.1"
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:7e258ee50338f4e46957f9e09a0f10fb1c2d05493fa901d113a8dafd0790de4e",
|
||||
"sha256:9332147e9af2dcf46cd7ceb14d5acadb6564744ddff1fe8c17f0ce60ece7d9a2"
|
||||
"sha256:067a1d4bf827ffdd56ad21bd46674703fce77c5957f6c1eef731f6146bfcef1c",
|
||||
"sha256:9687049d53695ad45cf5fdc7bbd51f0c49f1ea3ecfc4b7f3fde7501b541f17f4"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.8.2"
|
||||
"version": "==4.3.0"
|
||||
},
|
||||
"pytest-cov": {
|
||||
"hashes": [
|
||||
"sha256:0ab664b25c6aa9716cbf203b17ddb301932383046082c081b9848a0edf5add33",
|
||||
"sha256:230ef817450ab0699c6cc3c9c8f7a829c34674456f2ed8df1fe1d39780f7c87f"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.6.1"
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:a061aa0a9e06881eb8b3b2b43f05b9439d6583c206d0a6c340ff72a7b6669053",
|
||||
"sha256:ffb9ef1de172603304d9d2819af6f5ece76f2e85ec10692a524dd876e72bf277"
|
||||
"sha256:32b0891edff07e28efe91284ed9c31e123d84bea3fd98e1f72be2508f43ef8d9",
|
||||
"sha256:d5f05e487007e29e03409f9398d074e158d920d36eb82eaf66fb1136b0c5374c"
|
||||
],
|
||||
"version": "==2018.5"
|
||||
"version": "==2018.9"
|
||||
},
|
||||
"readme-renderer": {
|
||||
"hashes": [
|
||||
"sha256:237ca8705ffea849870de41101dba41543561da05c0ae45b2f1c547efa9843d2",
|
||||
"sha256:f75049a3a7afa57165551e030dd8f9882ebf688b9600535a3f7e23596651875d"
|
||||
"sha256:bb16f55b259f27f75f640acf5e00cf897845a8b3e4731b5c1a436e4b8529202f",
|
||||
"sha256:c8532b79afc0375a85f10433eca157d6b50f7d6990f337fa498c96cd4bfc203d"
|
||||
],
|
||||
"version": "==22.0"
|
||||
"version": "==24.0"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1",
|
||||
"sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a"
|
||||
"sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e",
|
||||
"sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b"
|
||||
],
|
||||
"version": "==2.19.1"
|
||||
"version": "==2.21.0"
|
||||
},
|
||||
"requests-toolbelt": {
|
||||
"hashes": [
|
||||
"sha256:42c9c170abc2cacb78b8ab23ac957945c7716249206f90874651971a4acff237",
|
||||
"sha256:f6a531936c6fa4c6cfce1b9c10d5c4f498d16528d2a54a22ca00011205a187b5"
|
||||
"sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f",
|
||||
"sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"
|
||||
],
|
||||
"version": "==0.8.0"
|
||||
"version": "==0.9.1"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
|
||||
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
|
||||
"sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
|
||||
"sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
|
||||
],
|
||||
"version": "==1.11.0"
|
||||
"version": "==1.12.0"
|
||||
},
|
||||
"snowballstemmer": {
|
||||
"hashes": [
|
||||
@@ -588,18 +656,53 @@
|
||||
},
|
||||
"sphinx": {
|
||||
"hashes": [
|
||||
"sha256:652eb8c566f18823a022bb4b6dbc868d366df332a11a0226b5bc3a798a479f17",
|
||||
"sha256:d222626d8356de702431e813a05c68a35967e3d66c6cd1c2c89539bb179a7464"
|
||||
"sha256:230af939a2f678ab4f2a0a948c3b24a822a0d280821859caaefb750ef7413003",
|
||||
"sha256:835c701420102a0a71ba2ed54a5bada2da6fd01263bf6dc8c5c80c798e27709c"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.8.1"
|
||||
"version": "==2.0.0b1"
|
||||
},
|
||||
"sphinxcontrib-websupport": {
|
||||
"sphinxcontrib-applehelp": {
|
||||
"hashes": [
|
||||
"sha256:68ca7ff70785cbe1e7bccc71a48b5b6d965d79ca50629606c7861a21b206d9dd",
|
||||
"sha256:9de47f375baf1ea07cdb3436ff39d7a9c76042c10a769c52353ec46e4e8fc3b9"
|
||||
"sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897",
|
||||
"sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d"
|
||||
],
|
||||
"version": "==1.1.0"
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"sphinxcontrib-devhelp": {
|
||||
"hashes": [
|
||||
"sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34",
|
||||
"sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981"
|
||||
],
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"sphinxcontrib-htmlhelp": {
|
||||
"hashes": [
|
||||
"sha256:0d691ca8edf5995fbacfe69b191914256071a94cbad03c3688dca47385c9206c",
|
||||
"sha256:e31c8271f5a8f04b620a500c0442a7d5cfc1a732fa5c10ec363f90fe72af0cb8"
|
||||
],
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"sphinxcontrib-jsmath": {
|
||||
"hashes": [
|
||||
"sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178",
|
||||
"sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"
|
||||
],
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"sphinxcontrib-qthelp": {
|
||||
"hashes": [
|
||||
"sha256:18ec9f74ea2c92fd512d5f3b532d6ab4ac2be76b12cc2490f7729842ba2a60c9",
|
||||
"sha256:f39159b45de6d3d86c30874a3220be4f8e75ed12c71aff50cb8b2cac46e240f0"
|
||||
],
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"sphinxcontrib-serializinghtml": {
|
||||
"hashes": [
|
||||
"sha256:01d9b2617d7e8ddf7a00cae091f08f9fa4db587cc160b493141ee56710810932",
|
||||
"sha256:392187ac558863b8aff0d76dc78e0731fed58f3b06e2b00e22995dcdb630f213"
|
||||
],
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"toml": {
|
||||
"hashes": [
|
||||
@@ -610,25 +713,25 @@
|
||||
},
|
||||
"tqdm": {
|
||||
"hashes": [
|
||||
"sha256:a0be569511161220ff709a5b60d0890d47921f746f1c737a11d965e1b29e7b2e",
|
||||
"sha256:e293e6d7a7f41a529a27f8d6624ab11544ccbfe82a205af6fad102545099fc21"
|
||||
"sha256:d385c95361699e5cf7622485d9b9eae2d4864b21cd5a2374a9c381ffed701021",
|
||||
"sha256:e22977e3ebe961f72362f6ddfb9197cc531c9737aaf5f607ef09740c849ecd05"
|
||||
],
|
||||
"version": "==4.27.0"
|
||||
"version": "==4.31.1"
|
||||
},
|
||||
"twine": {
|
||||
"hashes": [
|
||||
"sha256:7d89bc6acafb31d124e6e5b295ef26ac77030bf098960c2a4c4e058335827c5c",
|
||||
"sha256:fad6f1251195f7ddd1460cb76d6ea106c93adb4e56c41e0da79658e56e547d2c"
|
||||
"sha256:0fb0bfa3df4f62076cab5def36b1a71a2e4acb4d1fa5c97475b048117b1a6446",
|
||||
"sha256:d6c29c933ecfc74e9b1d9fa13aa1f87c5d5770e119f5a4ce032092f0ff5b14dc"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.12.1"
|
||||
"version": "==1.13.0"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf",
|
||||
"sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5"
|
||||
"sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39",
|
||||
"sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22"
|
||||
],
|
||||
"version": "==1.23"
|
||||
"version": "==1.24.1"
|
||||
},
|
||||
"webencodings": {
|
||||
"hashes": [
|
||||
|
||||
@@ -7,24 +7,10 @@
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://github.com/kennethreitz/responder/graphs/contributors)
|
||||
|
||||
[](http://python-responder.org/)
|
||||
[](https://python-responder.org/)
|
||||
|
||||
The Python world certainly doesn't need more web frameworks. But, it does need more creativity, so I thought I'd spread some [Hacktoberfest](https://hacktoberfest.digitalocean.com/) spirit around, bring some of my ideas to the table, and see what I could come up with.
|
||||
|
||||
```python
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
@api.route("/{greeting}")
|
||||
async def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
|
||||
if __name__ == '__main__':
|
||||
api.run()
|
||||
```
|
||||
|
||||
That `async` declaration is optional. [View documentation](http://python-responder.org).
|
||||
Powered by [Starlette](https://www.starlette.io/). That `async` declaration is optional. [View documentation](https://python-responder.org).
|
||||
|
||||
This gets you a ASGI app, with a production static files server pre-installed, jinja2 templating (without additional imports), and a production webserver based on uvloop, serving up requests with gzip compression automatically.
|
||||
|
||||
@@ -37,85 +23,10 @@ This gets you a ASGI app, with a production static files server pre-installed, j
|
||||
|
||||
> "I love that you are exploring new patterns. Go go go!" — Danny Greenfield, author of [Two Scoops of Django]()
|
||||
|
||||
> "Love what I have seen while it's in progress! Many features of Responder are from my wishlist for Flask, and it's even faster and even easier than Flask!" — Luna C.
|
||||
|
||||
## More Examples
|
||||
|
||||
Class-based views (and setting some headers and stuff):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Render a template, with arguments:
|
||||
|
||||
```python
|
||||
@api.route("/{greeting}")
|
||||
def greet_world(req, resp, *, greeting):
|
||||
resp.content = api.template("index.html", greeting=greeting)
|
||||
```
|
||||
|
||||
The `api` instance is available as an object during template rendering.
|
||||
|
||||
Here, you can spawn off a background thread to run any function, out-of-request:
|
||||
|
||||
```python
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
|
||||
@api.background.task
|
||||
def sleep(s=10):
|
||||
time.sleep(s)
|
||||
print("slept!")
|
||||
|
||||
sleep()
|
||||
resp.content = "processing"
|
||||
```
|
||||
|
||||
And even serve a GraphQL API:
|
||||
|
||||
```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.
|
||||
See [the documentation's feature tour](https://python-responder.org/en/latest/tour.html) for more details on features available in Responder.
|
||||
|
||||
|
||||
# Installing Responder
|
||||
@@ -123,7 +34,7 @@ Boom.
|
||||
Install the latest release:
|
||||
|
||||
|
||||
$ pipenv install responder
|
||||
$ pipenv install responder --pre
|
||||
✨🍰✨
|
||||
|
||||
|
||||
@@ -138,11 +49,13 @@ Only **Python 3.6+** is supported.
|
||||
|
||||
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).
|
||||
- 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.
|
||||
|
||||
|
||||
## Ideas
|
||||
|
||||
- Flask-style route expression, with new capabilities -- all while using Python 3.6+'s new f-string syntax.
|
||||
@@ -154,15 +67,7 @@ The primary concept here is to bring the niceties that are brought forth from bo
|
||||
- A production static file server is built-in.
|
||||
- Uvicorn 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.
|
||||
|
||||
## Future Ideas
|
||||
|
||||
- Cookie-based sessions are currently an afterthought, as this is an API framework, but websites are APIs too.
|
||||
- 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.
|
||||
- Provide an official way to run webpack.
|
||||
|
||||
|
||||
----------
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
Deploying Responder
|
||||
===================
|
||||
|
||||
You can deploy Responder anywhere you can deploy a basic Python application.
|
||||
|
||||
Docker Deployment
|
||||
-----------------
|
||||
|
||||
Assuming existing ``api.py`` and ``Pipfile.lock`` containing ``responder``.
|
||||
|
||||
``Dockerfile``::
|
||||
|
||||
FROM kennethreitz/pipenv
|
||||
ENV PORT '80'
|
||||
COPY . /app
|
||||
CMD python3 api.py
|
||||
EXPOSE 80
|
||||
|
||||
That's it!
|
||||
|
||||
Heroku Deployment
|
||||
-----------------
|
||||
|
||||
The basics::
|
||||
|
||||
$ mkdir my-api
|
||||
$ cd my-api
|
||||
$ git init
|
||||
$ heroku create
|
||||
...
|
||||
|
||||
Install Responder::
|
||||
|
||||
$ pipenv install responder --pre
|
||||
...
|
||||
|
||||
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()
|
||||
|
||||
Write out a ``Procfile``::
|
||||
|
||||
web: python api.py
|
||||
|
||||
That's it! Next, we commit and push to Heroku::
|
||||
|
||||
$ git add -A
|
||||
$ git commit -m 'initial commit'
|
||||
$ git push heroku master
|
||||
+15
-30
@@ -21,9 +21,6 @@ A familiar HTTP Service Framework
|
||||
.. |image5| image:: https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg
|
||||
:target: https://saythanks.io/to/kennethreitz
|
||||
|
||||
The Python world certainly doesn't need more web frameworks. But, it does need more creativity, so I thought I'd
|
||||
spread some `Hacktoberfest <https://hacktoberfest.digitalocean.com/>`_ spirit around, bring some of my ideas to the table, and see what I could come up with.
|
||||
|
||||
.. code:: python
|
||||
|
||||
import responder
|
||||
@@ -37,9 +34,9 @@ spread some `Hacktoberfest <https://hacktoberfest.digitalocean.com/>`_ spirit ar
|
||||
if __name__ == '__main__':
|
||||
api.run()
|
||||
|
||||
That ``async`` declaration is optional.
|
||||
Powered by `Starlette <https://www.starlette.io/>`_. That ``async`` declaration is optional.
|
||||
|
||||
This gets you a ASGI app, with a production static files server
|
||||
This gets you a ASGI app, with a production static files server (WhiteNoise)
|
||||
pre-installed, jinja2 templating (without additional imports), and a
|
||||
production webserver based on uvloop, serving up requests with gzip
|
||||
compression automatically.
|
||||
@@ -48,13 +45,16 @@ Features
|
||||
--------
|
||||
|
||||
- A pleasant API, with a single import statement.
|
||||
- Class-based views without inheritence.
|
||||
- Class-based views without inheritance.
|
||||
- ASGI framework, the future of Python web services.
|
||||
- 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
|
||||
------------
|
||||
@@ -81,18 +81,8 @@ Testimonials
|
||||
— Danny Greenfield, author of `Two Scoops of Django`_
|
||||
|
||||
|
||||
..
|
||||
|
||||
|
||||
“The most ambitious crossover event in history.”
|
||||
|
||||
—Pablo Cabezas, `on Tom Christie joining the project`_
|
||||
|
||||
|
||||
.. _APIStar: https://github.com/encode/apistar
|
||||
.. _Django REST Framework: https://www.django-rest-framework.org/
|
||||
.. _Two Scoops of Django:
|
||||
.. _on Tom Christie joining the project: https://twitter.com/pabloteleco/status/1050841098321620992?s=20
|
||||
.. _Two Scoops of Django: https://www.twoscoopspress.com/products/two-scoops-of-django-1-11
|
||||
|
||||
User Guides
|
||||
-----------
|
||||
@@ -102,6 +92,8 @@ User Guides
|
||||
|
||||
quickstart
|
||||
tour
|
||||
deployment
|
||||
testing
|
||||
api
|
||||
|
||||
|
||||
@@ -110,7 +102,7 @@ Installing Responder
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
$ pipenv install responder
|
||||
$ pipenv install responder --pre
|
||||
✨🍰✨
|
||||
|
||||
Only **Python 3.6+** is supported.
|
||||
@@ -121,8 +113,9 @@ The Basic Idea
|
||||
|
||||
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).
|
||||
- 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.
|
||||
|
||||
@@ -130,24 +123,16 @@ Ideas
|
||||
-----
|
||||
|
||||
- Flask-style route expression, with new capabilities -- 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.
|
||||
- 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 that uses the actual Requests you know and love**.
|
||||
- The ability to mount other WSGI apps easily.
|
||||
- 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 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 unneccessary in production.
|
||||
- Uvicorn 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.
|
||||
|
||||
|
||||
Future Ideas
|
||||
------------
|
||||
|
||||
- Cookie-based sessions are currently an afterthought, as this is an API framework, but websites are APIs too.
|
||||
- If frontend websites are supported, provide an official way to run webpack.
|
||||
|
||||
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Next, we can run our web service easily, with ``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.
|
||||
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
|
||||
@@ -69,7 +69,7 @@ If you want to render a template, simply use ``api.template``. No need for addit
|
||||
|
||||
@api.route("/hello/{who}/html")
|
||||
def hello_html(req, resp, *, who):
|
||||
resp.content = api.template('hello.html', who=who)
|
||||
resp.html = api.template('hello.html', who=who)
|
||||
|
||||
The ``api`` instance is available as an object during template rendering.
|
||||
|
||||
@@ -90,7 +90,7 @@ If you want to set a response header, like ``X-Pizza: 42``, simply modify the ``
|
||||
|
||||
@api.route("/pizza")
|
||||
def pizza_pizza(req, resp):
|
||||
resp.headers['X-Pizza'] = 42
|
||||
resp.headers['X-Pizza'] = '42'
|
||||
|
||||
That's it!
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
Building and Testing with Responder
|
||||
===================================
|
||||
|
||||
Responder comes with a first-class, well supported test client for your ASGI web services: **Requests**.
|
||||
|
||||
Here, we'll go over the basics of setting up a proper Python package and adding testing to it.
|
||||
|
||||
The Basics
|
||||
----------
|
||||
|
||||
Your repository should look like this::
|
||||
|
||||
Pipfile Pipfile.lock 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()
|
||||
|
||||
|
||||
``$ cat Pipfile``::
|
||||
|
||||
[[source]]
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
responder = "*"
|
||||
|
||||
[dev-packages]
|
||||
pytest = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.7"
|
||||
|
||||
[pipenv]
|
||||
allow_prereleases = true
|
||||
|
||||
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 ==========================
|
||||
|
||||
|
||||
(Optional) Proper Python Package
|
||||
--------------------------------
|
||||
|
||||
Optionally, you can not rely on relative imports, and instead install your api as a proper package. This requires:
|
||||
|
||||
1. A `proper setup.py <https://github.com/kennethreitz/setup.py>`_ file.
|
||||
2. ``$ pipenv install -e . --dev``
|
||||
|
||||
This will allow you to only specify your dependencies once: in ``setup.py``. ``$ pipenv lock`` will automatically lock your transitive dependencies (e.g. Responder), even if it's not specified in the ``Pipfile``.
|
||||
|
||||
This will ensure that your application gets installed in every developer's environment, using Pipenv.
|
||||
+198
-21
@@ -9,7 +9,7 @@ Class-based views (and setting some headers and stuff)::
|
||||
|
||||
@api.route("/{greeting}")
|
||||
class GreetingResource:
|
||||
def on_request(req, resp, *, greeting): # or on_get...
|
||||
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
|
||||
@@ -43,29 +43,17 @@ Serve a GraphQL API::
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
|
||||
def resolve_hello(self, info, name):
|
||||
return "Hello " + name
|
||||
return f"Hello {name}"
|
||||
|
||||
api.add_route("/graph", graphene.Schema(query=Query))
|
||||
schema = graphene.Schema(query=Query)
|
||||
view = responder.ext.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']``.
|
||||
|
||||
Built-in Testing Client (Requests)
|
||||
----------------------------------
|
||||
|
||||
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}
|
||||
|
||||
OpenAPI Schema Support
|
||||
----------------------
|
||||
@@ -75,7 +63,28 @@ Responder comes with built-in support for OpenAPI / marshmallow::
|
||||
import responder
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
api = responder.API(title="Web Service", version="1.0", openapi="3.0")
|
||||
description = "This is a sample server for a pet store."
|
||||
terms_of_service = "http://example.com/terms/"
|
||||
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",
|
||||
docs_route='/docs',
|
||||
description=description,
|
||||
terms_of_service=terms_of_service,
|
||||
contact=contact,
|
||||
license=license,
|
||||
)
|
||||
|
||||
|
||||
@api.schema("Pet")
|
||||
@@ -121,6 +130,15 @@ Responder comes with built-in support for OpenAPI / marshmallow::
|
||||
tags: []
|
||||
|
||||
|
||||
Interactive Documentation
|
||||
-------------------------
|
||||
|
||||
Responder can automatically supply API Documentation for you. Using the example above::
|
||||
|
||||
api = responder.API(title="Web Service", version="1.0", openapi="3.0.0", docs_route="/docs")
|
||||
|
||||
This will make ``/docs`` render interactive documentation for your API.
|
||||
|
||||
Mount a WSGI App (e.g. Flask)
|
||||
-----------------------------
|
||||
|
||||
@@ -140,10 +158,128 @@ Responder gives you the ability to mount another ASGI / WSGI app at a subroute::
|
||||
|
||||
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`` - **Reduired**
|
||||
* ``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#Directives>`_
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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: **Requests**.
|
||||
|
||||
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?
|
||||
Want HSTS (to redirect all traffic to HTTPS)?
|
||||
|
||||
::
|
||||
|
||||
@@ -151,3 +287,44 @@ Want HSTS?
|
||||
|
||||
|
||||
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=["*"]``.
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
[pytest]
|
||||
; addopts= -rsxX -s -v --strict
|
||||
;addopts= -rsxX -s -v --strict
|
||||
filterwarnings =
|
||||
error::UserWarning
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
from .core import *
|
||||
from . import ext
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.0.8"
|
||||
__version__ = "1.3.0"
|
||||
|
||||
+426
-156
@@ -1,35 +1,57 @@
|
||||
import os
|
||||
import json
|
||||
from functools import partial
|
||||
import os
|
||||
|
||||
from uuid import uuid4
|
||||
from pathlib import Path
|
||||
from base64 import b64encode
|
||||
|
||||
import uvicorn
|
||||
|
||||
import asyncio
|
||||
import apistar
|
||||
import itsdangerous
|
||||
import jinja2
|
||||
from graphql_server import encode_execution_results, json_encode, default_format_error
|
||||
from starlette.routing import Router
|
||||
import uvicorn
|
||||
import yaml
|
||||
from apispec import APISpec, yaml_utils
|
||||
from apispec.ext.marshmallow import MarshmallowPlugin
|
||||
from asgiref.wsgi import WsgiToAsgi
|
||||
from starlette.middleware.errors import ServerErrorMiddleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
|
||||
from starlette.middleware.lifespan import LifespanMiddleware
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
from starlette.routing import Router, LifespanHandler
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.testclient import TestClient
|
||||
from apispec import APISpec
|
||||
from apispec.ext.marshmallow import MarshmallowPlugin
|
||||
from apispec import yaml_utils
|
||||
from asgiref.wsgi import WsgiToAsgi
|
||||
from starlette.websockets import WebSocket
|
||||
from whitenoise import WhiteNoise
|
||||
|
||||
from . import models
|
||||
from . import status_codes
|
||||
from .routes import Route
|
||||
from .formats import get_formats
|
||||
from . import models, status_codes
|
||||
from .background import BackgroundQueue
|
||||
from .formats import get_formats
|
||||
from .routes import Route
|
||||
from .statics import (
|
||||
DEFAULT_API_THEME,
|
||||
DEFAULT_CORS_PARAMS,
|
||||
DEFAULT_SECRET_KEY,
|
||||
DEFAULT_SESSION_COOKIE,
|
||||
)
|
||||
from .templates import GRAPHIQL
|
||||
|
||||
|
||||
# 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 auto_escape: If ``True``, HTML and XML templates will automatically be escaped.
|
||||
:param enable_hsts: If ``True``, send all responses to HTTPS URLs.
|
||||
:param title: The title of the application (OpenAPI Info Object)
|
||||
:param version: The version of the OpenAPI document (OpenAPI Info Object)
|
||||
:param description: The description of the OpenAPI document (OpenAPI Info Object)
|
||||
:param terms_of_service: A URL to the Terms of Service for the API (OpenAPI Info Object)
|
||||
:param contact: The contact dictionary of the application (OpenAPI Contact Object)
|
||||
:param license: The license information of the exposed API (OpenAPI License Object)
|
||||
"""
|
||||
|
||||
status_codes = status_codes
|
||||
@@ -37,50 +59,153 @@ class API:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
debug=False,
|
||||
title=None,
|
||||
version=None,
|
||||
description=None,
|
||||
terms_of_service=None,
|
||||
contact=None,
|
||||
license=None,
|
||||
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,
|
||||
):
|
||||
self.background = BackgroundQueue()
|
||||
|
||||
self.secret_key = secret_key
|
||||
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.static_dir = Path(os.path.abspath(static_dir))
|
||||
self.static_route = f"/{static_dir}"
|
||||
self.static_route = f"/{static_route.strip('/')}"
|
||||
self.templates_dir = Path(os.path.abspath(templates_dir))
|
||||
self.built_in_templates_dir = Path(
|
||||
os.path.abspath(os.path.dirname(__file__) + "/templates")
|
||||
)
|
||||
self.routes = {}
|
||||
self.docs_theme = DEFAULT_API_THEME
|
||||
self.docs_route = docs_route
|
||||
self.schemas = {}
|
||||
self.session_cookie = DEFAULT_SESSION_COOKIE
|
||||
|
||||
self.hsts_enabled = enable_hsts
|
||||
self.static_files = StaticFiles(directory=str(self.static_dir))
|
||||
self.apps = {self.static_route: self.static_files}
|
||||
self.cors = cors
|
||||
self.cors_params = cors_params
|
||||
self.debug = debug
|
||||
|
||||
self.formats = get_formats()
|
||||
if not allowed_hosts:
|
||||
# if not debug:
|
||||
# raise RuntimeError(
|
||||
# "You need to specify `allowed_hosts` when debug is set to False"
|
||||
# )
|
||||
allowed_hosts = ["*"]
|
||||
self.allowed_hosts = allowed_hosts
|
||||
|
||||
# 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)
|
||||
|
||||
self.whitenoise = WhiteNoise(application=self._notfound_wsgi_app)
|
||||
self.whitenoise.add_files(str(self.static_dir))
|
||||
|
||||
self.whitenoise.add_files(
|
||||
(
|
||||
Path(apistar.__file__).parent / "themes" / self.docs_theme / "static"
|
||||
).resolve()
|
||||
)
|
||||
|
||||
self.apps = {}
|
||||
self.mount(self.static_route, self.whitenoise)
|
||||
|
||||
self.formats = get_formats()
|
||||
|
||||
# Cached requests session.
|
||||
self._session = None
|
||||
self.background = BackgroundQueue()
|
||||
|
||||
if self.openapi_version:
|
||||
self.add_route(openapi_route, self.schema_response)
|
||||
|
||||
if self.docs_route:
|
||||
self.add_route(self.docs_route, self.docs_response)
|
||||
|
||||
self.default_endpoint = None
|
||||
self.app = self.dispatch
|
||||
self.add_middleware(GZipMiddleware)
|
||||
|
||||
if self.hsts_enabled:
|
||||
self.add_middleware(HTTPSRedirectMiddleware)
|
||||
|
||||
self.add_middleware(TrustedHostMiddleware, allowed_hosts=self.allowed_hosts)
|
||||
|
||||
self.lifespan_handler = LifespanMiddleware(LifespanHandler)
|
||||
|
||||
if self.cors:
|
||||
self.add_middleware(CORSMiddleware, **self.cors_params)
|
||||
self.add_middleware(ServerErrorMiddleware, debug=debug)
|
||||
|
||||
# Jinja enviroment
|
||||
self.jinja_env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(
|
||||
[str(self.templates_dir), str(self.built_in_templates_dir)],
|
||||
followlinks=True,
|
||||
),
|
||||
autoescape=jinja2.select_autoescape(["html", "xml"] if auto_escape else []),
|
||||
)
|
||||
self.jinja_values_base = {"api": self} # Give reference to self.
|
||||
self.requests = (
|
||||
self.session()
|
||||
) #: A Requests session that is connected to the ASGI app.
|
||||
|
||||
@staticmethod
|
||||
def _default_wsgi_app(environ, start_response):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _notfound_wsgi_app(environ, start_response):
|
||||
start_response("404 NOT FOUND", [("Content-Type", "text/plain")])
|
||||
return [b"Not Found."]
|
||||
|
||||
@property
|
||||
def before_requests(self):
|
||||
def gen():
|
||||
for route in self.routes:
|
||||
if self.routes[route].before_request:
|
||||
yield self.routes[route]
|
||||
|
||||
return [g for g in gen()]
|
||||
|
||||
@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=[MarshmallowPlugin()],
|
||||
info=info,
|
||||
)
|
||||
|
||||
for route in self.routes:
|
||||
@@ -88,10 +213,10 @@ class API:
|
||||
operations = yaml_utils.load_operations_from_docstring(
|
||||
self.routes[route].description
|
||||
)
|
||||
spec.add_path(path=route, operations=operations)
|
||||
spec.path(path=route, operations=operations)
|
||||
|
||||
for name, schema in self.schemas.items():
|
||||
spec.definition(name, schema=schema)
|
||||
spec.components.schema(name, schema=schema)
|
||||
|
||||
return spec
|
||||
|
||||
@@ -99,7 +224,14 @@ class API:
|
||||
def openapi(self):
|
||||
return self._apispec.to_yaml()
|
||||
|
||||
def add_middleware(self, middleware_cls, **middleware_config):
|
||||
self.app = middleware_cls(self.app, **middleware_config)
|
||||
|
||||
def __call__(self, scope):
|
||||
|
||||
if scope["type"] == "lifespan":
|
||||
return self.lifespan_handler(scope)
|
||||
|
||||
path = scope["path"]
|
||||
root_path = scope.get("root_path", "")
|
||||
|
||||
@@ -114,16 +246,46 @@ class API:
|
||||
app = WsgiToAsgi(app)
|
||||
return app(scope)
|
||||
|
||||
return self.app(scope)
|
||||
|
||||
def dispatch(self, scope):
|
||||
# Call the main dispatcher.
|
||||
async def asgi(receive, send):
|
||||
nonlocal scope, self
|
||||
|
||||
req = models.Request(scope, receive=receive)
|
||||
resp = await self._dispatch_request(req)
|
||||
await resp(receive, send)
|
||||
if scope["type"] == "lifespan":
|
||||
return self.lifespan_handler(scope)
|
||||
elif scope["type"] == "websocket":
|
||||
ws = WebSocket(scope=scope, receive=receive, send=send)
|
||||
await self._dispatch_ws(ws)
|
||||
else:
|
||||
req = models.Request(scope, receive=receive, api=self)
|
||||
resp = await self._dispatch_request(
|
||||
req, scope=scope, send=send, receive=receive
|
||||
)
|
||||
await resp(receive, send)
|
||||
|
||||
return asgi
|
||||
|
||||
async def _dispatch_ws(self, ws):
|
||||
route = self.path_matches_route(ws.url.path)
|
||||
route = self.routes.get(route)
|
||||
|
||||
try:
|
||||
try:
|
||||
# Run the view.
|
||||
r = self.background(route.endpoint, ws)
|
||||
# If it's async, await it.
|
||||
if hasattr(r, "cr_running"):
|
||||
await r
|
||||
except TypeError as e:
|
||||
cont = True
|
||||
except Exception:
|
||||
await self.background(
|
||||
self.default_response, websocket=route.uses_websocket, error=True
|
||||
)
|
||||
raise
|
||||
|
||||
def add_schema(self, name, schema, check_existing=True):
|
||||
"""Adds a mashmallow schema to the API specification."""
|
||||
if check_existing:
|
||||
@@ -159,79 +321,179 @@ class API:
|
||||
if route_object.does_match(path):
|
||||
return route
|
||||
|
||||
async def _dispatch_request(self, req):
|
||||
@property
|
||||
def _signer(self):
|
||||
return itsdangerous.Signer(self.secret_key)
|
||||
|
||||
def _prepare_session(self, resp):
|
||||
|
||||
if resp.session:
|
||||
data = self._signer.sign(
|
||||
b64encode(json.dumps(resp.session).encode("utf-8"))
|
||||
)
|
||||
resp.cookies[self.session_cookie] = data.decode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def no_response(req, resp, **params):
|
||||
pass
|
||||
|
||||
async def _dispatch_request(self, req, **options):
|
||||
# Set formats on Request object.
|
||||
req.formats = self.formats
|
||||
|
||||
# Get the route.
|
||||
route = self.path_matches_route(req.url.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)
|
||||
|
||||
route = self.routes.get(route)
|
||||
if route:
|
||||
try:
|
||||
params = self.routes[route].incoming_matches(req.url.path)
|
||||
result = self.routes[route].endpoint(req, resp, **params)
|
||||
if hasattr(result, "cr_running"):
|
||||
await result
|
||||
# The request is using class-based views.
|
||||
except TypeError as e:
|
||||
try:
|
||||
view = self.routes[route].endpoint(**params)
|
||||
except TypeError:
|
||||
view = self.routes[route].endpoint
|
||||
resp = models.Response(req=req, formats=self.formats)
|
||||
|
||||
if self.routes[route].is_graphql:
|
||||
await self.graphql_response(req, resp, schema=view)
|
||||
else:
|
||||
# WSGI App.
|
||||
# try:
|
||||
# return view(
|
||||
# environ=req._environ, start_response=req._start_response
|
||||
# )
|
||||
# except TypeError:
|
||||
# pass
|
||||
pass
|
||||
for before_request in self.before_requests:
|
||||
await self._execute_route(route=before_request, req=req, resp=resp)
|
||||
|
||||
# Run on_request first.
|
||||
try:
|
||||
getattr(view, "on_request")(req, resp)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
# Then on_get.
|
||||
method = req.method.lower()
|
||||
|
||||
try:
|
||||
getattr(view, f"on_{method}")(req, resp)
|
||||
except AttributeError:
|
||||
pass
|
||||
await self._execute_route(route=route, req=req, resp=resp, **options)
|
||||
else:
|
||||
self.default_response(req, resp)
|
||||
resp = models.Response(req=req, formats=self.formats)
|
||||
self.default_response(req=req, resp=resp, notfound=True)
|
||||
self.default_response(req=req, resp=resp)
|
||||
|
||||
self._prepare_session(resp)
|
||||
|
||||
return resp
|
||||
|
||||
def add_route(self, route, endpoint, *, check_existing=True):
|
||||
"""Add a route to the API.
|
||||
async def _execute_route(self, *, route, req, resp, **options):
|
||||
|
||||
params = route.incoming_matches(req.url.path)
|
||||
|
||||
cont = True
|
||||
|
||||
if route.is_function:
|
||||
try:
|
||||
try:
|
||||
# Run the view.
|
||||
r = self.background(route.endpoint, req, resp, **params)
|
||||
# If it's async, await it.
|
||||
if hasattr(r, "cr_running"):
|
||||
await r
|
||||
except TypeError as e:
|
||||
cont = True
|
||||
except Exception:
|
||||
await self.background(self.default_response, req, resp, error=True)
|
||||
raise
|
||||
|
||||
if route.is_class_based or cont:
|
||||
try:
|
||||
view = route.endpoint(**params)
|
||||
except TypeError:
|
||||
try:
|
||||
view = route.endpoint()
|
||||
except TypeError:
|
||||
view = route.endpoint
|
||||
pass
|
||||
|
||||
# Run on_request first.
|
||||
try:
|
||||
# Run the view.
|
||||
r = getattr(view, "on_request", self.no_response)
|
||||
r = self.background(r, req, resp, **params)
|
||||
# If it's async, await it.
|
||||
if hasattr(r, "send"):
|
||||
await r
|
||||
except Exception:
|
||||
await self.background(self.default_response, req, resp, error=True)
|
||||
raise
|
||||
|
||||
# Then run on_method.
|
||||
method = req.method
|
||||
try:
|
||||
# Run the view.
|
||||
r = getattr(view, f"on_{method}", self.no_response)
|
||||
r = self.background(r, req, resp, **params)
|
||||
# If it's async, await it.
|
||||
if hasattr(r, "send"):
|
||||
await r
|
||||
except Exception:
|
||||
await self.background(self.default_response, req, resp, error=True)
|
||||
raise
|
||||
|
||||
def add_event_handler(self, event_type, handler):
|
||||
"""Adds an event handler to the API.
|
||||
|
||||
:param event_type: A string in ("startup", "shutdown")
|
||||
:param handler: The function to run. Can be either a function or a coroutine.
|
||||
"""
|
||||
|
||||
self.lifespan_handler.add_event_handler(event_type, handler)
|
||||
|
||||
def add_route(
|
||||
self,
|
||||
route=None,
|
||||
endpoint=None,
|
||||
*,
|
||||
default=False,
|
||||
static=False,
|
||||
check_existing=True,
|
||||
websocket=False,
|
||||
before_request=False,
|
||||
):
|
||||
"""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 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", and it will become a default route.
|
||||
:param check_existing: If ``True``, an AssertionError will be raised, if the route is already defined.
|
||||
"""
|
||||
if route is None:
|
||||
route = f"/{uuid4().hex}"
|
||||
|
||||
if check_existing:
|
||||
assert route not in self.routes
|
||||
self.routes[route] = Route(route, endpoint)
|
||||
# TODO: A better datastructer or sort it once the app is loaded
|
||||
|
||||
if not endpoint and static:
|
||||
endpoint = self.static_response
|
||||
default = True
|
||||
|
||||
if default:
|
||||
self.default_endpoint = endpoint
|
||||
|
||||
self.routes[route] = Route(
|
||||
route, endpoint, websocket=websocket, before_request=before_request
|
||||
)
|
||||
# TODO: A better data structure or sort it once the app is loaded
|
||||
self.routes = dict(
|
||||
sorted(self.routes.items(), key=lambda item: item[1]._weight())
|
||||
)
|
||||
|
||||
def default_response(self, req, resp):
|
||||
resp.status_code = status_codes.HTTP_404
|
||||
resp.text = "Not found."
|
||||
def default_response(
|
||||
self, req=None, resp=None, websocket=False, notfound=False, error=False
|
||||
):
|
||||
if websocket:
|
||||
return
|
||||
|
||||
if resp.status_code is None:
|
||||
resp.status_code = 200
|
||||
|
||||
if self.default_endpoint and notfound:
|
||||
self.default_endpoint(req=req, resp=resp)
|
||||
else:
|
||||
if notfound:
|
||||
resp.status_code = status_codes.HTTP_404
|
||||
resp.text = "Not found."
|
||||
if error:
|
||||
resp.status_code = status_codes.HTTP_500
|
||||
resp.text = "Application error."
|
||||
|
||||
def docs_response(self, req, resp):
|
||||
resp.html = self.docs
|
||||
|
||||
def static_response(self, req, resp):
|
||||
index = (self.static_dir / "index.html").resolve()
|
||||
if os.path.exists(index):
|
||||
with open(index, "r") as f:
|
||||
resp.html = f.read()
|
||||
else:
|
||||
resp.status_code = status_codes.HTTP_404
|
||||
resp.text = "Not found."
|
||||
|
||||
def schema_response(self, req, resp):
|
||||
resp.status_code = status_codes.HTTP_200
|
||||
@@ -249,54 +511,40 @@ class API:
|
||||
:param status_code: an `API.status_codes` attribute, or an integer, representing the HTTP status code of the redirect.
|
||||
"""
|
||||
|
||||
assert resp.status_code.is_300(status_code)
|
||||
# assert resp.status_code.is_300(status_code)
|
||||
|
||||
resp.status_code = status_code
|
||||
if set_text:
|
||||
resp.text = f"Redirecting to: {location}"
|
||||
resp.headers.update({"Location": location})
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_graphql_query(req):
|
||||
if "json" in req.mimetype:
|
||||
return (await req.media("json"))["query"]
|
||||
def on_event(self, event_type: str, **args):
|
||||
"""Decorator for registering functions or coroutines to run at certain events
|
||||
Supported events: startup, cleanup, shutdown, tick
|
||||
|
||||
# Support query/q in form data.
|
||||
# Form data is awaiting https://github.com/encode/starlette/pull/102
|
||||
# if "query" in req.media("form"):
|
||||
# return req.media("form")["query"]
|
||||
# if "q" in req.media("form"):
|
||||
# return req.media("form")["q"]
|
||||
Usage::
|
||||
|
||||
# Support query/q in params.
|
||||
if "query" in req.params:
|
||||
return req.params["query"]
|
||||
if "q" in req.params:
|
||||
return req.params["q"]
|
||||
@api.on_event('startup')
|
||||
async def open_database_connection_pool():
|
||||
...
|
||||
|
||||
# Otherwise, the request text is used (typical).
|
||||
# TODO: Make some assertions about content-type here.
|
||||
return req.text
|
||||
@api.on_event('tick', seconds=10)
|
||||
async def do_stuff():
|
||||
...
|
||||
|
||||
async def graphql_response(self, req, resp, schema):
|
||||
show_graphiql = req.method.lower() == "get" and req.accepts("text/html")
|
||||
@api.on_event('cleanup')
|
||||
async def close_database_connection_pool():
|
||||
...
|
||||
|
||||
if show_graphiql:
|
||||
resp.content = self.template_string(GRAPHIQL, endpoint=req.url.path)
|
||||
return
|
||||
"""
|
||||
|
||||
query = await 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 decorator(func):
|
||||
self.add_event_handler(event_type, func, **args)
|
||||
return func
|
||||
|
||||
def route(self, route, **options):
|
||||
return decorator
|
||||
|
||||
def route(self, route=None, **options):
|
||||
"""Decorator for creating new routes around function and class definitions.
|
||||
|
||||
Usage::
|
||||
@@ -328,82 +576,96 @@ class API:
|
||||
"""
|
||||
|
||||
if self._session is None:
|
||||
self._session = TestClient(self)
|
||||
self._session = TestClient(self, base_url=base_url)
|
||||
return self._session
|
||||
|
||||
def url_for(self, endpoint, testing=False, **params):
|
||||
def _route_for(self, endpoint):
|
||||
for route_object in self.routes.values():
|
||||
if endpoint in (route_object.endpoint, route_object.endpoint_name):
|
||||
return route_object
|
||||
|
||||
def url_for(self, endpoint, **params):
|
||||
# TODO: Absolute_url
|
||||
"""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(testing=testing, **params)
|
||||
route_object = self._route_for(endpoint)
|
||||
if route_object:
|
||||
return route_object.url(**params)
|
||||
raise ValueError
|
||||
|
||||
def static_url(self, asset):
|
||||
"""Given a static asset, return its URL path."""
|
||||
return f"{self.static_route}/{str(asset)}"
|
||||
|
||||
def template(self, name, auto_escape=True, **values):
|
||||
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template, with provided values supplied.
|
||||
@property
|
||||
def docs(self):
|
||||
|
||||
Note: The current ``api`` instance is always passed into the view.
|
||||
loader = jinja2.PrefixLoader(
|
||||
{
|
||||
self.docs_theme: jinja2.PackageLoader(
|
||||
"apistar", os.path.join("themes", self.docs_theme, "templates")
|
||||
)
|
||||
}
|
||||
)
|
||||
env = jinja2.Environment(autoescape=True, loader=loader)
|
||||
document = apistar.document.Document()
|
||||
document.content = yaml.safe_load(self.openapi)
|
||||
|
||||
: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.
|
||||
"""
|
||||
# Give reference to self.
|
||||
values.update(api=self)
|
||||
template = env.get_template("/".join([self.docs_theme, "index.html"]))
|
||||
|
||||
env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(
|
||||
[str(self.templates_dir), str(self.built_in_templates_dir)],
|
||||
followlinks=True,
|
||||
),
|
||||
autoescape=jinja2.select_autoescape(["html", "xml"] if auto_escape else []),
|
||||
def static_url(asset):
|
||||
return f"{self.static_route}/{asset}"
|
||||
|
||||
return template.render(
|
||||
document=document,
|
||||
langs=["javascript", "python"],
|
||||
code_style=None,
|
||||
static_url=static_url,
|
||||
schema_url="/schema.yml",
|
||||
)
|
||||
|
||||
template = env.get_template(name)
|
||||
return template.render(**values)
|
||||
def template(self, name_, **values):
|
||||
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template, with provided values supplied.
|
||||
|
||||
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 by default passed into the view. This is set in the dict ``api.jinja_values_base``.
|
||||
|
||||
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 name_: The filename of the jinja2 template, in ``templates_dir``.
|
||||
:param values: Data to pass into the template.
|
||||
"""
|
||||
# Give reference to self.
|
||||
values.update(api=self)
|
||||
# Prepopulate values with base
|
||||
values = {**self.jinja_values_base, **values}
|
||||
|
||||
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([])
|
||||
)
|
||||
|
||||
template = env.from_string(s)
|
||||
template = self.jinja_env.get_template(name_)
|
||||
return template.render(**values)
|
||||
|
||||
def run(self, address=None, port=None, **options):
|
||||
def template_string(self, s_, **values):
|
||||
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template string, with provided values supplied.
|
||||
|
||||
Note: The current ``api`` instance is by default passed into the view. This is set in the dict ``api.jinja_values_base``.
|
||||
|
||||
:param s_: The template to use.
|
||||
:param values: Data to pass into the template.
|
||||
"""
|
||||
# Prepopulate values with base
|
||||
values = {**self.jinja_values_base, **values}
|
||||
|
||||
template = self.jinja_env.from_string(s_)
|
||||
return template.render(**values)
|
||||
|
||||
def serve(self, *, address=None, port=None, debug=False, **options):
|
||||
"""Runs the application with uvicorn. 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 debug: Run uvicorn server in debug mode.
|
||||
:param options: Additional keyword arguments to send to ``uvicorn.run()``.
|
||||
"""
|
||||
|
||||
if "PORT" in os.environ:
|
||||
if address is None:
|
||||
address = "0.0.0.0"
|
||||
@@ -414,4 +676,12 @@ class API:
|
||||
if port is None:
|
||||
port = 5042
|
||||
|
||||
uvicorn.run(self, host=address, port=port, **options)
|
||||
def spawn():
|
||||
uvicorn.run(self, host=address, port=port, debug=debug, **options)
|
||||
|
||||
spawn()
|
||||
|
||||
def run(self, **kwargs):
|
||||
if "debug" not in kwargs:
|
||||
kwargs.update({"debug": self.debug})
|
||||
self.serve(**kwargs)
|
||||
|
||||
+18
-1
@@ -1,5 +1,9 @@
|
||||
import multiprocessing
|
||||
import asyncio
|
||||
import functools
|
||||
import concurrent.futures
|
||||
import multiprocessing
|
||||
import traceback
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
|
||||
class BackgroundQueue:
|
||||
@@ -20,8 +24,21 @@ class BackgroundQueue:
|
||||
return f
|
||||
|
||||
def task(self, f):
|
||||
def on_future_done(fs):
|
||||
try:
|
||||
fs.result()
|
||||
except:
|
||||
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.ensure_future(func(*args, **kwargs))
|
||||
else:
|
||||
return await run_in_threadpool(func, *args, **kwargs)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Responder.
|
||||
|
||||
Usage:
|
||||
responder
|
||||
responder run [--build] [--debug] <module>
|
||||
responder build
|
||||
responder --version
|
||||
|
||||
Options:
|
||||
-h --help Show this screen.
|
||||
-v --version Show version.
|
||||
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import docopt
|
||||
from .__version__ import __version__
|
||||
|
||||
|
||||
def cli():
|
||||
args = docopt.docopt(
|
||||
__doc__, argv=None, help=True, version=__version__, options_first=False
|
||||
)
|
||||
|
||||
module = args["<module>"]
|
||||
build = args["build"] or args["--build"]
|
||||
run = args["run"]
|
||||
|
||||
if build:
|
||||
os.system("npm run build")
|
||||
|
||||
if run:
|
||||
split_module = module.split(":")
|
||||
|
||||
if len(split_module) > 1:
|
||||
module = split_module[0]
|
||||
prop = split_module[1]
|
||||
else:
|
||||
prop = "api"
|
||||
|
||||
app = __import__(module)
|
||||
getattr(app, prop).run()
|
||||
@@ -1,2 +1,3 @@
|
||||
from .api import API
|
||||
from .models import Request, Response
|
||||
from .cli import cli
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .graphql import GraphQLView
|
||||
@@ -0,0 +1,65 @@
|
||||
import json
|
||||
from functools import partial
|
||||
|
||||
from graphql_server import default_format_error, encode_execution_results, json_encode
|
||||
|
||||
from ..templates import GRAPHIQL
|
||||
|
||||
|
||||
class GraphQLView:
|
||||
def __init__(self, *, api, schema):
|
||||
self.api = api
|
||||
self.schema = schema
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_graphql_query(req):
|
||||
# TODO: Get variables and operation_name from form data, params, request text?
|
||||
|
||||
if "json" in req.mimetype:
|
||||
json_media = await req.media("json")
|
||||
return (
|
||||
json_media["query"],
|
||||
json_media.get("variables"),
|
||||
json_media.get("operationName"),
|
||||
)
|
||||
|
||||
# Support query/q in form data.
|
||||
# Form data is awaiting https://github.com/encode/starlette/pull/102
|
||||
# if "query" in req.media("form"):
|
||||
# return req.media("form")["query"], None, None
|
||||
# if "q" in req.media("form"):
|
||||
# return req.media("form")["q"], None, None
|
||||
|
||||
# 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).
|
||||
# TODO: Make some assertions about content-type here.
|
||||
return req.text, None, None
|
||||
|
||||
async def graphql_response(self, req, resp, schema):
|
||||
show_graphiql = req.method == "get" and req.accepts("text/html")
|
||||
|
||||
if show_graphiql:
|
||||
resp.content = self.api.template_string(GRAPHIQL, endpoint=req.url.path)
|
||||
return
|
||||
|
||||
query, variables, operation_name = await self._resolve_graphql_query(req)
|
||||
context = {"request": req, "response": resp}
|
||||
result = schema.execute(
|
||||
query, variables=variables, operation_name=operation_name, context=context
|
||||
)
|
||||
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)
|
||||
|
||||
async def on_request(self, req, resp):
|
||||
await self.graphql_response(req, resp, self.schema)
|
||||
+50
-4
@@ -1,10 +1,16 @@
|
||||
import yaml
|
||||
import json
|
||||
|
||||
import yaml
|
||||
from requests_toolbelt.multipart import decoder
|
||||
|
||||
from .models import QueryDict
|
||||
|
||||
|
||||
async def format_form(r, encode=False):
|
||||
if not encode:
|
||||
return await r._starlette.form()
|
||||
if encode:
|
||||
pass
|
||||
else:
|
||||
return QueryDict(await r.text)
|
||||
|
||||
|
||||
async def format_yaml(r, encode=False):
|
||||
@@ -23,5 +29,45 @@ async def format_json(r, encode=False):
|
||||
return json.loads(await r.content)
|
||||
|
||||
|
||||
async def format_files(r, encode=False):
|
||||
if encode:
|
||||
pass
|
||||
else:
|
||||
decoded = decoder.MultipartDecoder(await r.content, r.mimetype)
|
||||
dump = {}
|
||||
for part in decoded.parts:
|
||||
header = part.headers[b"Content-Disposition"].decode("utf-8")
|
||||
mimetype = part.headers.get(b"Content-Type", None)
|
||||
filename = 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 mimetype is None:
|
||||
dump[formname] = part.content
|
||||
else:
|
||||
dump[formname] = {
|
||||
"filename": filename,
|
||||
"content": part.content,
|
||||
"content-type": mimetype.decode("utf-8"),
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
+133
-50
@@ -1,16 +1,24 @@
|
||||
import functools
|
||||
import io
|
||||
import inspect
|
||||
import json
|
||||
import gzip
|
||||
from base64 import b64decode
|
||||
from http.cookies import SimpleCookie
|
||||
|
||||
|
||||
import chardet
|
||||
import rfc3986
|
||||
import graphene
|
||||
import yaml
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
from requests.cookies import RequestsCookieJar
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
|
||||
from starlette.responses import (
|
||||
Response as StarletteResponse,
|
||||
StreamingResponse as StarletteStreamingResponse,
|
||||
)
|
||||
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
@@ -86,25 +94,42 @@ class QueryDict(dict):
|
||||
yield from super().items()
|
||||
|
||||
|
||||
# TODO: add slots
|
||||
class Request:
|
||||
__slots__ = [
|
||||
"_starlette",
|
||||
"formats",
|
||||
"_headers",
|
||||
"_encoding",
|
||||
"api",
|
||||
"_content",
|
||||
"_cookies",
|
||||
]
|
||||
|
||||
def __init__(self, scope, receive):
|
||||
def __init__(self, scope, receive, api=None):
|
||||
self._starlette = StarletteRequest(scope, receive)
|
||||
self.formats = None
|
||||
self._encoding = None
|
||||
self.api = api
|
||||
self._content = None
|
||||
|
||||
headers = CaseInsensitiveDict()
|
||||
for header, value in self._starlette.headers.items():
|
||||
headers[header] = value
|
||||
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."""
|
||||
if self.api.session_cookie in self.cookies:
|
||||
|
||||
data = self.cookies[self.api.session_cookie]
|
||||
|
||||
data = self.api._signer.unsign(data)
|
||||
data = b64decode(data)
|
||||
return json.loads(data)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
@@ -130,6 +155,21 @@ class Request:
|
||||
"""The parsed URL of the Request."""
|
||||
return rfc3986.urlparse(self.full_url)
|
||||
|
||||
@property
|
||||
def cookies(self):
|
||||
"""The cookies sent in the Request, as a dictionary."""
|
||||
if self._cookies is None:
|
||||
cookies = RequestsCookieJar()
|
||||
cookie_header = self.headers.get("Cookie", "")
|
||||
|
||||
bc = SimpleCookie(cookie_header)
|
||||
for key, morsel in bc.items():
|
||||
cookies[key] = morsel.value
|
||||
|
||||
self._cookies = cookies.get_dict()
|
||||
|
||||
return self._cookies
|
||||
|
||||
@property
|
||||
def params(self):
|
||||
"""A dictionary of the parsed query parameters used for the Request."""
|
||||
@@ -160,12 +200,14 @@ class Request:
|
||||
@property
|
||||
async def content(self):
|
||||
"""The Request body, as bytes. Must be awaited."""
|
||||
return await self._starlette.body()
|
||||
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._starlette.body()).decode(await self.encoding)
|
||||
return (await self.content).decode(await self.encoding)
|
||||
|
||||
@property
|
||||
async def declared_encoding(self):
|
||||
@@ -191,7 +233,7 @@ class Request:
|
||||
return content_type in self.headers.get("Accept", [])
|
||||
|
||||
async def media(self, format=None):
|
||||
"""Renders incoming json/yaml/form data as Python objects.
|
||||
"""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.
|
||||
"""
|
||||
@@ -206,39 +248,76 @@ class Request:
|
||||
return await format(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",
|
||||
"text",
|
||||
"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.
|
||||
self.status_code = None #: The HTTP Status Code to use for the Response.
|
||||
self.content = None #: A bytes representation of the response body.
|
||||
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.
|
||||
) #: A Python dictionary of ``{key: value}``, representing the headers of the response.
|
||||
self.formats = formats
|
||||
self.cookies = SimpleCookie() #: The cookies set in the Response
|
||||
self.session = (
|
||||
req.session.copy()
|
||||
) #: The cookie-based session data, in dict form, to add to the Response.
|
||||
|
||||
# Property or func/dec
|
||||
def stream(self, func, *args, **kwargs):
|
||||
assert inspect.isasyncgenfunction(func)
|
||||
|
||||
self._stream = functools.partial(func, *args, **kwargs)
|
||||
|
||||
return func
|
||||
|
||||
@property
|
||||
async def body(self):
|
||||
if self.content:
|
||||
return (self.content, {})
|
||||
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
|
||||
content = content.encode(self.encoding)
|
||||
return (content, headers)
|
||||
|
||||
for format in self.formats:
|
||||
if self.req.accepts(format):
|
||||
@@ -250,44 +329,48 @@ class Response:
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
@property
|
||||
async 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 = await self.body
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
async def __call__(self, receive, send):
|
||||
body, headers = await self.body
|
||||
if len(await self.body) > 500:
|
||||
body, headers = await self.gzipped_body
|
||||
if self.headers:
|
||||
headers.update(self.headers)
|
||||
|
||||
response = StarletteResponse(
|
||||
body, status_code=self.status_code, headers=headers
|
||||
)
|
||||
if self._stream is not None:
|
||||
response_cls = StarletteStreamingResponse
|
||||
else:
|
||||
response_cls = StarletteResponse
|
||||
|
||||
response = response_cls(body, status_code=self.status_code, headers=headers)
|
||||
self._prepare_cookies(response)
|
||||
|
||||
await response(receive, send)
|
||||
|
||||
|
||||
class Schema(graphene.Schema):
|
||||
def on_request(self, req, resp):
|
||||
pass
|
||||
|
||||
+37
-26
@@ -1,22 +1,17 @@
|
||||
import re
|
||||
from parse import parse, search
|
||||
|
||||
|
||||
def memoize(f):
|
||||
def helper(self, s):
|
||||
memoize_key = f"{f.__name__}:{s}"
|
||||
if memoize_key not in self._memo:
|
||||
self._memo[memoize_key] = f(self, s)
|
||||
return self._memo[memoize_key]
|
||||
|
||||
return helper
|
||||
import functools
|
||||
import inspect
|
||||
from parse import parse
|
||||
|
||||
|
||||
class Route:
|
||||
def __init__(self, route, endpoint):
|
||||
_param_pattern = re.compile(r"{([^{}]*)}")
|
||||
|
||||
def __init__(self, route, endpoint, *, websocket=False, before_request=False):
|
||||
self.route = route
|
||||
self.endpoint = endpoint
|
||||
self._memo = {}
|
||||
self.uses_websocket = websocket
|
||||
self.before_request = before_request
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Route {self.route!r}={self.endpoint!r}>"
|
||||
@@ -29,15 +24,19 @@ class Route:
|
||||
# Strings.
|
||||
return self.does_match(other)
|
||||
|
||||
@property
|
||||
def endpoint_name(self):
|
||||
return self.endpoint.__name__
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return self.endpoint.__doc__
|
||||
|
||||
@property
|
||||
def has_parameters(self):
|
||||
return all([("{" in self.route), ("}" in self.route)])
|
||||
return bool(self._param_pattern.search(self.route))
|
||||
|
||||
@memoize
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def does_match(self, s):
|
||||
if s == self.route:
|
||||
return True
|
||||
@@ -45,22 +44,34 @@ class Route:
|
||||
named = self.incoming_matches(s)
|
||||
return bool(len(named))
|
||||
|
||||
@memoize
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def incoming_matches(self, s):
|
||||
results = parse(self.route, s)
|
||||
return results.named if results else {}
|
||||
|
||||
def url(self, testing=False, **params):
|
||||
url = self.route.format(**params)
|
||||
if testing:
|
||||
url = f"http://;{url}"
|
||||
|
||||
return url
|
||||
def url(self, **params):
|
||||
return self.route.format(**params)
|
||||
|
||||
def _weight(self):
|
||||
params_count = -len(set(re.findall(r"{([a-zA-Z]\w*)}", self.route)))
|
||||
return params_count != 0, params_count
|
||||
params = set(self._param_pattern.findall(self.route))
|
||||
params_count = len(params)
|
||||
w = len(self.route.rsplit("}", 1)[-1].strip("/"))
|
||||
return params_count != 0, w == 0, -params_count
|
||||
|
||||
@property
|
||||
def is_graphql(self):
|
||||
return hasattr(self.endpoint, "get_graphql_type")
|
||||
def is_class_based(self):
|
||||
return inspect.isclass(self.endpoint)
|
||||
|
||||
@property
|
||||
def is_function(self):
|
||||
code = hasattr(self.endpoint, "__code__")
|
||||
kwdefaults = hasattr(self.endpoint, "__kwdefaults__")
|
||||
return all((callable(self.endpoint), code, kwdefaults))
|
||||
|
||||
def __hash__(self):
|
||||
return (
|
||||
hash(self.route)
|
||||
^ hash(self.endpoint)
|
||||
^ hash(self.uses_websocket)
|
||||
^ hash(self.before_request)
|
||||
)
|
||||
|
||||
@@ -1 +1,14 @@
|
||||
DEFAULT_ENCODING = "utf-8"
|
||||
DEFAULT_API_THEME = "swaggerui"
|
||||
DEFAULT_SESSION_COOKIE = "Responder-Session"
|
||||
DEFAULT_SECRET_KEY = "NOTASECRET"
|
||||
|
||||
DEFAULT_CORS_PARAMS = {
|
||||
"allow_origins": (),
|
||||
"allow_methods": ("GET",),
|
||||
"allow_headers": (),
|
||||
"allow_credentials": False,
|
||||
"allow_origin_regex": None,
|
||||
"expose_headers": (),
|
||||
"max_age": 600,
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ if sys.argv[-1] == "publish":
|
||||
sys.exit()
|
||||
|
||||
required = [
|
||||
"starlette",
|
||||
"starlette==0.10.*",
|
||||
"uvicorn",
|
||||
"aiofiles",
|
||||
"pyyaml",
|
||||
@@ -31,13 +31,18 @@ required = [
|
||||
"graphql-server-core>=1.1",
|
||||
"jinja2",
|
||||
"parse",
|
||||
"uvloop ; sys_platform != 'win32'",
|
||||
"uvloop; sys_platform != 'win32'",
|
||||
"rfc3986",
|
||||
"python-multipart",
|
||||
"chardet",
|
||||
"apispec>=1.0.0b1",
|
||||
"marshmallow",
|
||||
"asgiref",
|
||||
"whitenoise",
|
||||
"docopt",
|
||||
"itsdangerous",
|
||||
"requests-toolbelt",
|
||||
"apistar",
|
||||
]
|
||||
|
||||
|
||||
@@ -65,7 +70,7 @@ class DebCommand(Command):
|
||||
rmtree(os.path.join(here, "deb_dist"))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
self.status(u"Creating debian mainfest…")
|
||||
self.status(u"Creating debian manifest…")
|
||||
os.system(
|
||||
"python setup.py --command-packages=stdeb.command sdist_dsc -z artful --package3=pipenv --depends3=python3-virtualenv-clone"
|
||||
)
|
||||
@@ -117,9 +122,7 @@ setup(
|
||||
author_email="me@kennethreitz.org",
|
||||
url="https://github.com/kennethreitz/responder",
|
||||
packages=find_packages(exclude=["tests"]),
|
||||
# entry_points={
|
||||
# "console_scripts": ["responder=responder:cli"]
|
||||
# },
|
||||
entry_points={"console_scripts": ["responder=responder.cli:cli"]},
|
||||
package_data={
|
||||
# "": ["LICENSE", "NOTICES"],
|
||||
# "pipenv.vendor.requests": ["*.pem"],
|
||||
@@ -142,7 +145,7 @@ setup(
|
||||
include_package_data=True,
|
||||
license="Apache 2.0",
|
||||
classifiers=[
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
this is a test
|
||||
|
||||
{{ api.static_url('test') }}
|
||||
+4
-2
@@ -2,6 +2,8 @@ import graphene
|
||||
import responder
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
import multiprocessing
|
||||
import concurrent.futures
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -16,12 +18,12 @@ def current_dir():
|
||||
|
||||
@pytest.fixture
|
||||
def api():
|
||||
return responder.API()
|
||||
return responder.API(debug=False, allowed_hosts=[";"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session(api):
|
||||
return api.session()
|
||||
return api.requests
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -22,15 +22,3 @@ def test_bytes_encoding(api, session):
|
||||
|
||||
r = session.get(api.url_for(route), data=data)
|
||||
assert r.content == data
|
||||
|
||||
|
||||
def test_false_encoding_raises(api, session):
|
||||
data = "hi mom!"
|
||||
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
req.encoding = "non-existient"
|
||||
resp.text = await req.text
|
||||
|
||||
with pytest.raises(LookupError):
|
||||
session.get(api.url_for(route), data=data)
|
||||
|
||||
+473
-50
@@ -1,6 +1,14 @@
|
||||
import concurrent
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
import random
|
||||
import responder
|
||||
import requests
|
||||
import string
|
||||
import io
|
||||
|
||||
from starlette.responses import PlainTextResponse
|
||||
|
||||
|
||||
def test_api_basic_route(api):
|
||||
@@ -61,18 +69,28 @@ def test_class_based_view_registration(api):
|
||||
resp.text = "42"
|
||||
|
||||
|
||||
def test_class_based_view_parameters(api):
|
||||
@api.route("/{greeting}")
|
||||
class Greeting:
|
||||
def on_request(self, req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
|
||||
assert api.session().get("http://;/Hello").ok
|
||||
|
||||
|
||||
def test_requests_session(api):
|
||||
assert api.session()
|
||||
assert api.requests
|
||||
|
||||
|
||||
def test_requests_session_works(api, session, url):
|
||||
def test_requests_session_works(api, url):
|
||||
TEXT = "spiral out"
|
||||
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
resp.text = TEXT
|
||||
|
||||
assert session.get(url("/")).text == TEXT
|
||||
assert api.requests.get(url("/")).text == TEXT
|
||||
|
||||
|
||||
def test_status_code(api):
|
||||
@@ -81,7 +99,7 @@ def test_status_code(api):
|
||||
resp.text = "keep going"
|
||||
resp.status_code = responder.status_codes.HTTP_416
|
||||
|
||||
assert api.session().get("http://;/").status_code == responder.status_codes.HTTP_416
|
||||
assert api.requests.get("http://;/").status_code == responder.status_codes.HTTP_416
|
||||
|
||||
|
||||
def test_json_media(api):
|
||||
@@ -91,7 +109,7 @@ def test_json_media(api):
|
||||
def media(req, resp):
|
||||
resp.media = dump
|
||||
|
||||
r = api.session().get("http://;/")
|
||||
r = api.requests.get("http://;/")
|
||||
|
||||
assert "json" in r.headers["Content-Type"]
|
||||
assert r.json() == dump
|
||||
@@ -104,47 +122,47 @@ def test_yaml_media(api):
|
||||
def media(req, resp):
|
||||
resp.media = dump
|
||||
|
||||
r = api.session().get("http://;/", headers={"Accept": "yaml"})
|
||||
r = api.requests.get("http://;/", headers={"Accept": "yaml"})
|
||||
|
||||
assert "yaml" in r.headers["Content-Type"]
|
||||
assert yaml.load(r.content) == dump
|
||||
|
||||
|
||||
def test_graphql_schema_query_querying(api, schema):
|
||||
api.add_route("/", schema)
|
||||
api.add_route("/", responder.ext.GraphQLView(schema=schema, api=api))
|
||||
|
||||
r = api.session().get("http://;/?q={ hello }", headers={"Accept": "json"})
|
||||
r = api.requests.get("http://;/?q={ hello }", headers={"Accept": "json"})
|
||||
assert r.json() == {"data": {"hello": "Hello stranger"}}
|
||||
|
||||
|
||||
def test_argumented_routing(api, session):
|
||||
def test_argumented_routing(api):
|
||||
@api.route("/{name}")
|
||||
def hello(req, resp, *, name):
|
||||
resp.text = f"Hello, {name}."
|
||||
|
||||
r = session.get(api.url_for(hello, name="sean"))
|
||||
r = api.requests.get(api.url_for(hello, name="sean"))
|
||||
assert r.text == "Hello, sean."
|
||||
|
||||
|
||||
def test_mote_argumented_routing(api, session):
|
||||
def test_mote_argumented_routing(api):
|
||||
@api.route("/{greeting}/{name}")
|
||||
def hello(req, resp, *, greeting, name):
|
||||
resp.text = f"{greeting}, {name}."
|
||||
|
||||
r = session.get(api.url_for(hello, greeting="hello", name="lyndsy"))
|
||||
r = api.requests.get(api.url_for(hello, greeting="hello", name="lyndsy"))
|
||||
assert r.text == "hello, lyndsy."
|
||||
|
||||
|
||||
def test_request_and_get(api, session):
|
||||
def test_request_and_get(api):
|
||||
@api.route("/")
|
||||
class ThingsResource:
|
||||
def on_request(self, req, resp):
|
||||
resp.headers.update({"DEATH": "666"})
|
||||
|
||||
def on_get(self, request, resp):
|
||||
def on_get(self, req, resp):
|
||||
resp.headers.update({"LIFE": "42"})
|
||||
|
||||
r = session.get(api.url_for(ThingsResource))
|
||||
r = api.requests.get(api.url_for(ThingsResource))
|
||||
assert "DEATH" in r.headers
|
||||
assert "LIFE" in r.headers
|
||||
|
||||
@@ -155,58 +173,58 @@ def test_class_based_view_status_code(api):
|
||||
def on_request(self, req, resp):
|
||||
resp.status_code = responder.status_codes.HTTP_416
|
||||
|
||||
assert api.session().get("http://;/").status_code == responder.status_codes.HTTP_416
|
||||
assert api.requests.get("http://;/").status_code == responder.status_codes.HTTP_416
|
||||
|
||||
|
||||
def test_query_params(api, url, session):
|
||||
def test_query_params(api, url):
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
resp.media = {"params": req.params}
|
||||
|
||||
r = session.get(api.url_for(route), params={"q": "q"})
|
||||
r = api.requests.get(api.url_for(route), params={"q": "q"})
|
||||
assert r.json()["params"] == {"q": "q"}
|
||||
|
||||
r = session.get(url("/?q=1&q=2&q=3"))
|
||||
r = api.requests.get(url("/?q=1&q=2&q=3"))
|
||||
assert r.json()["params"] == {"q": "3"}
|
||||
|
||||
|
||||
# Requires https://github.com/encode/starlette/pull/102
|
||||
def test_form_data(api, session):
|
||||
def test_form_data(api):
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
resp.media = {"form": await req.media("form")}
|
||||
|
||||
dump = {"q": "q"}
|
||||
r = session.get(api.url_for(route), data=dump)
|
||||
r = api.requests.get(api.url_for(route), data=dump)
|
||||
assert r.json()["form"] == dump
|
||||
|
||||
|
||||
def test_async_function(api, session):
|
||||
def test_async_function(api):
|
||||
content = "The Emerald Tablet of Hermes"
|
||||
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
resp.text = content
|
||||
|
||||
r = session.get(api.url_for(route))
|
||||
r = api.requests.get(api.url_for(route))
|
||||
assert r.text == content
|
||||
|
||||
|
||||
def test_media_parsing(api, session):
|
||||
def test_media_parsing(api):
|
||||
dump = {"hello": "sam"}
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
resp.media = dump
|
||||
|
||||
r = session.get(api.url_for(route))
|
||||
r = api.requests.get(api.url_for(route))
|
||||
assert r.json() == dump
|
||||
|
||||
r = session.get(api.url_for(route), headers={"Accept": "application/x-yaml"})
|
||||
r = api.requests.get(api.url_for(route), headers={"Accept": "application/x-yaml"})
|
||||
assert r.text == "{hello: sam}\n"
|
||||
|
||||
|
||||
def test_background(api, session):
|
||||
def test_background(api):
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
@api.background.task
|
||||
@@ -218,11 +236,11 @@ def test_background(api, session):
|
||||
task()
|
||||
api.text = "ok"
|
||||
|
||||
r = session.get(api.url_for(route))
|
||||
r = api.requests.get(api.url_for(route))
|
||||
assert r.ok
|
||||
|
||||
|
||||
def test_multiple_routes(api, session):
|
||||
def test_multiple_routes(api):
|
||||
@api.route("/1")
|
||||
def route1(req, resp):
|
||||
resp.text = "1"
|
||||
@@ -231,44 +249,45 @@ def test_multiple_routes(api, session):
|
||||
def route2(req, resp):
|
||||
resp.text = "2"
|
||||
|
||||
r = session.get(api.url_for(route1))
|
||||
r = api.requests.get(api.url_for(route1))
|
||||
assert r.text == "1"
|
||||
|
||||
r = session.get(api.url_for(route2))
|
||||
r = api.requests.get(api.url_for(route2))
|
||||
assert r.text == "2"
|
||||
|
||||
|
||||
def test_graphql_schema_json_query(api, schema):
|
||||
api.add_route("/", schema)
|
||||
api.add_route("/", responder.ext.GraphQLView(schema=schema, api=api))
|
||||
|
||||
r = api.session().post("http://;/", json={"query": "{ hello }"})
|
||||
r = api.requests.post("http://;/", json={"query": "{ hello }"})
|
||||
assert r.ok
|
||||
|
||||
|
||||
def test_graphiql(api, schema):
|
||||
api.add_route("/", schema)
|
||||
api.add_route("/", responder.ext.GraphQLView(schema=schema, api=api))
|
||||
|
||||
r = api.session().get("http://;/", headers={"Accept": "text/html"})
|
||||
r = api.requests.get("http://;/", headers={"Accept": "text/html"})
|
||||
assert r.ok
|
||||
assert 'GraphiQL' in r.text
|
||||
assert "GraphiQL" in r.text
|
||||
|
||||
|
||||
def test_json_uploads(api, session):
|
||||
def test_json_uploads(api):
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
resp.media = await req.media()
|
||||
|
||||
dump = {"complicated": "times"}
|
||||
r = session.post(api.url_for(route), json=dump)
|
||||
r = api.requests.post(api.url_for(route), json=dump)
|
||||
assert r.json() == dump
|
||||
|
||||
|
||||
def test_yaml_uploads(api, session):
|
||||
def test_yaml_uploads(api):
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
resp.media = await req.media()
|
||||
|
||||
dump = {"complicated": "times"}
|
||||
r = session.post(
|
||||
r = api.requests.post(
|
||||
api.url_for(route),
|
||||
data=yaml.dump(dump),
|
||||
headers={"Content-Type": "application/x-yaml"},
|
||||
@@ -276,35 +295,39 @@ def test_yaml_uploads(api, session):
|
||||
assert r.json() == dump
|
||||
|
||||
|
||||
def test_form_uploads(api, session):
|
||||
def test_form_uploads(api):
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
resp.media = await req.media()
|
||||
|
||||
dump = {"complicated": "times"}
|
||||
r = session.post(api.url_for(route), data=dump)
|
||||
r = api.requests.post(api.url_for(route), data=dump)
|
||||
assert r.json() == dump
|
||||
|
||||
|
||||
def test_json_downloads(api, session):
|
||||
def test_json_downloads(api):
|
||||
dump = {"testing": "123"}
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
resp.media = dump
|
||||
|
||||
r = session.get(api.url_for(route), headers={"Content-Type": "application/json"})
|
||||
r = api.requests.get(
|
||||
api.url_for(route), headers={"Content-Type": "application/json"}
|
||||
)
|
||||
assert r.json() == dump
|
||||
|
||||
|
||||
def test_yaml_downloads(api, session):
|
||||
def test_yaml_downloads(api):
|
||||
dump = {"testing": "123"}
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
resp.media = dump
|
||||
|
||||
r = session.get(api.url_for(route), headers={"Content-Type": "application/x-yaml"})
|
||||
r = api.requests.get(
|
||||
api.url_for(route), headers={"Content-Type": "application/x-yaml"}
|
||||
)
|
||||
assert yaml.safe_load(r.content) == dump
|
||||
|
||||
|
||||
@@ -312,7 +335,9 @@ def test_schema_generation():
|
||||
import responder
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
api = responder.API(title="Web Service", openapi="3.0")
|
||||
api = responder.API(
|
||||
title="Web Service", openapi="3.0.2", allowed_hosts=["testserver", ";"]
|
||||
)
|
||||
|
||||
@api.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
@@ -332,19 +357,417 @@ def test_schema_generation():
|
||||
"""
|
||||
resp.media = PetSchema().dump({"name": "little orange"})
|
||||
|
||||
r = api.session().get("http://;/schema.yml")
|
||||
r = api.requests.get("http://;/schema.yml")
|
||||
dump = yaml.safe_load(r.content)
|
||||
|
||||
assert dump
|
||||
assert dump["openapi"] == "3.0"
|
||||
assert dump["openapi"] == "3.0.2"
|
||||
|
||||
|
||||
def test_mount_wsgi_app(api, flask, session):
|
||||
def test_documentation():
|
||||
import responder
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
description = "This is a sample server for a pet store."
|
||||
terms_of_service = "http://example.com/terms/"
|
||||
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",
|
||||
docs_route="/docs",
|
||||
description=description,
|
||||
terms_of_service=terms_of_service,
|
||||
contact=contact,
|
||||
license=license,
|
||||
allowed_hosts=["testserver", ";"],
|
||||
)
|
||||
|
||||
@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
|
||||
schema:
|
||||
$ref = "#/components/schemas/Pet"
|
||||
"""
|
||||
resp.media = PetSchema().dump({"name": "little orange"})
|
||||
|
||||
r = api.requests.get("/docs")
|
||||
assert "html" in r.text
|
||||
|
||||
|
||||
def test_mount_wsgi_app(api, flask):
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
resp.text = "hello"
|
||||
|
||||
api.mount("/flask", flask)
|
||||
|
||||
r = session.get("http://;/flask")
|
||||
r = api.requests.get("http://;/flask")
|
||||
assert r.ok
|
||||
|
||||
|
||||
def test_async_class_based_views(api):
|
||||
@api.route("/")
|
||||
class Resource:
|
||||
async def on_post(self, req, resp):
|
||||
resp.text = await req.text
|
||||
|
||||
data = "frame"
|
||||
r = api.requests.post(api.url_for(Resource), data=data)
|
||||
assert r.text == data
|
||||
|
||||
|
||||
def test_cookies(api):
|
||||
@api.route("/")
|
||||
def cookies(req, resp):
|
||||
resp.media = {"cookies": req.cookies}
|
||||
resp.cookies["sent"] = "true"
|
||||
resp.set_cookie(
|
||||
"hello",
|
||||
"world",
|
||||
expires=123,
|
||||
path="/",
|
||||
max_age=123,
|
||||
secure=False,
|
||||
httponly=True,
|
||||
)
|
||||
|
||||
r = api.requests.get(api.url_for(cookies), cookies={"hello": "universe"})
|
||||
assert r.json() == {"cookies": {"hello": "universe"}}
|
||||
assert "sent" in r.cookies
|
||||
assert "hello" in r.cookies
|
||||
|
||||
r = api.requests.get(api.url_for(cookies))
|
||||
assert r.json() == {"cookies": {"hello": "world", "sent": "true"}}
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
def test_sessions(api):
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.session["hello"] = "world"
|
||||
resp.media = resp.session
|
||||
|
||||
r = api.requests.get(api.url_for(view))
|
||||
assert api.session_cookie in r.cookies
|
||||
|
||||
r = api.requests.get(api.url_for(view))
|
||||
assert (
|
||||
r.cookies[api.session_cookie]
|
||||
== '{"hello": "world"}.r3EB04hEEyLYIJaAXCEq3d4YEbs'
|
||||
)
|
||||
assert r.json() == {"hello": "world"}
|
||||
|
||||
|
||||
def test_template_rendering(api):
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.content = api.template_string("{{ var }}", var="hello")
|
||||
|
||||
r = api.requests.get(api.url_for(view))
|
||||
assert r.text == "hello"
|
||||
|
||||
|
||||
def test_file_uploads(api):
|
||||
@api.route("/")
|
||||
async def upload(req, resp):
|
||||
|
||||
files = await req.media("files")
|
||||
result = {}
|
||||
result["hello"] = files["hello"]["content"].decode("utf-8")
|
||||
result["not-a-file"] = files["not-a-file"].decode("utf-8")
|
||||
resp.media = {"files": result}
|
||||
|
||||
world = io.StringIO("world")
|
||||
data = {"hello": ("hello.txt", world, "text/plain"), "not-a-file": b"data only"}
|
||||
r = api.requests.post(api.url_for(upload), files=data)
|
||||
assert r.json() == {"files": {"hello": "world", "not-a-file": "data only"}}
|
||||
|
||||
|
||||
def test_500(api):
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
raise ValueError
|
||||
|
||||
dumb_client = responder.api.TestClient(
|
||||
api, base_url="http://;", raise_server_exceptions=False
|
||||
)
|
||||
r = dumb_client.get(api.url_for(view))
|
||||
assert not r.ok
|
||||
assert r.status_code == responder.status_codes.HTTP_500
|
||||
|
||||
|
||||
def test_404(api):
|
||||
r = api.requests.get("/foo")
|
||||
|
||||
assert r.status_code == responder.status_codes.HTTP_404
|
||||
|
||||
|
||||
def test_kinda_websockets(api):
|
||||
@api.route("/ws", websocket=True)
|
||||
async def websocket(ws):
|
||||
await ws.accept()
|
||||
await ws.send_text("Hello via websocket!")
|
||||
await ws.close()
|
||||
|
||||
|
||||
def test_startup(api):
|
||||
who = [None]
|
||||
|
||||
@api.route("/{greeting}")
|
||||
async def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, {who[0]}!"
|
||||
|
||||
@api.on_event("startup")
|
||||
async def run_startup():
|
||||
who[0] = "world"
|
||||
|
||||
with api.requests as session:
|
||||
r = session.get(f"http://;/hello")
|
||||
assert r.text == "hello, world!"
|
||||
|
||||
|
||||
def test_redirects(api, session):
|
||||
@api.route("/2")
|
||||
def two(req, resp):
|
||||
api.redirect(resp, location="/1")
|
||||
|
||||
@api.route("/1")
|
||||
def one(req, resp):
|
||||
resp.text = "redirected"
|
||||
|
||||
assert session.get("/1").url == "http://;/1"
|
||||
|
||||
|
||||
def test_session_thoroughly(api, session):
|
||||
@api.route("/set")
|
||||
def set(req, resp):
|
||||
resp.session["hello"] = "world"
|
||||
api.redirect(resp, location="/get")
|
||||
|
||||
@api.route("/get")
|
||||
def get(req, resp):
|
||||
resp.media = {"session": req.session}
|
||||
|
||||
r = session.get(api.url_for(set))
|
||||
r = session.get(api.url_for(get))
|
||||
assert r.json() == {"session": {"hello": "world"}}
|
||||
|
||||
|
||||
def test_before_response(api, session):
|
||||
@api.route("/get")
|
||||
def get(req, resp):
|
||||
resp.media = req.session
|
||||
|
||||
@api.route(before_request=True)
|
||||
def before_request(req, resp):
|
||||
resp.headers["x-pizza"] = "1"
|
||||
|
||||
r = session.get(api.url_for(get))
|
||||
assert "x-pizza" in r.headers
|
||||
|
||||
|
||||
def test_allowed_hosts():
|
||||
api = responder.API(allowed_hosts=[";", "tenant.;"])
|
||||
|
||||
@api.route("/")
|
||||
def get(req, resp):
|
||||
pass
|
||||
|
||||
# Exact match
|
||||
r = api.requests.get(api.url_for(get))
|
||||
assert r.status_code == 200
|
||||
|
||||
# Reset the session
|
||||
api._session = None
|
||||
r = api.session(base_url="http://tenant.;").get(api.url_for(get))
|
||||
assert r.status_code == 200
|
||||
|
||||
# Reset the session
|
||||
api._session = None
|
||||
r = api.session(base_url="http://unkownhost").get(api.url_for(get))
|
||||
assert r.status_code == 400
|
||||
|
||||
# Reset the session
|
||||
api._session = None
|
||||
r = api.session(base_url="http://unkown_tenant.;").get(api.url_for(get))
|
||||
assert r.status_code == 400
|
||||
|
||||
api = responder.API(allowed_hosts=["*.;"])
|
||||
|
||||
@api.route("/")
|
||||
def get(req, resp):
|
||||
pass
|
||||
|
||||
# Wildcard domains
|
||||
# Using http://;
|
||||
r = api.requests.get(api.url_for(get))
|
||||
assert r.status_code == 400
|
||||
|
||||
# Reset the session
|
||||
api._session = None
|
||||
r = api.session(base_url="http://tenant1.;").get(api.url_for(get))
|
||||
assert r.status_code == 200
|
||||
|
||||
# Reset the session
|
||||
api._session = None
|
||||
r = api.session(base_url="http://tenant2.;").get(api.url_for(get))
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def create_asset(static_dir, name=None, parent_dir=None):
|
||||
if name is None:
|
||||
name = random.choices(string.ascii_letters, k=6)
|
||||
# :3
|
||||
ext = random.choices(string.ascii_letters, k=2)
|
||||
name = f"{name}.{ext}"
|
||||
|
||||
if parent_dir is None:
|
||||
parent_dir = static_dir
|
||||
else:
|
||||
parent_dir = static_dir.mkdir(parent_dir)
|
||||
|
||||
asset = parent_dir.join(name)
|
||||
asset.write("body { color: blue; }")
|
||||
return asset
|
||||
|
||||
|
||||
def test_staticfiles(tmpdir):
|
||||
static_dir = tmpdir.mkdir("static")
|
||||
|
||||
asset1 = create_asset(static_dir)
|
||||
parent_dir = "css"
|
||||
asset2 = create_asset(static_dir, name="asset2", parent_dir=parent_dir)
|
||||
|
||||
api = responder.API(static_dir=str(static_dir))
|
||||
session = api.session()
|
||||
|
||||
static_route = api.static_route
|
||||
|
||||
# ok
|
||||
r = session.get(f"{static_route}/{asset1.basename}")
|
||||
assert r.status_code == api.status_codes.HTTP_200
|
||||
|
||||
r = session.get(f"{static_route}/{parent_dir}/{asset2.basename}")
|
||||
assert r.status_code == api.status_codes.HTTP_200
|
||||
|
||||
# Asset not found
|
||||
r = session.get(f"{static_route}/not_found.css")
|
||||
assert r.status_code == api.status_codes.HTTP_404
|
||||
|
||||
# Not found on dir listing
|
||||
r = session.get(f"{static_route}")
|
||||
assert r.status_code == api.status_codes.HTTP_404
|
||||
|
||||
r = session.get(f"{static_route}/{parent_dir}")
|
||||
assert r.status_code == api.status_codes.HTTP_404
|
||||
|
||||
|
||||
def test_staticfiles_custom_route(tmpdir):
|
||||
static_dir = tmpdir.mkdir("static")
|
||||
static_route = "custom/static/route/"
|
||||
|
||||
asset = create_asset(static_dir)
|
||||
|
||||
api = responder.API(static_dir=str(static_dir), static_route=static_route)
|
||||
session = api.session()
|
||||
|
||||
# Check
|
||||
assert api.static_route == "/custom/static/route"
|
||||
|
||||
static_route = api.static_route
|
||||
|
||||
# ok
|
||||
r = session.get(f"{static_route}/{asset.basename}")
|
||||
assert r.status_code == api.status_codes.HTTP_200
|
||||
|
||||
# Asset not found
|
||||
r = session.get(f"{static_route}/not_found.css")
|
||||
assert r.status_code == api.status_codes.HTTP_404
|
||||
|
||||
# Not found on dir listing
|
||||
r = session.get(f"{static_route}")
|
||||
assert r.status_code == api.status_codes.HTTP_404
|
||||
|
||||
|
||||
def test_response_html_property(api):
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.html = "<h1>Hello !</h1>"
|
||||
|
||||
assert resp.content == "<h1>Hello !</h1>"
|
||||
assert resp.mimetype == "text/html"
|
||||
|
||||
r = api.requests.get(api.url_for(view))
|
||||
assert r.content == b"<h1>Hello !</h1>"
|
||||
assert r.headers["Content-Type"] == "text/html"
|
||||
|
||||
|
||||
def test_response_text_property(api):
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.text = "<h1>Hello !</h1>"
|
||||
|
||||
assert resp.content == "<h1>Hello !</h1>"
|
||||
assert resp.mimetype == "text/plain"
|
||||
|
||||
r = api.requests.get(api.url_for(view))
|
||||
assert r.content == b"<h1>Hello !</h1>"
|
||||
assert r.headers["Content-Type"] == "text/plain"
|
||||
|
||||
|
||||
def test_stream(api, session):
|
||||
async def shout_stream(who):
|
||||
for c in who.upper():
|
||||
yield c
|
||||
|
||||
@api.route("/{who}")
|
||||
async def greeting(req, resp, *, who):
|
||||
|
||||
resp.stream(shout_stream, who)
|
||||
|
||||
r = session.get("/morocco")
|
||||
assert r.text == "MOROCCO"
|
||||
|
||||
@api.route("/")
|
||||
async def home(req, resp):
|
||||
# Raise when it's not an async generator
|
||||
with pytest.raises(AssertionError):
|
||||
|
||||
def foo():
|
||||
pass
|
||||
|
||||
res.stream(foo)
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
|
||||
async def foo():
|
||||
pass
|
||||
|
||||
res.stream(foo)
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
|
||||
def foo():
|
||||
yield "oopsie"
|
||||
|
||||
res.stream(foo)
|
||||
|
||||
+76
-26
@@ -2,6 +2,10 @@ import pytest
|
||||
from responder import routes
|
||||
|
||||
|
||||
def setup_function(function):
|
||||
routes.Route.incoming_matches.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route, expected",
|
||||
[
|
||||
@@ -30,32 +34,29 @@ def test_equal():
|
||||
assert r != r3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path_param, actual, match",
|
||||
[
|
||||
pytest.param(
|
||||
"/{greetings}", "/hello", {"greetings": "hello"}, id="with one strformat"
|
||||
),
|
||||
pytest.param(
|
||||
"/{greetings}.{name}",
|
||||
"/hi.jane",
|
||||
{"greetings": "hi", "name": "jane"},
|
||||
id="with dot in url and two strformat",
|
||||
),
|
||||
pytest.param(
|
||||
"/{greetings}/{name}",
|
||||
"/hi/john",
|
||||
{"greetings": "hi", "name": "john"},
|
||||
id="with sub url and two strformat",
|
||||
),
|
||||
pytest.param(
|
||||
"/concrete_path", "/foo", {}, id="test concrete path with no match"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_incoming_matches(path_param, actual, match):
|
||||
r = routes.Route(path_param, "test_endpoint")
|
||||
assert r.incoming_matches(actual) == match
|
||||
def test_incoming_matches():
|
||||
# Test Route with one param
|
||||
r = routes.Route("/{greetings}", "test_endpoint")
|
||||
assert r.incoming_matches("/hello") == {"greetings": "hello"}
|
||||
assert r.incoming_matches("/foo") == {"greetings": "foo"}
|
||||
|
||||
# Test Route with two params
|
||||
r = routes.Route("/{greetings}/{name}", "test_endpoint")
|
||||
assert r.incoming_matches("/hi/john") == {"greetings": "hi", "name": "john"}
|
||||
assert r.incoming_matches("/hello/jane") == {"greetings": "hello", "name": "jane"}
|
||||
|
||||
# Test Route with no param
|
||||
r = routes.Route("/hello", "test_endpoint")
|
||||
assert r.incoming_matches("/hello") == {}
|
||||
assert r.incoming_matches("/bye") == {}
|
||||
|
||||
|
||||
def test_incoming_matches_cache():
|
||||
r = routes.Route("/hello", "test_endpoint")
|
||||
r.incoming_matches("/hello")
|
||||
assert r.incoming_matches.cache_info().hits == 0
|
||||
r.incoming_matches("/hello")
|
||||
assert r.incoming_matches.cache_info().hits == 1
|
||||
|
||||
|
||||
def test_incoming_matches_with_concrete_path_no_match():
|
||||
@@ -81,3 +82,52 @@ def test_incoming_matches_with_concrete_path_no_match():
|
||||
def test_does_match_with_route(route, match, expected):
|
||||
r = routes.Route(route, "test_endpoint")
|
||||
assert r.does_match(match) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path_param, expected_weight",
|
||||
[
|
||||
pytest.param("/{greetings}", (True, True, -1), id="with one param"),
|
||||
pytest.param(
|
||||
"/{greetings}.{name}",
|
||||
(True, True, -2),
|
||||
id="with 2 params and dot in the middle",
|
||||
),
|
||||
pytest.param(
|
||||
"/{greetings}/{name}", (True, True, -2), id="with 2 params and subpath"
|
||||
),
|
||||
pytest.param(
|
||||
"/{greetings}/{name}/{hello}",
|
||||
(True, True, -3),
|
||||
id="with 3 params and subpath",
|
||||
),
|
||||
pytest.param(
|
||||
"/{greetings}_{name}", (True, True, -2), id="with 2 params and underscore"
|
||||
),
|
||||
pytest.param("/{greetings}/test", (True, False, -1), id="with one param"),
|
||||
pytest.param(
|
||||
"/{greetings}.{name}/test",
|
||||
(True, False, -2),
|
||||
id="with 2 params and dot in the middle",
|
||||
),
|
||||
pytest.param(
|
||||
"/{greetings}/{name}/test",
|
||||
(True, False, -2),
|
||||
id="with 2 params and subpath",
|
||||
),
|
||||
pytest.param(
|
||||
"/{greetings}/{name}/{hello}/test",
|
||||
(True, False, -3),
|
||||
id="with 3 params and subpath",
|
||||
),
|
||||
pytest.param(
|
||||
"/{greetings}_{name}/test",
|
||||
(True, False, -2),
|
||||
id="with 2 params and underscore",
|
||||
),
|
||||
pytest.param("/hello", (False, False, 0), id="without params"),
|
||||
],
|
||||
)
|
||||
def test_weight(path_param, expected_weight):
|
||||
r = routes.Route(path_param, "test_endpoint")
|
||||
assert r._weight() == expected_weight
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import pytest
|
||||
from responder import status_codes
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code, expected",
|
||||
[
|
||||
pytest.param(101, True, id="Normal 101"),
|
||||
pytest.param(199, True, id="Not actual status code but within 100"),
|
||||
pytest.param(0, False, id="Zero case (below 100)"),
|
||||
pytest.param(200, False, id="Above 100"),
|
||||
],
|
||||
)
|
||||
def test_is_100(status_code, expected):
|
||||
assert status_codes.is_100(status_code) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code, expected",
|
||||
[
|
||||
pytest.param(201, True, id="Normal 201"),
|
||||
pytest.param(299, True, id="Not actual status code but within 200"),
|
||||
pytest.param(0, False, id="Zero case (below 200)"),
|
||||
pytest.param(300, False, id="Above 200"),
|
||||
],
|
||||
)
|
||||
def test_is_200(status_code, expected):
|
||||
assert status_codes.is_200(status_code) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code, expected",
|
||||
[
|
||||
pytest.param(301, True, id="Normal 301"),
|
||||
pytest.param(399, True, id="Not actual status code but within 300"),
|
||||
pytest.param(0, False, id="Zero case (below 300)"),
|
||||
pytest.param(400, False, id="Above 300"),
|
||||
],
|
||||
)
|
||||
def test_is_300(status_code, expected):
|
||||
assert status_codes.is_300(status_code) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code, expected",
|
||||
[
|
||||
pytest.param(401, True, id="Normal 401"),
|
||||
pytest.param(499, True, id="Not actual status code but within 400"),
|
||||
pytest.param(0, False, id="Zero case (below 400)"),
|
||||
pytest.param(500, False, id="Above 400"),
|
||||
],
|
||||
)
|
||||
def test_is_400(status_code, expected):
|
||||
assert status_codes.is_400(status_code) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code, expected",
|
||||
[
|
||||
pytest.param(501, True, id="Normal 501"),
|
||||
pytest.param(599, True, id="Not actual status code but within 500"),
|
||||
pytest.param(0, False, id="Zero case (below 500)"),
|
||||
pytest.param(600, False, id="Above 500"),
|
||||
],
|
||||
)
|
||||
def test_is_500(status_code, expected):
|
||||
assert status_codes.is_500(status_code) is expected
|
||||
Reference in New Issue
Block a user