mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 23:00:17 +00:00
Compare commits
142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cbcaf9c4f | |||
| 3fa6f11ffa | |||
| 8b88b148bf | |||
| 1aecafa82a | |||
| 8c763aa97e | |||
| 91aa242a5a | |||
| 084d057a99 | |||
| d3acf2c1c1 | |||
| 80715a12ac | |||
| 66fc7afbe4 | |||
| e7776eb9e8 | |||
| 944d47da45 | |||
| a3a12cff77 | |||
| 7b2839086d | |||
| 351ff8d95e | |||
| 2278beba18 | |||
| 3cfc7ec2b6 | |||
| 0de22eeed2 | |||
| b0cc37861b | |||
| 7d4532acc9 | |||
| 1b63d2943a | |||
| b5723303c8 | |||
| 5730be4b31 | |||
| 6f9c11645a | |||
| 827cc64988 | |||
| 7b5db5bc33 | |||
| b9a03c7088 | |||
| 4cbf55508e | |||
| 83d0fcf1ae | |||
| a698eaaab3 | |||
| 3aa21eed08 | |||
| 2741c74b90 | |||
| aba96525ad | |||
| a5b6d36991 | |||
| e4cff76fa6 | |||
| f11ad7136d | |||
| c32e8c7468 | |||
| d93e3cd12c | |||
| 040f1a57e4 | |||
| 307313744f | |||
| 98ca45003b | |||
| ab76594297 | |||
| 7fba0f6362 | |||
| 4ff73e9d0c | |||
| 68bbea0a55 | |||
| 106e5e9073 | |||
| 3426aa71da | |||
| 413028b636 | |||
| 3edf979a8c | |||
| cd75deeb4e | |||
| b71bb5ddb9 | |||
| 27a9459f22 | |||
| b39c539d57 | |||
| 718b53cce2 | |||
| 2e0b4975f7 | |||
| a118a5dc4b | |||
| 69c1d7f185 | |||
| fba2f135a3 | |||
| 4006de72cd | |||
| b3c7252197 | |||
| 398ac3343e | |||
| 8b197ba361 | |||
| e700aa2937 | |||
| 3894550642 | |||
| 43fd041138 | |||
| 363af5338d | |||
| 55430a4366 | |||
| f7c6a3ae97 | |||
| dcadba1425 | |||
| de08b15ae8 | |||
| 0cfca6d906 | |||
| a73e413a66 | |||
| 87931a25d0 | |||
| 1fd9a682dd | |||
| 5d3e650901 | |||
| 48d082e6a5 | |||
| 87e22481e8 | |||
| e48ce6c301 | |||
| e9613500da | |||
| c2943accd0 | |||
| 649a255657 | |||
| 7eaaaaafe1 | |||
| ae09b88978 | |||
| e3e307fd68 | |||
| 89f0724029 | |||
| bebe62adaf | |||
| eb9cddc8c2 | |||
| 7c19eca78a | |||
| ed28b11d21 | |||
| 46cdd4a245 | |||
| ac91b172e6 | |||
| ed0da6d462 | |||
| 555e9bff65 | |||
| bf43d9f202 | |||
| e239cc304d | |||
| 3285bd57c7 | |||
| 3090fb9e68 | |||
| e90bd24ebe | |||
| a0acc03a97 | |||
| 8a668e6efe | |||
| 4c75742e4d | |||
| 796fdc2ddf | |||
| a8caa3054b | |||
| 2ef9e133ad | |||
| 2ec570ad61 | |||
| 02aa338970 | |||
| 882250bd86 | |||
| 3809eda2f2 | |||
| b32eda70d2 | |||
| f1b2f46a10 | |||
| cf82dac4ad | |||
| a0913e3f63 | |||
| f90955a9b9 | |||
| 3736c9229d | |||
| a802853367 | |||
| 96ca88fe88 | |||
| a57570210a | |||
| 7682e94b35 | |||
| 8bbebe113c | |||
| 7c921f827b | |||
| 4cc055f93a | |||
| e596a8b457 | |||
| fd2da55880 | |||
| 975e9b5643 | |||
| c0036e0474 | |||
| 103816e27a | |||
| b7c1684ab4 | |||
| 16bd6ca266 | |||
| 20bae4712b | |||
| a7aa80c690 | |||
| df89d1d58b | |||
| 477cddd29c | |||
| 9b8cf3a1b1 | |||
| 2871a3c07f | |||
| 13763296dd | |||
| 783b22ab1c | |||
| 109937adf4 | |||
| 63ea9cc4e0 | |||
| ec40a0c4c3 | |||
| 0855d1a378 | |||
| 77fe17d350 | |||
| 0b8a031ccb |
@@ -0,0 +1,3 @@
|
||||
github: kennethreitz
|
||||
thanks_dev: kennethreitz
|
||||
custom: https://cash.app/$KennethReitz
|
||||
@@ -0,0 +1,16 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
@@ -0,0 +1,54 @@
|
||||
name: "Documentation"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request: ~
|
||||
workflow_dispatch:
|
||||
|
||||
# Cancel redundant in-progress jobs.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
documentation:
|
||||
name: "Documentation: Python ${{ matrix.python-version }} on ${{ matrix.os }}"
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ["ubuntu-latest"]
|
||||
python-version: ["3.13"]
|
||||
env:
|
||||
UV_SYSTEM_PYTHON: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
version: "latest"
|
||||
enable-cache: true
|
||||
cache-suffix: ${{ matrix.python-version }}
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
|
||||
- name: Install package and documentation dependencies
|
||||
run: |
|
||||
uv pip install '.[develop,docs]'
|
||||
|
||||
- name: Run link checker
|
||||
run: |
|
||||
poe docs-linkcheck
|
||||
|
||||
- name: Build static HTML documentation
|
||||
run: |
|
||||
poe docs-html
|
||||
@@ -0,0 +1,55 @@
|
||||
name: "Tests"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request: ~
|
||||
workflow_dispatch:
|
||||
|
||||
# Cancel redundant in-progress jobs.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
test:
|
||||
name: "Python ${{ matrix.python-version }}"
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: [
|
||||
"3.9",
|
||||
"3.10",
|
||||
"3.11",
|
||||
"3.12",
|
||||
"3.13",
|
||||
]
|
||||
env:
|
||||
UV_SYSTEM_PYTHON: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
version: "latest"
|
||||
enable-cache: true
|
||||
cache-suffix: ${{ matrix.python-version }}
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
|
||||
- name: Install and validate package
|
||||
run: |
|
||||
uv pip install '.[develop,test]'
|
||||
poe check
|
||||
@@ -1,3 +1,4 @@
|
||||
.venv*
|
||||
.vscode/
|
||||
.cache
|
||||
.idea
|
||||
@@ -6,6 +7,7 @@
|
||||
.pytest_cache
|
||||
.DS_Store
|
||||
coverage.xml
|
||||
.coverage*
|
||||
|
||||
__pycache__
|
||||
tests/__pycache__
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# .readthedocs.yml
|
||||
# Read the Docs configuration file
|
||||
|
||||
# Details
|
||||
# - https://docs.readthedocs.io/en/stable/config-file/v2.html
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
build:
|
||||
os: "ubuntu-24.04"
|
||||
tools:
|
||||
python: "3.12"
|
||||
|
||||
python:
|
||||
install:
|
||||
- method: pip
|
||||
path: .
|
||||
extra_requirements:
|
||||
- docs
|
||||
|
||||
sphinx:
|
||||
configuration: docs/source/conf.py
|
||||
|
||||
# Use standard HTML builder.
|
||||
builder: html
|
||||
|
||||
# Fail on all warnings to avoid broken references.
|
||||
fail_on_warning: true
|
||||
|
||||
# Optionally build your docs in additional formats such as PDF
|
||||
#formats:
|
||||
# - pdf
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
# travis use trusty by default
|
||||
dist: xenial
|
||||
|
||||
language: python
|
||||
python:
|
||||
- 3.6
|
||||
- 3.7
|
||||
- "3.8-dev"
|
||||
|
||||
# command to install dependencies
|
||||
install:
|
||||
- pip install pipenv --upgrade-strategy=only-if-needed
|
||||
- pipenv install --dev
|
||||
|
||||
# command to run the dependencies
|
||||
script:
|
||||
- black responder tests setup.py --check
|
||||
- pytest
|
||||
+196
-48
@@ -1,274 +1,422 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and
|
||||
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [v2.0.4] - 2019-11-19
|
||||
|
||||
## [v3.0.0] - 2026-03-22
|
||||
|
||||
### Added
|
||||
|
||||
- Platform: Added support for Python 3.10 - Python 3.13
|
||||
- CLI: `responder run` now also accepts a filesystem path on its `<target>`
|
||||
argument, enabling usage on single-file applications.
|
||||
- CLI: `responder run` now also accepts URLs.
|
||||
|
||||
### Changed
|
||||
|
||||
- Platform: Minimum Python version is now 3.9 (dropped 3.6, 3.7, 3.8)
|
||||
- Dependencies: Dramatically reduced core dependency count (10 → 5)
|
||||
- Removed `requests`, `requests-toolbelt`, `rfc3986`, `whitenoise`
|
||||
- Moved `apispec` and `marshmallow` to `openapi` optional extra
|
||||
- Replaced `rfc3986` with stdlib `urllib.parse`
|
||||
- Replaced `requests-toolbelt` multipart decoder with `python-multipart`
|
||||
- Replaced deprecated `starlette.middleware.wsgi` with `a2wsgi`
|
||||
- Switched from WhiteNoise to ServeStatic
|
||||
- Dependencies: Pinned `starlette[full]>=0.40` (was unpinned)
|
||||
- GraphQL: Upgraded to `graphene>=3` and `graphql-core>=3.1`
|
||||
(from `graphene<3` and `graphql-server-core`, which is unmaintained)
|
||||
- GraphQL: Updated GraphiQL UI from 0.12.0 (2018) to 3.0.6 with React 18
|
||||
- Extensions: All of CLI-, GraphQL-, and OpenAPI-Support modules are
|
||||
extensions now, found within the `responder.ext` module namespace.
|
||||
- Packaging: Migrated from `setup.py` to declarative `pyproject.toml`
|
||||
|
||||
### Removed
|
||||
|
||||
- Platform: Removed support for EOL Python 3.6, 3.7, 3.8
|
||||
- Status codes: Removed deprecated `resume_incomplete` and `resume`
|
||||
aliases for HTTP 308 (marked for removal in 3.0)
|
||||
- CLI: `responder run --build` ceased to exist
|
||||
|
||||
### Fixed
|
||||
|
||||
- Routing: Fixed dispatching `static_route=None` on Windows
|
||||
- uvicorn: `--debug` now maps to uvicorn's `log_level = "debug"`
|
||||
- Tests: Fixed deprecated httpx TestClient usage
|
||||
|
||||
## [v2.0.5] - 2019-12-15
|
||||
|
||||
### Added
|
||||
|
||||
- Update requirements to support python 3.8
|
||||
|
||||
## [v2.0.4] - 2019-11-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix static app resolving
|
||||
|
||||
## [v2.0.3] - 2019-09-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix template conflicts
|
||||
|
||||
## [v2.0.2] - 2019-09-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix template conflicts
|
||||
|
||||
## [v2.0.1] - 2019-09-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix template import
|
||||
|
||||
## [v2.0.0] - 2019-09-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactor Router and Schema
|
||||
|
||||
## [v1.3.2] - 2019-08-15
|
||||
|
||||
### Added
|
||||
|
||||
- ASGI 3 support
|
||||
- CI tests for python 3.8-dev
|
||||
- Now requests have `state` a mapping object
|
||||
|
||||
### Deprecated
|
||||
|
||||
- ASGI 2
|
||||
|
||||
## [v1.3.1] - 2019-04-28
|
||||
|
||||
### Added
|
||||
|
||||
- Route params Converters
|
||||
- Add search for documentation pages
|
||||
|
||||
### Changed
|
||||
|
||||
- Bump dependencies
|
||||
|
||||
## [v1.3.0] - 2019-02-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- Versioning issue
|
||||
- Multiple cookies.
|
||||
- Whitenoise returns not found.
|
||||
- Other bugfixes.
|
||||
|
||||
### Added
|
||||
|
||||
- Stream support via `resp.stream`.
|
||||
- Cookie directives via `resp.set_cookie`.
|
||||
- Add `resp.html` to send HTML.
|
||||
- Other improvements.
|
||||
|
||||
## [v1.1.3] - 2019-01-12
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactor `_route_for`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Resolve startup/shutdwown events
|
||||
|
||||
## [v1.2.0] - 2018-12-29
|
||||
|
||||
### Added
|
||||
|
||||
- Documentations
|
||||
|
||||
### Changed
|
||||
|
||||
- Use Starlette's LifeSpan middleware
|
||||
- Update denpendencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix route.is_class_based
|
||||
- Fix test_500
|
||||
- Typos
|
||||
|
||||
## [v1.1.2] - 2018-11-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- Minor fixes for Open API
|
||||
- Typos
|
||||
|
||||
## [v1.1.1] - 2018-10-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Run sync views in a threadpoolexecutor.
|
||||
|
||||
## [v1.1.0] - 2018-10-27
|
||||
|
||||
### Added
|
||||
|
||||
- Support for `before_request`.
|
||||
|
||||
## [v1.0.5]- 2018-10-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix sessions.
|
||||
|
||||
## [v1.0.4] - 2018-10-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Potential bufix for cookies.
|
||||
|
||||
## [v1.0.3] - 2018-10-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bugfix for redirects.
|
||||
|
||||
## [v1.0.2] - 2018-10-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Improvement for static file hosting.
|
||||
|
||||
## [v1.0.1] - 2018-10-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Improve cors configuration settings.
|
||||
|
||||
## [v1.0.0] - 2018-10-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Move GraphQL support into a built-in plugin.
|
||||
|
||||
## [v0.3.3] - 2018-10-25
|
||||
|
||||
### Added
|
||||
|
||||
- CORS support
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved exceptions.
|
||||
|
||||
## [v0.3.2] - 2018-10-25
|
||||
|
||||
### Changed
|
||||
|
||||
- Subtle improvements.
|
||||
|
||||
## [v0.3.1] - 2018-10-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- Packaging fix.
|
||||
|
||||
## [v0.3.0] - 2018-10-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Interactive Documentation endpoint.
|
||||
- Minor improvements.
|
||||
|
||||
## [v0.2.3] - 2018-10-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Overall improvements.
|
||||
|
||||
## [v0.2.2] - 2018-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- Show traceback info when background tasks raise exceptions.
|
||||
|
||||
## [v0.2.1] - 2018-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- api.requests.
|
||||
|
||||
## [v0.2.0] - 2018-10-22
|
||||
|
||||
### Added
|
||||
|
||||
- WebSocket support.
|
||||
|
||||
## [v0.1.6] - 2018-10-20
|
||||
|
||||
### Added
|
||||
|
||||
- 500 support.
|
||||
|
||||
## [v0.1.5] - 2018-10-20
|
||||
|
||||
### Added
|
||||
|
||||
- File upload support
|
||||
|
||||
### Changed
|
||||
|
||||
- Improvements to sequential media reading.
|
||||
|
||||
## [v0.1.4] - 2018-10-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stability.
|
||||
|
||||
## [v0.1.3] - 2018-10-18
|
||||
|
||||
### Added
|
||||
|
||||
- Sessions support.
|
||||
|
||||
## [v0.1.2] - 2018-10-18
|
||||
|
||||
### Added
|
||||
|
||||
- Cookies support.
|
||||
|
||||
## [v0.1.1] - 2018-10-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Default routes.
|
||||
|
||||
## [v0.1.0] - 2018-10-17
|
||||
|
||||
### Added
|
||||
|
||||
- Prototype of static application support.
|
||||
|
||||
## [v0.0.10] - 2018-10-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bugfix for async class-based views.
|
||||
|
||||
## [v0.0.9] - 2018-10-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bugfix for async class-based views.
|
||||
|
||||
## [v0.0.8] - 2018-10-17
|
||||
|
||||
### Added
|
||||
|
||||
- GraphiQL Support.
|
||||
|
||||
### Changed
|
||||
|
||||
- Improvement to route selection.
|
||||
|
||||
## [v0.0.7] - 2018-10-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Immutable Request object.
|
||||
|
||||
## [v0.0.6] - 2018-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Ability to mount WSGI apps.
|
||||
- Supply content-type when serving up the schema.
|
||||
|
||||
## [v0.0.5] - 2018-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- OpenAPI Schema support.
|
||||
- Safe load/dump yaml.
|
||||
|
||||
## [v0.0.4] - 2018-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Asynchronous support for data uploads.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bug fixes.
|
||||
|
||||
## [v0.0.3] - 2018-10-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bug fixes.
|
||||
|
||||
## [v0.0.2] - 2018-10-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Switch to ASGI/Starlette.
|
||||
|
||||
## [v0.0.1] - 2018-10-12
|
||||
|
||||
### Added
|
||||
|
||||
- Conception!
|
||||
|
||||
[Unreleased]: https://github.com/taoufik07/responder/compare/v2.0.4..HEAD
|
||||
[v2.0.4]: https://github.com/taoufik07/responder/compare/v2.0.3..v2.0.4
|
||||
[v2.0.3]: https://github.com/taoufik07/responder/compare/v2.0.2..v2.0.3
|
||||
[v2.0.2]: https://github.com/taoufik07/responder/compare/v2.0.1..v2.0.2
|
||||
[v2.0.1]: https://github.com/taoufik07/responder/compare/v2.0.0..v2.0.1
|
||||
[v2.0.0]: https://github.com/taoufik07/responder/compare/v1.3.2..v2.0.0
|
||||
[v1.3.2]: https://github.com/taoufik07/responder/compare/v1.3.1..v1.3.2
|
||||
[v1.3.1]: https://github.com/taoufik07/responder/compare/v1.3.0..v1.3.1
|
||||
[v1.3.0]: https://github.com/taoufik07/responder/compare/v1.2.0..v1.3.0
|
||||
[v1.2.0]: https://github.com/taoufik07/responder/compare/v1.1.3..v1.2.0
|
||||
[v1.1.3]: https://github.com/taoufik07/responder/compare/v1.1.2..v1.1.3
|
||||
[v1.1.2]: https://github.com/taoufik07/responder/compare/v1.1.1..v1.1.2
|
||||
[v1.1.1]: https://github.com/taoufik07/responder/compare/v1.1.0..v1.1.1
|
||||
[v1.1.0]: https://github.com/taoufik07/responder/compare/v1.0.5..v1.1.0
|
||||
[v1.0.5]: https://github.com/taoufik07/responder/compare/v1.0.4..v1.0.5
|
||||
[v1.0.4]: https://github.com/taoufik07/responder/compare/v1.0.3..v1.0.4
|
||||
[v1.0.3]: https://github.com/taoufik07/responder/compare/v1.0.2..v1.0.3
|
||||
[v1.0.2]: https://github.com/taoufik07/responder/compare/v1.0.1..v1.0.2
|
||||
[v1.0.1]: https://github.com/taoufik07/responder/compare/v1.0.0..v1.0.1
|
||||
[v1.0.0]: https://github.com/taoufik07/responder/compare/v0.3.3..v1.0.0
|
||||
[v0.3.3]: https://github.com/taoufik07/responder/compare/v0.3.2..v0.3.3
|
||||
[v0.3.2]: https://github.com/taoufik07/responder/compare/v0.3.1..v0.3.2
|
||||
[v0.3.1]: https://github.com/taoufik07/responder/compare/v0.3.0..v0.3.1
|
||||
[v0.3.0]: https://github.com/taoufik07/responder/compare/v0.2.3..v0.3.0
|
||||
[v0.2.3]: https://github.com/taoufik07/responder/compare/v0.2.2..v0.2.3
|
||||
[v0.2.2]: https://github.com/taoufik07/responder/compare/v0.2.1..v0.2.2
|
||||
[v0.2.1]: https://github.com/taoufik07/responder/compare/v0.2.0..v0.2.1
|
||||
[v0.2.0]: https://github.com/taoufik07/responder/compare/v0.1.6..v0.2.0
|
||||
[v0.1.6]: https://github.com/taoufik07/responder/compare/v0.1.5..v0.1.6
|
||||
[v0.1.5]: https://github.com/taoufik07/responder/compare/v0.1.4..v0.1.5
|
||||
[v0.1.4]: https://github.com/taoufik07/responder/compare/v0.1.3..v0.1.4
|
||||
[v0.1.3]: https://github.com/taoufik07/responder/compare/v0.1.2..v0.1.3
|
||||
[v0.1.2]: https://github.com/taoufik07/responder/compare/v0.1.1..v0.1.2
|
||||
[v0.1.1]: https://github.com/taoufik07/responder/compare/v0.1.0..v0.1.1
|
||||
[v0.1.0]: https://github.com/taoufik07/responder/compare/v0.0.10..v0.1.0
|
||||
[v0.0.10]: https://github.com/taoufik07/responder/compare/v0.0.9..v0.0.10
|
||||
[v0.0.9]: https://github.com/taoufik07/responder/compare/v0.0.8..v0.0.9
|
||||
[v0.0.8]: https://github.com/taoufik07/responder/compare/v0.0.7..v0.0.8
|
||||
[v0.0.7]: https://github.com/taoufik07/responder/compare/v0.0.6..v0.0.7
|
||||
[v0.0.6]: https://github.com/taoufik07/responder/compare/v0.0.5..v0.0.6
|
||||
[v0.0.5]: https://github.com/taoufik07/responder/compare/v0.0.4..v0.0.5
|
||||
[v0.0.4]: https://github.com/taoufik07/responder/compare/v0.0.3..v0.0.4
|
||||
[v0.0.3]: https://github.com/taoufik07/responder/compare/v0.0.2..v0.0.3
|
||||
[v0.0.2]: https://github.com/taoufik07/responder/compare/v0.0.1..v0.0.2
|
||||
[v0.0.1]: https://github.com/taoufik07/responder/compare/v0.0.0..v0.0.1
|
||||
[unreleased]: https://github.com/kennethreitz/responder/compare/v3.0.0..HEAD
|
||||
[v3.0.0]: https://github.com/kennethreitz/responder/compare/v2.0.5..v3.0.0
|
||||
[v2.0.5]: https://github.com/kennethreitz/responder/compare/v2.0.4..v2.0.5
|
||||
[v2.0.4]: https://github.com/kennethreitz/responder/compare/v2.0.3..v2.0.4
|
||||
[v2.0.3]: https://github.com/kennethreitz/responder/compare/v2.0.2..v2.0.3
|
||||
[v2.0.2]: https://github.com/kennethreitz/responder/compare/v2.0.1..v2.0.2
|
||||
[v2.0.1]: https://github.com/kennethreitz/responder/compare/v2.0.0..v2.0.1
|
||||
[v2.0.0]: https://github.com/kennethreitz/responder/compare/v1.3.2..v2.0.0
|
||||
[v1.3.2]: https://github.com/kennethreitz/responder/compare/v1.3.1..v1.3.2
|
||||
[v1.3.1]: https://github.com/kennethreitz/responder/compare/v1.3.0..v1.3.1
|
||||
[v1.3.0]: https://github.com/kennethreitz/responder/compare/v1.2.0..v1.3.0
|
||||
[v1.2.0]: https://github.com/kennethreitz/responder/compare/v1.1.3..v1.2.0
|
||||
[v1.1.3]: https://github.com/kennethreitz/responder/compare/v1.1.2..v1.1.3
|
||||
[v1.1.2]: https://github.com/kennethreitz/responder/compare/v1.1.1..v1.1.2
|
||||
[v1.1.1]: https://github.com/kennethreitz/responder/compare/v1.1.0..v1.1.1
|
||||
[v1.1.0]: https://github.com/kennethreitz/responder/compare/v1.0.5..v1.1.0
|
||||
[v1.0.5]: https://github.com/kennethreitz/responder/compare/v1.0.4..v1.0.5
|
||||
[v1.0.4]: https://github.com/kennethreitz/responder/compare/v1.0.3..v1.0.4
|
||||
[v1.0.3]: https://github.com/kennethreitz/responder/compare/v1.0.2..v1.0.3
|
||||
[v1.0.2]: https://github.com/kennethreitz/responder/compare/v1.0.1..v1.0.2
|
||||
[v1.0.1]: https://github.com/kennethreitz/responder/compare/v1.0.0..v1.0.1
|
||||
[v1.0.0]: https://github.com/kennethreitz/responder/compare/v0.3.3..v1.0.0
|
||||
[v0.3.3]: https://github.com/kennethreitz/responder/compare/v0.3.2..v0.3.3
|
||||
[v0.3.2]: https://github.com/kennethreitz/responder/compare/v0.3.1..v0.3.2
|
||||
[v0.3.1]: https://github.com/kennethreitz/responder/compare/v0.3.0..v0.3.1
|
||||
[v0.3.0]: https://github.com/kennethreitz/responder/compare/v0.2.3..v0.3.0
|
||||
[v0.2.3]: https://github.com/kennethreitz/responder/compare/v0.2.2..v0.2.3
|
||||
[v0.2.2]: https://github.com/kennethreitz/responder/compare/v0.2.1..v0.2.2
|
||||
[v0.2.1]: https://github.com/kennethreitz/responder/compare/v0.2.0..v0.2.1
|
||||
[v0.2.0]: https://github.com/kennethreitz/responder/compare/v0.1.6..v0.2.0
|
||||
[v0.1.6]: https://github.com/kennethreitz/responder/compare/v0.1.5..v0.1.6
|
||||
[v0.1.5]: https://github.com/kennethreitz/responder/compare/v0.1.4..v0.1.5
|
||||
[v0.1.4]: https://github.com/kennethreitz/responder/compare/v0.1.3..v0.1.4
|
||||
[v0.1.3]: https://github.com/kennethreitz/responder/compare/v0.1.2..v0.1.3
|
||||
[v0.1.2]: https://github.com/kennethreitz/responder/compare/v0.1.1..v0.1.2
|
||||
[v0.1.1]: https://github.com/kennethreitz/responder/compare/v0.1.0..v0.1.1
|
||||
[v0.1.0]: https://github.com/kennethreitz/responder/compare/v0.0.10..v0.1.0
|
||||
[v0.0.10]: https://github.com/kennethreitz/responder/compare/v0.0.9..v0.0.10
|
||||
[v0.0.9]: https://github.com/kennethreitz/responder/compare/v0.0.8..v0.0.9
|
||||
[v0.0.8]: https://github.com/kennethreitz/responder/compare/v0.0.7..v0.0.8
|
||||
[v0.0.7]: https://github.com/kennethreitz/responder/compare/v0.0.6..v0.0.7
|
||||
[v0.0.6]: https://github.com/kennethreitz/responder/compare/v0.0.5..v0.0.6
|
||||
[v0.0.5]: https://github.com/kennethreitz/responder/compare/v0.0.4..v0.0.5
|
||||
[v0.0.4]: https://github.com/kennethreitz/responder/compare/v0.0.3..v0.0.4
|
||||
[v0.0.3]: https://github.com/kennethreitz/responder/compare/v0.0.2..v0.0.3
|
||||
[v0.0.2]: https://github.com/kennethreitz/responder/compare/v0.0.1..v0.0.2
|
||||
[v0.0.1]: https://github.com/kennethreitz/responder/compare/v0.0.0..v0.0.1
|
||||
|
||||
@@ -1,13 +1,178 @@
|
||||
Copyright 2018 Kenneth Reitz
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
include LICENSE
|
||||
@@ -1,20 +0,0 @@
|
||||
[[source]]
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
responder = {editable = true, path = "."}
|
||||
|
||||
[dev-packages]
|
||||
pytest = "*"
|
||||
"flake8" = "*"
|
||||
black = "*"
|
||||
twine = "*"
|
||||
flask = "*"
|
||||
sphinx = "*"
|
||||
marshmallow = "*"
|
||||
pytest-cov = "*"
|
||||
|
||||
[pipenv]
|
||||
allow_prereleases = true
|
||||
Generated
-762
@@ -1,762 +0,0 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "ea12c0d556a3ca0848b0eba291a11a5ea98a701f0885c2d030b2aeb1e5b9c15f"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {},
|
||||
"sources": [
|
||||
{
|
||||
"name": "pypi",
|
||||
"url": "https://pypi.org/simple",
|
||||
"verify_ssl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"aiofiles": {
|
||||
"hashes": [
|
||||
"sha256:021ea0ba314a86027c166ecc4b4c07f2d40fc0f4b3a950d1868a0f2571c2bbee",
|
||||
"sha256:1e644c2573f953664368de28d2aa4c89dfd64550429d0c27c4680ccd3aa4985d"
|
||||
],
|
||||
"version": "==0.4.0"
|
||||
},
|
||||
"aniso8601": {
|
||||
"hashes": [
|
||||
"sha256:513d2b6637b7853806ae79ffaca6f3e8754bdd547048f5ccc1420aec4b714f1e",
|
||||
"sha256:d10a4bf949f619f719b227ef5386e31f49a2b6d453004b21f02661ccc8670c7b"
|
||||
],
|
||||
"version": "==7.0.0"
|
||||
},
|
||||
"apispec": {
|
||||
"hashes": [
|
||||
"sha256:5fdaa1173b32515cc83f9d413a49a6c37fafc2b87f6b40e95923d3e85f0942c5",
|
||||
"sha256:9e88c51517a6515612e818459f61c1bc06c00f2313e5187828bdbabaa7461473"
|
||||
],
|
||||
"version": "==3.0.0"
|
||||
},
|
||||
"apistar": {
|
||||
"hashes": [
|
||||
"sha256:8da0d3f15748c8ed6e68914ba5b8f6dd5dff5afbe137950d07103575df0bce73"
|
||||
],
|
||||
"version": "==0.7.2"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50",
|
||||
"sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"
|
||||
],
|
||||
"version": "==2019.9.11"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
|
||||
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
|
||||
],
|
||||
"version": "==3.0.4"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
|
||||
"sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
|
||||
],
|
||||
"version": "==7.0"
|
||||
},
|
||||
"docopt": {
|
||||
"hashes": [
|
||||
"sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"
|
||||
],
|
||||
"version": "==0.6.2"
|
||||
},
|
||||
"graphene": {
|
||||
"hashes": [
|
||||
"sha256:09165f03e1591b76bf57b133482db9be6dac72c74b0a628d3c93182af9c5a896",
|
||||
"sha256:2cbe6d4ef15cfc7b7805e0760a0e5b80747161ce1b0f990dfdc0d2cf497c12f9"
|
||||
],
|
||||
"version": "==2.1.8"
|
||||
},
|
||||
"graphql-core": {
|
||||
"hashes": [
|
||||
"sha256:1488f2a5c2272dc9ba66e3042a6d1c30cea0db4c80bd1e911c6791ad6187d91b",
|
||||
"sha256:da64c472d720da4537a2e8de8ba859210b62841bd47a9be65ca35177f62fe0e4"
|
||||
],
|
||||
"version": "==2.2.1"
|
||||
},
|
||||
"graphql-relay": {
|
||||
"hashes": [
|
||||
"sha256:0e94201af4089e1f81f07d7bd8f84799768e39d70fa1ea16d1df505b46cc6335",
|
||||
"sha256:75aa0758971e252964cb94068a4decd472d2a8295229f02189e3cbca1f10dbb5",
|
||||
"sha256:7fa74661246e826ef939ee92e768f698df167a7617361ab399901eaebf80dce6"
|
||||
],
|
||||
"version": "==2.0.0"
|
||||
},
|
||||
"graphql-server-core": {
|
||||
"hashes": [
|
||||
"sha256:e5f82add4b3d5580aa1f1e7d9f00e944ad3abe1b65eb337e611d6a77cc20f231"
|
||||
],
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"h11": {
|
||||
"hashes": [
|
||||
"sha256:acca6a44cb52a32ab442b1779adf0875c443c689e9e028f8d831a3769f9c5208",
|
||||
"sha256:f2b1ca39bfed357d1f19ac732913d5f9faa54a5062eca7d2ec3a916cfb7ae4c7"
|
||||
],
|
||||
"version": "==0.8.1"
|
||||
},
|
||||
"httptools": {
|
||||
"hashes": [
|
||||
"sha256:e00cbd7ba01ff748e494248183abc6e153f49181169d8a3d41bb49132ca01dfc"
|
||||
],
|
||||
"version": "==0.0.13"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
|
||||
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
|
||||
],
|
||||
"version": "==2.8"
|
||||
},
|
||||
"itsdangerous": {
|
||||
"hashes": [
|
||||
"sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19",
|
||||
"sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"
|
||||
],
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
"sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f",
|
||||
"sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de"
|
||||
],
|
||||
"version": "==2.10.3"
|
||||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
|
||||
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
|
||||
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
|
||||
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
|
||||
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
|
||||
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
|
||||
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
|
||||
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
|
||||
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
|
||||
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
|
||||
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
|
||||
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
|
||||
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
|
||||
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
|
||||
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
|
||||
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
|
||||
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
|
||||
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
|
||||
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
|
||||
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
|
||||
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
|
||||
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
|
||||
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
|
||||
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
|
||||
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
|
||||
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
|
||||
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
|
||||
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"
|
||||
],
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"marshmallow": {
|
||||
"hashes": [
|
||||
"sha256:077b4612f5d3b9333b736fdc6b963d2b46d409070f44ff3e6c4109645c673e83",
|
||||
"sha256:9a2f3e8ea5f530a9664e882d7d04b58650f46190178b2264c72b7d20399d28f0"
|
||||
],
|
||||
"version": "==3.2.1"
|
||||
},
|
||||
"promise": {
|
||||
"hashes": [
|
||||
"sha256:2ebbfc10b7abf6354403ed785fe4f04b9dfd421eb1a474ac8d187022228332af",
|
||||
"sha256:348f5f6c3edd4fd47c9cd65aed03ac1b31136d375aa63871a57d3e444c85655c"
|
||||
],
|
||||
"version": "==2.2.1"
|
||||
},
|
||||
"python-multipart": {
|
||||
"hashes": [
|
||||
"sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"
|
||||
],
|
||||
"version": "==0.0.5"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
"sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9",
|
||||
"sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4",
|
||||
"sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8",
|
||||
"sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696",
|
||||
"sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34",
|
||||
"sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9",
|
||||
"sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73",
|
||||
"sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299",
|
||||
"sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b",
|
||||
"sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae",
|
||||
"sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681",
|
||||
"sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41",
|
||||
"sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"
|
||||
],
|
||||
"version": "==5.1.2"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
|
||||
"sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
|
||||
],
|
||||
"version": "==2.22.0"
|
||||
},
|
||||
"requests-toolbelt": {
|
||||
"hashes": [
|
||||
"sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f",
|
||||
"sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"
|
||||
],
|
||||
"version": "==0.9.1"
|
||||
},
|
||||
"responder": {
|
||||
"editable": true,
|
||||
"path": "."
|
||||
},
|
||||
"rfc3986": {
|
||||
"hashes": [
|
||||
"sha256:0344d0bd428126ce554e7ca2b61787b6a28d2bbd19fc70ed2dd85efe31176405",
|
||||
"sha256:df4eba676077cefb86450c8f60121b9ae04b94f65f85b69f3f731af0516b7b18"
|
||||
],
|
||||
"version": "==1.3.2"
|
||||
},
|
||||
"rx": {
|
||||
"hashes": [
|
||||
"sha256:13a1d8d9e252625c173dc795471e614eadfe1cf40ffc684e08b8fff0d9748c23",
|
||||
"sha256:7357592bc7e881a95e0c2013b73326f704953301ab551fbc8133a6fadab84105"
|
||||
],
|
||||
"version": "==1.6.1"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
|
||||
"sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
|
||||
],
|
||||
"version": "==1.12.0"
|
||||
},
|
||||
"starlette": {
|
||||
"hashes": [
|
||||
"sha256:e41ef52e711a82ef95c195674e5d8d41c75c6b1d6f5a275637eedd4cc2150a7f"
|
||||
],
|
||||
"version": "==0.12.10"
|
||||
},
|
||||
"typesystem": {
|
||||
"hashes": [
|
||||
"sha256:ba2bd10f1c5844d08dd8841e777bdee55bfca569bf21cb96cd0f91e0a4f66cd8"
|
||||
],
|
||||
"version": "==0.2.4"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:3de946ffbed6e6746608990594d08faac602528ac7015ac28d33cee6a45b7398",
|
||||
"sha256:9a107b99a5393caf59c7aa3c1249c16e6879447533d0887f4336dde834c7be86"
|
||||
],
|
||||
"version": "==1.25.6"
|
||||
},
|
||||
"uvicorn": {
|
||||
"hashes": [
|
||||
"sha256:8aa44f9d9c3082ef693950387ea25d376e32944df6d4071dbd8edc3c25a40c74"
|
||||
],
|
||||
"version": "==0.8.6"
|
||||
},
|
||||
"uvloop": {
|
||||
"hashes": [
|
||||
"sha256:0fcd894f6fc3226a962ee7ad895c4f52e3f5c3c55098e21efb17c071849a0573",
|
||||
"sha256:2f31de1742c059c96cb76b91c5275b22b22b965c886ee1fced093fa27dde9e64",
|
||||
"sha256:459e4649fcd5ff719523de33964aa284898e55df62761e7773d088823ccbd3e0",
|
||||
"sha256:67867aafd6e0bc2c30a079603a85d83b94f23c5593b3cc08ec7e58ac18bf48e5",
|
||||
"sha256:8c200457e6847f28d8bb91c5e5039d301716f5f2fce25646f5fb3fd65eda4a26",
|
||||
"sha256:958906b9ca39eb158414fbb7d6b8ef1b7aee4db5c8e8e5d00fcbb69a1ce9dca7",
|
||||
"sha256:ac1dca3d8f3ef52806059e81042ee397ac939e5a86c8a3cea55d6b087db66115",
|
||||
"sha256:b284c22d8938866318e3b9d178142b8be316c52d16fcfe1560685a686718a021",
|
||||
"sha256:c48692bf4587ce281d641087658eca275a5ad3b63c78297bbded96570ae9ce8f",
|
||||
"sha256:fefc3b2b947c99737c348887db2c32e539160dcbeb7af9aa6b53db7a283538fe"
|
||||
],
|
||||
"version": "==0.12.2"
|
||||
},
|
||||
"websockets": {
|
||||
"hashes": [
|
||||
"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": "==7.0"
|
||||
},
|
||||
"whitenoise": {
|
||||
"hashes": [
|
||||
"sha256:22f79cf8f1f509639330f93886acaece8ec5ac5e9600c3b981d33c34e8a42dfd",
|
||||
"sha256:6dfea214b7c12efd689007abf9afa87a426586e9dbc051873ad2c8e535e2a1ac"
|
||||
],
|
||||
"version": "==4.1.4"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
"alabaster": {
|
||||
"hashes": [
|
||||
"sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359",
|
||||
"sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"
|
||||
],
|
||||
"version": "==0.7.12"
|
||||
},
|
||||
"appdirs": {
|
||||
"hashes": [
|
||||
"sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92",
|
||||
"sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"
|
||||
],
|
||||
"version": "==1.4.3"
|
||||
},
|
||||
"atomicwrites": {
|
||||
"hashes": [
|
||||
"sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4",
|
||||
"sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"
|
||||
],
|
||||
"version": "==1.3.0"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
|
||||
"sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
|
||||
],
|
||||
"version": "==19.3.0"
|
||||
},
|
||||
"babel": {
|
||||
"hashes": [
|
||||
"sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab",
|
||||
"sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28"
|
||||
],
|
||||
"version": "==2.7.0"
|
||||
},
|
||||
"black": {
|
||||
"hashes": [
|
||||
"sha256:09a9dcb7c46ed496a9850b76e4e825d6049ecd38b611f1224857a79bd985a8cf",
|
||||
"sha256:68950ffd4d9169716bcb8719a56c07a2f4485354fec061cdd5910aa07369731c"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==19.3b0"
|
||||
},
|
||||
"bleach": {
|
||||
"hashes": [
|
||||
"sha256:213336e49e102af26d9cde77dd2d0397afabc5a6bf2fed985dc35b5d1e285a16",
|
||||
"sha256:3fdf7f77adcf649c9911387df51254b813185e32b2c6619f690b593a617e19fa"
|
||||
],
|
||||
"version": "==3.1.0"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50",
|
||||
"sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"
|
||||
],
|
||||
"version": "==2019.9.11"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
|
||||
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
|
||||
],
|
||||
"version": "==3.0.4"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
|
||||
"sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
|
||||
],
|
||||
"version": "==7.0"
|
||||
},
|
||||
"coverage": {
|
||||
"hashes": [
|
||||
"sha256:17a417c691de3fc88de027832267313e5ed2b2ea3956745b562c4c389e44d05b",
|
||||
"sha256:24307e67ebd9dc06fcbab9b7fef87412a97746c1baabb04ed8a93d5c2ccfe5ba",
|
||||
"sha256:2a5d44a9d8426bd3699123864e63f008dc8dea9df22d5216a141a25d4670f22c",
|
||||
"sha256:3726b8f5461e103a40e380f52b4b4ccdf2eda55d5d72f037cee43627992b4462",
|
||||
"sha256:39dd15bbc4880a64399e180925bbc21c0c316a3065f6455d2512039f5cb59b94",
|
||||
"sha256:3bb121f5dd156aab4fba2ebad6b0ad605bc5dc305931140dc614b101aa9d81ed",
|
||||
"sha256:3bfdea9226eaed97736c973a7d6d0bbf9e1c1f1c7391c8e9c2bb2d0dbae49156",
|
||||
"sha256:43be906a16239c1aa9f3742e3e6b0a5dd24781a13ce401f063262e9b4e93b69f",
|
||||
"sha256:4a54cac1b39b2925041a41bcd1f191898fe401618627d7c3abf127c32a1c6dd1",
|
||||
"sha256:4e58d65b90d6f26b3ccca7cf0fe573ef847347b8734af596a087a21eebb681f5",
|
||||
"sha256:50229727d9baf0cd7f5ee6b194bf9dea708e9a20823d93f9e04d710b0a60e757",
|
||||
"sha256:5141cdb010e9cd6939e37b8c2769d535cb535d80ef94f927c8a306f2e05a4736",
|
||||
"sha256:748ba2b950425b9aef9d1bde2d6af7023585505016bd634e578f76ada4a30465",
|
||||
"sha256:75e635bc6730c88b04421b25a0afc47b9b80efc1ed57630839196eb475722e50",
|
||||
"sha256:78556f51dbfb33f18794eee29a4a8542fd2e301aa0d072653930793974dced03",
|
||||
"sha256:7de17133509210ecc256535bab2f9a5547f3016c44f984fe12b4c10d81a4623f",
|
||||
"sha256:83bf376555898fe2dc50d111a34b0152b504e454ed1e13cdcda6e5d50ba0ed5b",
|
||||
"sha256:87730b5e4c3a42674fe8f0ecbb0d556c59c7e12b11a65c2178f2787252a80dfd",
|
||||
"sha256:9bb7819c020c20c6200764879f0b10b323d6d4719aa7b0ae316c9e35730f9e2d",
|
||||
"sha256:9c825788acb13d49ac20455433f3b862029aa497e97faba8c998555a042a6b91",
|
||||
"sha256:b2bb4941c8838fc9ea2fca3c52e6dd865d39bbbc014bde249161bf8fcccf2152",
|
||||
"sha256:c1b44c6c680f137910cb0f5481a2ae9899787ca7019f110a3708d9e99df941be",
|
||||
"sha256:c52c2bc67bd3ff8db685f7c5f03e34a95bddd58a535630161f28d1c485d61e22",
|
||||
"sha256:d6845e46338695c571759be1c770b013c477111e785b26151ec9feb6cd063543",
|
||||
"sha256:e292b32dfc80d9f271af2d52df95455248322156e764763c4bfb2385b2e33533"
|
||||
],
|
||||
"version": "==5.0a8"
|
||||
},
|
||||
"docutils": {
|
||||
"hashes": [
|
||||
"sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0",
|
||||
"sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827",
|
||||
"sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99"
|
||||
],
|
||||
"version": "==0.15.2"
|
||||
},
|
||||
"entrypoints": {
|
||||
"hashes": [
|
||||
"sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19",
|
||||
"sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"
|
||||
],
|
||||
"version": "==0.3"
|
||||
},
|
||||
"flake8": {
|
||||
"hashes": [
|
||||
"sha256:19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548",
|
||||
"sha256:8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.7.8"
|
||||
},
|
||||
"flask": {
|
||||
"hashes": [
|
||||
"sha256:13f9f196f330c7c2c5d7a5cf91af894110ca0215ac051b5844701f2bfd934d52",
|
||||
"sha256:45eb5a6fd193d6cf7e0cf5d8a5b31f83d5faae0293695626f539a823e93b13f6"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
|
||||
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
|
||||
],
|
||||
"version": "==2.8"
|
||||
},
|
||||
"imagesize": {
|
||||
"hashes": [
|
||||
"sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8",
|
||||
"sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5"
|
||||
],
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"importlib-metadata": {
|
||||
"hashes": [
|
||||
"sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26",
|
||||
"sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"
|
||||
],
|
||||
"markers": "python_version < '3.8'",
|
||||
"version": "==0.23"
|
||||
},
|
||||
"itsdangerous": {
|
||||
"hashes": [
|
||||
"sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19",
|
||||
"sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"
|
||||
],
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
"sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f",
|
||||
"sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de"
|
||||
],
|
||||
"version": "==2.10.3"
|
||||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
|
||||
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
|
||||
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
|
||||
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
|
||||
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
|
||||
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
|
||||
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
|
||||
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
|
||||
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
|
||||
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
|
||||
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
|
||||
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
|
||||
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
|
||||
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
|
||||
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
|
||||
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
|
||||
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
|
||||
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
|
||||
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
|
||||
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
|
||||
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
|
||||
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
|
||||
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
|
||||
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
|
||||
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
|
||||
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
|
||||
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
|
||||
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"
|
||||
],
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"marshmallow": {
|
||||
"hashes": [
|
||||
"sha256:077b4612f5d3b9333b736fdc6b963d2b46d409070f44ff3e6c4109645c673e83",
|
||||
"sha256:9a2f3e8ea5f530a9664e882d7d04b58650f46190178b2264c72b7d20399d28f0"
|
||||
],
|
||||
"version": "==3.2.1"
|
||||
},
|
||||
"mccabe": {
|
||||
"hashes": [
|
||||
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
|
||||
"sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
|
||||
],
|
||||
"version": "==0.6.1"
|
||||
},
|
||||
"more-itertools": {
|
||||
"hashes": [
|
||||
"sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832",
|
||||
"sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"
|
||||
],
|
||||
"version": "==7.2.0"
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47",
|
||||
"sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108"
|
||||
],
|
||||
"version": "==19.2"
|
||||
},
|
||||
"pkginfo": {
|
||||
"hashes": [
|
||||
"sha256:7424f2c8511c186cd5424bbf31045b77435b37a8d604990b79d4e70d741148bb",
|
||||
"sha256:a6d9e40ca61ad3ebd0b72fbadd4fba16e4c0e4df0428c041e01e06eb6ee71f32"
|
||||
],
|
||||
"version": "==1.5.0.1"
|
||||
},
|
||||
"pluggy": {
|
||||
"hashes": [
|
||||
"sha256:0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6",
|
||||
"sha256:fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34"
|
||||
],
|
||||
"version": "==0.13.0"
|
||||
},
|
||||
"py": {
|
||||
"hashes": [
|
||||
"sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa",
|
||||
"sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"
|
||||
],
|
||||
"version": "==1.8.0"
|
||||
},
|
||||
"pycodestyle": {
|
||||
"hashes": [
|
||||
"sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56",
|
||||
"sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"
|
||||
],
|
||||
"version": "==2.5.0"
|
||||
},
|
||||
"pyflakes": {
|
||||
"hashes": [
|
||||
"sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0",
|
||||
"sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"
|
||||
],
|
||||
"version": "==2.1.1"
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127",
|
||||
"sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"
|
||||
],
|
||||
"version": "==2.4.2"
|
||||
},
|
||||
"pyparsing": {
|
||||
"hashes": [
|
||||
"sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80",
|
||||
"sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4"
|
||||
],
|
||||
"version": "==2.4.2"
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:7e4800063ccfc306a53c461442526c5571e1462f61583506ce97e4da6a1d88c8",
|
||||
"sha256:ca563435f4941d0cb34767301c27bc65c510cb82e90b9ecf9cb52dc2c63caaa0"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==5.2.1"
|
||||
},
|
||||
"pytest-cov": {
|
||||
"hashes": [
|
||||
"sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b",
|
||||
"sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.8.1"
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d",
|
||||
"sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"
|
||||
],
|
||||
"version": "==2019.3"
|
||||
},
|
||||
"readme-renderer": {
|
||||
"hashes": [
|
||||
"sha256:bb16f55b259f27f75f640acf5e00cf897845a8b3e4731b5c1a436e4b8529202f",
|
||||
"sha256:c8532b79afc0375a85f10433eca157d6b50f7d6990f337fa498c96cd4bfc203d"
|
||||
],
|
||||
"version": "==24.0"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
|
||||
"sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
|
||||
],
|
||||
"version": "==2.22.0"
|
||||
},
|
||||
"requests-toolbelt": {
|
||||
"hashes": [
|
||||
"sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f",
|
||||
"sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"
|
||||
],
|
||||
"version": "==0.9.1"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
|
||||
"sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
|
||||
],
|
||||
"version": "==1.12.0"
|
||||
},
|
||||
"snowballstemmer": {
|
||||
"hashes": [
|
||||
"sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0",
|
||||
"sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"
|
||||
],
|
||||
"version": "==2.0.0"
|
||||
},
|
||||
"sphinx": {
|
||||
"hashes": [
|
||||
"sha256:0d586b0f8c2fc3cc6559c5e8fd6124628110514fda0e5d7c82e682d749d2e845",
|
||||
"sha256:839a3ed6f6b092bb60f492024489cc9e6991360fb9f52ed6361acd510d261069"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.2.0"
|
||||
},
|
||||
"sphinxcontrib-applehelp": {
|
||||
"hashes": [
|
||||
"sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897",
|
||||
"sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d"
|
||||
],
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"sphinxcontrib-devhelp": {
|
||||
"hashes": [
|
||||
"sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34",
|
||||
"sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981"
|
||||
],
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"sphinxcontrib-htmlhelp": {
|
||||
"hashes": [
|
||||
"sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422",
|
||||
"sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7"
|
||||
],
|
||||
"version": "==1.0.2"
|
||||
},
|
||||
"sphinxcontrib-jsmath": {
|
||||
"hashes": [
|
||||
"sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178",
|
||||
"sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"
|
||||
],
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"sphinxcontrib-qthelp": {
|
||||
"hashes": [
|
||||
"sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20",
|
||||
"sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f"
|
||||
],
|
||||
"version": "==1.0.2"
|
||||
},
|
||||
"sphinxcontrib-serializinghtml": {
|
||||
"hashes": [
|
||||
"sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227",
|
||||
"sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768"
|
||||
],
|
||||
"version": "==1.1.3"
|
||||
},
|
||||
"toml": {
|
||||
"hashes": [
|
||||
"sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
|
||||
"sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
|
||||
],
|
||||
"version": "==0.10.0"
|
||||
},
|
||||
"tqdm": {
|
||||
"hashes": [
|
||||
"sha256:abc25d0ce2397d070ef07d8c7e706aede7920da163c64997585d42d3537ece3d",
|
||||
"sha256:dd3fcca8488bb1d416aa7469d2f277902f26260c45aa86b667b074cd44b3b115"
|
||||
],
|
||||
"version": "==4.36.1"
|
||||
},
|
||||
"twine": {
|
||||
"hashes": [
|
||||
"sha256:5319dd3e02ac73fcddcd94f035b9631589ab5d23e1f4699d57365199d85261e1",
|
||||
"sha256:9fe7091715c7576df166df8ef6654e61bada39571783f2fd415bdcba867c6993"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.0.0"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:3de946ffbed6e6746608990594d08faac602528ac7015ac28d33cee6a45b7398",
|
||||
"sha256:9a107b99a5393caf59c7aa3c1249c16e6879447533d0887f4336dde834c7be86"
|
||||
],
|
||||
"version": "==1.25.6"
|
||||
},
|
||||
"wcwidth": {
|
||||
"hashes": [
|
||||
"sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e",
|
||||
"sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c"
|
||||
],
|
||||
"version": "==0.1.7"
|
||||
},
|
||||
"webencodings": {
|
||||
"hashes": [
|
||||
"sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78",
|
||||
"sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"
|
||||
],
|
||||
"version": "==0.5.1"
|
||||
},
|
||||
"werkzeug": {
|
||||
"hashes": [
|
||||
"sha256:7280924747b5733b246fe23972186c6b348f9ae29724135a6dfc1e53cea433e7",
|
||||
"sha256:e5f4a1f98b52b18a93da705a7458e55afb26f32bff83ff5d19189f92462d65c4"
|
||||
],
|
||||
"version": "==0.16.0"
|
||||
},
|
||||
"zipp": {
|
||||
"hashes": [
|
||||
"sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e",
|
||||
"sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"
|
||||
],
|
||||
"version": "==0.6.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +1,97 @@
|
||||
# Responder: a familiar HTTP Service Framework for Python
|
||||
|
||||
[](https://travis-ci.org/taoufik07/responder)
|
||||
[](https://responder.readthedocs.io/en/latest/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://github.com/taoufik07/responder/graphs/contributors)
|
||||
[](https://github.com/kennethreitz/responder/actions/workflows/test.yaml)
|
||||
[](https://github.com/kennethreitz/responder/actions/workflows/docs.yaml)
|
||||
[](https://responder.kennethreitz.org/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://pepy.tech/project/responder)
|
||||
[](https://github.com/kennethreitz/responder/graphs/contributors)
|
||||
[](https://pypi.org/project/responder/)
|
||||
|
||||
[](https://responder.readthedocs.io)
|
||||
[](https://responder.readthedocs.io)
|
||||
|
||||
Responder is powered by [Starlette](https://www.starlette.io/).
|
||||
[View documentation](https://responder.readthedocs.io).
|
||||
|
||||
Powered by [Starlette](https://www.starlette.io/). That `async` declaration is optional. [View documentation](https://responder.readthedocs.io).
|
||||
|
||||
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.
|
||||
|
||||
Responder gets you an ASGI app, with a production static files server pre-installed,
|
||||
Jinja templating, and a production webserver based on uvloop, automatically serving
|
||||
up requests with gzip compression.
|
||||
The `async` declaration within the example program is optional.
|
||||
|
||||
## Testimonials
|
||||
|
||||
> "Pleasantly very taken with python-responder. [@kennethreitz](https://twitter.com/kennethreitz) at his absolute best." —Rudraksh M.K.
|
||||
> "Pleasantly very taken with python-responder.
|
||||
> [@kennethreitz](https://x.com/kennethreitz42) at his absolute best." —Rudraksh
|
||||
> M.K.
|
||||
|
||||
> "ASGI is going to enable all sorts of new high-performance web services. It's awesome to see Responder starting to take advantage of that." — Tom Christie author of [Django REST Framework](https://www.django-rest-framework.org/)
|
||||
|
||||
> "I love that you are exploring new patterns. Go go go!" — Danny Greenfield, author of [Two Scoops of Django]()
|
||||
> "ASGI is going to enable all sorts of new high-performance web services. It's awesome
|
||||
> to see Responder starting to take advantage of that." — Tom Christie author of
|
||||
> [Django REST Framework](https://www.django-rest-framework.org/)
|
||||
|
||||
> "I love that you are exploring new patterns. Go go go!" — Danny Greenfield, author of
|
||||
> [Two Scoops of Django](https://www.feldroy.com/two-scoops-press#two-scoops-of-django)
|
||||
|
||||
## More Examples
|
||||
|
||||
See [the documentation's feature tour](https://responder.readthedocs.io/en/latest/tour.html) for more details on features available in Responder.
|
||||
|
||||
See
|
||||
[the documentation's feature tour](https://responder.readthedocs.io/tour.html)
|
||||
for more details on features available in Responder.
|
||||
|
||||
# Installing Responder
|
||||
|
||||
Install the stable release:
|
||||
Install the most recent stable release:
|
||||
|
||||
pip install --upgrade responder
|
||||
|
||||
$ pipenv install responder
|
||||
✨🍰✨
|
||||
Alternatively, install directly from the repository:
|
||||
|
||||
pip install 'responder @ git+https://github.com/kennethreitz/responder.git'
|
||||
|
||||
Or, install from the development branch:
|
||||
|
||||
$ pipenv install -e git+https://github.com/taoufik07/responder.git#egg=responder
|
||||
|
||||
Only **Python 3.6+** is supported.
|
||||
|
||||
Responder supports **Python 3.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.
|
||||
The primary concept here is to bring the niceties from both Flask and Falcon and
|
||||
unify them into a single framework. You'll find a familiar API with a clean,
|
||||
Pythonic design.
|
||||
|
||||
- Setting `resp.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).
|
||||
- Setting `resp.content` sends back bytes.
|
||||
- Use `resp.file("path")` to serve files with automatic content-type detection.
|
||||
- Case-insensitive `req.headers` dict.
|
||||
- `resp.status_code`, `req.method`, `req.url`, and other familiar friends.
|
||||
|
||||
## Features
|
||||
|
||||
## Ideas
|
||||
- Flask-style route expressions with f-string syntax and type convertors
|
||||
(`str`, `int`, `float`, `uuid`, `path`).
|
||||
- HTTP method filtering: `@api.route("/data", methods=["GET"])`.
|
||||
- Every request and response is passed into each view and mutated — including
|
||||
`response.media` for JSON/YAML content negotiation.
|
||||
- Built-in test client powered by Starlette's TestClient.
|
||||
- Mount other WSGI/ASGI apps at subroutes.
|
||||
- Automatic gzip compression.
|
||||
- Class-based views with `on_get`, `on_post`, `on_request` methods.
|
||||
- GraphQL support via Graphene with `api.graphql()`.
|
||||
- OpenAPI schema generation with interactive docs.
|
||||
- Lifespan context managers for startup/shutdown.
|
||||
- Custom exception handlers.
|
||||
- Before-request hooks with short-circuit support.
|
||||
- Cookie-based sessions.
|
||||
- WebSocket support.
|
||||
- Background tasks.
|
||||
- Production uvicorn server built-in.
|
||||
|
||||
- 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.
|
||||
- **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 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.
|
||||
- Provide an official way to run webpack.
|
||||
## Development
|
||||
|
||||
See [Development Sandbox](https://responder.kennethreitz.org/sandbox.html).
|
||||
|
||||
## Supported by
|
||||
|
||||
[](https://jb.gg/OpenSourceSupport)
|
||||
|
||||
Special thanks to the kind people at JetBrains s.r.o. for supporting us with
|
||||
excellent development tooling.
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
alabaster==0.7.12
|
||||
appdirs==1.4.3
|
||||
atomicwrites==1.2.1
|
||||
attrs==18.2.0
|
||||
babel==2.6.0
|
||||
black==18.9b0
|
||||
bleach==3.0.2
|
||||
certifi==2018.8.24
|
||||
cffi==1.11.5
|
||||
chardet==3.0.4
|
||||
click==7.0
|
||||
cmarkgfm==0.4.2
|
||||
colorama==0.4.0 ; sys_platform == 'win32'
|
||||
docutils==0.14
|
||||
flake8==3.5.0
|
||||
flask==1.0.2
|
||||
future==0.16.0
|
||||
idna==2.7
|
||||
imagesize==1.1.0
|
||||
itsdangerous==0.24
|
||||
jinja2==2.10
|
||||
markupsafe==1.0
|
||||
mccabe==0.6.1
|
||||
more-itertools==4.3.0
|
||||
packaging==18.0
|
||||
pkginfo==1.4.2
|
||||
pluggy==0.7.1
|
||||
py==1.7.0
|
||||
pycodestyle==2.3.1
|
||||
pycparser==2.19
|
||||
pyflakes==1.6.0
|
||||
pygments==2.2.0
|
||||
pyparsing==2.2.2
|
||||
pytest==3.8.2
|
||||
pytz==2018.5
|
||||
readme-renderer==22.0
|
||||
requests-toolbelt==0.8.0
|
||||
requests==2.19.1
|
||||
six==1.11.0
|
||||
snowballstemmer==1.2.1
|
||||
sphinx==1.8.1
|
||||
sphinxcontrib-websupport==1.1.0
|
||||
toml==0.10.0
|
||||
tqdm==4.26.0
|
||||
twine==1.12.1
|
||||
urllib3==1.23
|
||||
webencodings==0.5.1
|
||||
werkzeug==0.15.5
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Hide module name and default value for environment variable section */
|
||||
div[id$='environment-variables'] code.descclassname {
|
||||
display: none;
|
||||
div[id$="environment-variables"] code.descclassname {
|
||||
display: none;
|
||||
}
|
||||
div[id$='environment-variables'] em.property {
|
||||
display: none;
|
||||
div[id$="environment-variables"] em.property {
|
||||
display: none;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+142
-132
@@ -10,142 +10,152 @@
|
||||
*/
|
||||
|
||||
var Konami = function (callback) {
|
||||
var konami = {
|
||||
addEvent: function (obj, type, fn, ref_obj) {
|
||||
if (obj.addEventListener)
|
||||
obj.addEventListener(type, fn, false);
|
||||
else if (obj.attachEvent) {
|
||||
// IE
|
||||
obj["e" + type + fn] = fn;
|
||||
obj[type + fn] = function () {
|
||||
obj["e" + type + fn](window.event, ref_obj);
|
||||
}
|
||||
obj.attachEvent("on" + type, obj[type + fn]);
|
||||
}
|
||||
},
|
||||
removeEvent: function (obj, eventName, eventCallback) {
|
||||
if (obj.removeEventListener) {
|
||||
obj.removeEventListener(eventName, eventCallback);
|
||||
} else if (obj.attachEvent) {
|
||||
obj.detachEvent(eventName);
|
||||
}
|
||||
},
|
||||
input: "",
|
||||
pattern: "38384040373937396665",
|
||||
keydownHandler: function (e, ref_obj) {
|
||||
if (ref_obj) {
|
||||
konami = ref_obj;
|
||||
} // IE
|
||||
konami.input += e ? e.keyCode : event.keyCode;
|
||||
if (konami.input.length > konami.pattern.length) {
|
||||
konami.input = konami.input.substr((konami.input.length - konami.pattern.length));
|
||||
}
|
||||
if (konami.input === konami.pattern) {
|
||||
konami.code(konami._currentLink);
|
||||
konami.input = '';
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
load: function (link) {
|
||||
this._currentLink = link;
|
||||
this.addEvent(document, "keydown", this.keydownHandler, this);
|
||||
this.iphone.load(link);
|
||||
},
|
||||
unload: function () {
|
||||
this.removeEvent(document, 'keydown', this.keydownHandler);
|
||||
this.iphone.unload();
|
||||
},
|
||||
code: function (link) {
|
||||
window.location = link
|
||||
},
|
||||
iphone: {
|
||||
start_x: 0,
|
||||
start_y: 0,
|
||||
stop_x: 0,
|
||||
stop_y: 0,
|
||||
tap: false,
|
||||
capture: false,
|
||||
orig_keys: "",
|
||||
keys: ["UP", "UP", "DOWN", "DOWN", "LEFT", "RIGHT", "LEFT", "RIGHT", "TAP", "TAP"],
|
||||
input: [],
|
||||
code: function (link) {
|
||||
konami.code(link);
|
||||
},
|
||||
touchmoveHandler: function (e) {
|
||||
if (e.touches.length === 1 && konami.iphone.capture === true) {
|
||||
var touch = e.touches[0];
|
||||
konami.iphone.stop_x = touch.pageX;
|
||||
konami.iphone.stop_y = touch.pageY;
|
||||
konami.iphone.tap = false;
|
||||
konami.iphone.capture = false;
|
||||
konami.iphone.check_direction();
|
||||
}
|
||||
},
|
||||
touchendHandler: function () {
|
||||
konami.iphone.input.push(konami.iphone.check_direction());
|
||||
|
||||
if (konami.iphone.input.length > konami.iphone.keys.length) konami.iphone.input.shift();
|
||||
|
||||
if (konami.iphone.input.length === konami.iphone.keys.length) {
|
||||
var match = true;
|
||||
for (var i = 0; i < konami.iphone.keys.length; i++) {
|
||||
if (konami.iphone.input[i] !== konami.iphone.keys[i]) {
|
||||
match = false;
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
konami.iphone.code(konami._currentLink);
|
||||
}
|
||||
}
|
||||
},
|
||||
touchstartHandler: function (e) {
|
||||
konami.iphone.start_x = e.changedTouches[0].pageX;
|
||||
konami.iphone.start_y = e.changedTouches[0].pageY;
|
||||
konami.iphone.tap = true;
|
||||
konami.iphone.capture = true;
|
||||
},
|
||||
load: function (link) {
|
||||
this.orig_keys = this.keys;
|
||||
konami.addEvent(document, "touchmove", this.touchmoveHandler);
|
||||
konami.addEvent(document, "touchend", this.touchendHandler, false);
|
||||
konami.addEvent(document, "touchstart", this.touchstartHandler);
|
||||
},
|
||||
unload: function () {
|
||||
konami.removeEvent(document, 'touchmove', this.touchmoveHandler);
|
||||
konami.removeEvent(document, 'touchend', this.touchendHandler);
|
||||
konami.removeEvent(document, 'touchstart', this.touchstartHandler);
|
||||
},
|
||||
check_direction: function () {
|
||||
x_magnitude = Math.abs(this.start_x - this.stop_x);
|
||||
y_magnitude = Math.abs(this.start_y - this.stop_y);
|
||||
x = ((this.start_x - this.stop_x) < 0) ? "RIGHT" : "LEFT";
|
||||
y = ((this.start_y - this.stop_y) < 0) ? "DOWN" : "UP";
|
||||
result = (x_magnitude > y_magnitude) ? x : y;
|
||||
result = (this.tap === true) ? "TAP" : result;
|
||||
return result;
|
||||
}
|
||||
var konami = {
|
||||
addEvent: function (obj, type, fn, ref_obj) {
|
||||
if (obj.addEventListener) obj.addEventListener(type, fn, false);
|
||||
else if (obj.attachEvent) {
|
||||
// IE
|
||||
obj["e" + type + fn] = fn;
|
||||
obj[type + fn] = function () {
|
||||
obj["e" + type + fn](window.event, ref_obj);
|
||||
};
|
||||
obj.attachEvent("on" + type, obj[type + fn]);
|
||||
}
|
||||
},
|
||||
removeEvent: function (obj, eventName, eventCallback) {
|
||||
if (obj.removeEventListener) {
|
||||
obj.removeEventListener(eventName, eventCallback);
|
||||
} else if (obj.attachEvent) {
|
||||
obj.detachEvent(eventName);
|
||||
}
|
||||
},
|
||||
input: "",
|
||||
pattern: "38384040373937396665",
|
||||
keydownHandler: function (e, ref_obj) {
|
||||
if (ref_obj) {
|
||||
konami = ref_obj;
|
||||
} // IE
|
||||
konami.input += e ? e.keyCode : event.keyCode;
|
||||
if (konami.input.length > konami.pattern.length) {
|
||||
konami.input = konami.input.substr(konami.input.length - konami.pattern.length);
|
||||
}
|
||||
if (konami.input === konami.pattern) {
|
||||
konami.code(konami._currentLink);
|
||||
konami.input = "";
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
load: function (link) {
|
||||
this._currentLink = link;
|
||||
this.addEvent(document, "keydown", this.keydownHandler, this);
|
||||
this.iphone.load(link);
|
||||
},
|
||||
unload: function () {
|
||||
this.removeEvent(document, "keydown", this.keydownHandler);
|
||||
this.iphone.unload();
|
||||
},
|
||||
code: function (link) {
|
||||
window.location = link;
|
||||
},
|
||||
iphone: {
|
||||
start_x: 0,
|
||||
start_y: 0,
|
||||
stop_x: 0,
|
||||
stop_y: 0,
|
||||
tap: false,
|
||||
capture: false,
|
||||
orig_keys: "",
|
||||
keys: [
|
||||
"UP",
|
||||
"UP",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"LEFT",
|
||||
"RIGHT",
|
||||
"LEFT",
|
||||
"RIGHT",
|
||||
"TAP",
|
||||
"TAP",
|
||||
],
|
||||
input: [],
|
||||
code: function (link) {
|
||||
konami.code(link);
|
||||
},
|
||||
touchmoveHandler: function (e) {
|
||||
if (e.touches.length === 1 && konami.iphone.capture === true) {
|
||||
var touch = e.touches[0];
|
||||
konami.iphone.stop_x = touch.pageX;
|
||||
konami.iphone.stop_y = touch.pageY;
|
||||
konami.iphone.tap = false;
|
||||
konami.iphone.capture = false;
|
||||
konami.iphone.check_direction();
|
||||
}
|
||||
}
|
||||
},
|
||||
touchendHandler: function () {
|
||||
konami.iphone.input.push(konami.iphone.check_direction());
|
||||
|
||||
typeof callback === "string" && konami.load(callback);
|
||||
if (typeof callback === "function") {
|
||||
konami.code = callback;
|
||||
konami.load();
|
||||
}
|
||||
if (konami.iphone.input.length > konami.iphone.keys.length)
|
||||
konami.iphone.input.shift();
|
||||
|
||||
return konami;
|
||||
if (konami.iphone.input.length === konami.iphone.keys.length) {
|
||||
var match = true;
|
||||
for (var i = 0; i < konami.iphone.keys.length; i++) {
|
||||
if (konami.iphone.input[i] !== konami.iphone.keys[i]) {
|
||||
match = false;
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
konami.iphone.code(konami._currentLink);
|
||||
}
|
||||
}
|
||||
},
|
||||
touchstartHandler: function (e) {
|
||||
konami.iphone.start_x = e.changedTouches[0].pageX;
|
||||
konami.iphone.start_y = e.changedTouches[0].pageY;
|
||||
konami.iphone.tap = true;
|
||||
konami.iphone.capture = true;
|
||||
},
|
||||
load: function (link) {
|
||||
this.orig_keys = this.keys;
|
||||
konami.addEvent(document, "touchmove", this.touchmoveHandler);
|
||||
konami.addEvent(document, "touchend", this.touchendHandler, false);
|
||||
konami.addEvent(document, "touchstart", this.touchstartHandler);
|
||||
},
|
||||
unload: function () {
|
||||
konami.removeEvent(document, "touchmove", this.touchmoveHandler);
|
||||
konami.removeEvent(document, "touchend", this.touchendHandler);
|
||||
konami.removeEvent(document, "touchstart", this.touchstartHandler);
|
||||
},
|
||||
check_direction: function () {
|
||||
x_magnitude = Math.abs(this.start_x - this.stop_x);
|
||||
y_magnitude = Math.abs(this.start_y - this.stop_y);
|
||||
x = this.start_x - this.stop_x < 0 ? "RIGHT" : "LEFT";
|
||||
y = this.start_y - this.stop_y < 0 ? "DOWN" : "UP";
|
||||
result = x_magnitude > y_magnitude ? x : y;
|
||||
result = this.tap === true ? "TAP" : result;
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
typeof callback === "string" && konami.load(callback);
|
||||
if (typeof callback === "function") {
|
||||
konami.code = callback;
|
||||
konami.load();
|
||||
}
|
||||
|
||||
return konami;
|
||||
};
|
||||
|
||||
|
||||
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
|
||||
module.exports = Konami;
|
||||
if (typeof module !== "undefined" && typeof module.exports !== "undefined") {
|
||||
module.exports = Konami;
|
||||
} else {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
define([], function() {
|
||||
return Konami;
|
||||
});
|
||||
} else {
|
||||
window.Konami = Konami;
|
||||
}
|
||||
if (typeof define === "function" && define.amd) {
|
||||
define([], function () {
|
||||
return Konami;
|
||||
});
|
||||
} else {
|
||||
window.Konami = Konami;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<link rel="stylesheet" type="text/css" href="https://cloud.typography.com/7584432/7586812/css/fonts.css" />
|
||||
<script type="text/javascript">$('#searchbox').hide(0);</script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href="https://cloud.typography.com/7584432/7586812/css/fonts.css"
|
||||
/>
|
||||
<script async defer src="https://buttons.github.io/buttons.js"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
$("#searchbox").hide(0);
|
||||
</script>
|
||||
<!--Alabaster (krTheme++) Hacks -->
|
||||
|
||||
<!-- CSS Adjustments (I'm very picky.) -->
|
||||
@@ -39,9 +47,7 @@
|
||||
}
|
||||
|
||||
.method {
|
||||
|
||||
margin-bottom: 2em;
|
||||
|
||||
}
|
||||
|
||||
.si,
|
||||
@@ -80,8 +86,6 @@
|
||||
margin-top: -1em;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* "Quick Search" should be not be shown for now. */
|
||||
div#searchbox h3 {
|
||||
display: none;
|
||||
@@ -118,10 +122,12 @@
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-127383416-1"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() { dataLayer.push(arguments); }
|
||||
gtag('js', new Date());
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
|
||||
gtag('config', 'UA-127383416-1');
|
||||
gtag("config", "UA-127383416-1");
|
||||
</script>
|
||||
|
||||
<!-- There are no more hacks. -->
|
||||
@@ -130,7 +136,10 @@
|
||||
|
||||
<script src="{{ pathto('_static/', 1) }}/konami.js"></script>
|
||||
<script>
|
||||
var easter_egg = new Konami('https://www.myfortunecookie.co.uk/fortunes/' + (Math.floor(Math.random() * 152) + 1));
|
||||
var easter_egg = new Konami(
|
||||
"https://www.myfortunecookie.co.uk/fortunes/" +
|
||||
(Math.floor(Math.random() * 152) + 1)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -140,67 +149,94 @@
|
||||
</style>
|
||||
|
||||
<!-- GitHub Logo -->
|
||||
<a href="https://github.com/kennethreitz/responder" class="github-corner" aria-label="View source on GitHub">
|
||||
<svg width="80" height="80" viewBox="0 0 250 250" style="fill:#151513; color:#fff; position: absolute; top: 0; border: 0; right: 0;"
|
||||
aria-hidden="true">
|
||||
<a
|
||||
href="https://github.com/kennethreitz/responder"
|
||||
class="github-corner"
|
||||
aria-label="View source on GitHub"
|
||||
>
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 250 250"
|
||||
style="fill: #151513; color: #fff; position: absolute; top: 0; border: 0; right: 0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
|
||||
<path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2"
|
||||
fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path>
|
||||
<path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
|
||||
fill="currentColor" class="octo-body"></path>
|
||||
<path
|
||||
d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2"
|
||||
fill="currentColor"
|
||||
style="transform-origin: 130px 106px"
|
||||
class="octo-arm"
|
||||
></path>
|
||||
<path
|
||||
d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
|
||||
fill="currentColor"
|
||||
class="octo-body"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
<style>
|
||||
.github-corner:hover .octo-arm {
|
||||
animation: octocat-wave 560ms ease-in-out
|
||||
animation: octocat-wave 560ms ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes octocat-wave {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0)
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
20%,
|
||||
60% {
|
||||
transform: rotate(-25deg)
|
||||
transform: rotate(-25deg);
|
||||
}
|
||||
|
||||
40%,
|
||||
80% {
|
||||
transform: rotate(10deg)
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:500px) {
|
||||
@media (max-width: 500px) {
|
||||
.github-corner:hover .octo-arm {
|
||||
animation: none
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.github-corner .octo-arm {
|
||||
animation: octocat-wave 560ms ease-in-out
|
||||
animation: octocat-wave 560ms ease-in-out;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<!-- That was not a hack. That was art.
|
||||
|
||||
<!-- UserVoice JavaScript SDK (only needed once on a page) -->
|
||||
<script>(function () { var uv = document.createElement('script'); uv.type = 'text/javascript'; uv.async = true; uv.src = '//widget.uservoice.com/f4AQraEfwInlMzkexfRLg.js'; var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(uv, s) })()</script>
|
||||
<script>
|
||||
(function () {
|
||||
var uv = document.createElement("script");
|
||||
uv.type = "text/javascript";
|
||||
uv.async = true;
|
||||
uv.src = "//widget.uservoice.com/f4AQraEfwInlMzkexfRLg.js";
|
||||
var s = document.getElementsByTagName("script")[0];
|
||||
s.parentNode.insertBefore(uv, s);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- A tab to launch the Classic Widget -->
|
||||
<script>
|
||||
UserVoice = window.UserVoice || [];
|
||||
UserVoice.push(['showTab', 'classic_widget', {
|
||||
mode: 'feedback',
|
||||
primary_color: '#fa8c28',
|
||||
link_color: '#0a8cc6',
|
||||
forum_id: 913660,
|
||||
tab_label: 'Got feedback?',
|
||||
tab_color: '#00994f',
|
||||
tab_position: 'bottom-left',
|
||||
tab_inverted: true
|
||||
}]);
|
||||
UserVoice.push([
|
||||
"showTab",
|
||||
"classic_widget",
|
||||
{
|
||||
mode: "feedback",
|
||||
primary_color: "#fa8c28",
|
||||
link_color: "#0a8cc6",
|
||||
forum_id: 913660,
|
||||
tab_label: "Got feedback?",
|
||||
tab_color: "#00994f",
|
||||
tab_position: "bottom-left",
|
||||
tab_inverted: true,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -1,54 +1,88 @@
|
||||
<p class="logo">
|
||||
<a href="{{ pathto(master_doc) }}">
|
||||
<img class="logo" src="{{ pathto('_static/responder.png', 1) }}" title="https://kennethreitz.org/tattoos" />
|
||||
<img
|
||||
class="logo"
|
||||
src="{{ pathto('_static/responder.png', 1) }}"
|
||||
title="https://kennethreitz.org/tattoos"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<iframe src="https://ghbtns.com/github-btn.html?user=kennethreitz&repo=responder&type=watch&count=true&size=large"
|
||||
allowtransparency="true" frameborder="0" scrolling="0" width="200px" height="35px"></iframe>
|
||||
<a class="github-button"
|
||||
href="https://github.com/kennethreitz/responder"
|
||||
data-color-scheme="no-preference: light; light: light; dark: light;"
|
||||
data-size="large"
|
||||
data-show-count="true"
|
||||
aria-label="Star kennethreitz/responder on GitHub">Star</a>
|
||||
</p>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.css"
|
||||
/>
|
||||
<style>
|
||||
.algolia-autocomplete{
|
||||
width: 100%;
|
||||
height: 1.5em
|
||||
}
|
||||
.algolia-autocomplete a{
|
||||
border-bottom: none !important;
|
||||
}
|
||||
#doc_search{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.algolia-autocomplete {
|
||||
width: 100%;
|
||||
height: 1.5em;
|
||||
}
|
||||
.algolia-autocomplete a {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
#doc_search {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
<input id="doc_search" placeholder="Search the doc" autofocus/>
|
||||
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.js" onload="docsearch({
|
||||
<input id="doc_search" placeholder="Search the doc" autofocus />
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.js"
|
||||
onload="docsearch({
|
||||
apiKey: 'ac965312db252e0496283c75c6f76f0b',
|
||||
indexName: 'python-responder',
|
||||
inputSelector: '#doc_search',
|
||||
debug: false // Set debug to true if you want to inspect the dropdown
|
||||
})" async></script>
|
||||
})"
|
||||
async
|
||||
></script>
|
||||
|
||||
<p>
|
||||
<strong>Responder</strong> is a web service framework, written for human beings.
|
||||
</p>
|
||||
<p><strong>Responder</strong> is a web service framework, written for human beings.</p>
|
||||
|
||||
<h3>Stay Informed</h3>
|
||||
<p>Receive updates on new releases and upcoming projects.</p>
|
||||
|
||||
<p><iframe src="https://ghbtns.com/github-btn.html?user=kennethreitz&type=follow&count=true" allowtransparency="true"
|
||||
frameborder="0" scrolling="0" width="200" height="20"></iframe></p>
|
||||
|
||||
<p><a href="https://twitter.com/kennethreitz" class="twitter-follow-button" data-show-count="false">Follow
|
||||
@kennethreitz</a>
|
||||
<script>!function (d, s, id) { var js, fjs = d.getElementsByTagName(s)[0], p = /^http:/.test(d.location) ? 'http' : 'https'; if (!d.getElementById(id)) { js = d.createElement(s); js.id = id; js.src = p + '://platform.twitter.com/widgets.js'; fjs.parentNode.insertBefore(js, fjs); } }(document, 'script', 'twitter-wjs');</script>
|
||||
<p>
|
||||
<a class="github-button"
|
||||
href="https://github.com/kennethreitz"
|
||||
data-color-scheme="no-preference: light; light: light; dark: light;"
|
||||
data-size="medium"
|
||||
data-show-count="true"
|
||||
aria-label="Follow @kennethreitz on GitHub">Follow @kennethreitz</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a
|
||||
href="https://x.com/kennethreitz42"
|
||||
class="twitter-follow-button"
|
||||
data-show-count="false"
|
||||
>Follow @kennethreitz</a
|
||||
>
|
||||
<script>
|
||||
!(function (d, s, id) {
|
||||
var js,
|
||||
fjs = d.getElementsByTagName(s)[0],
|
||||
p = /^http:/.test(d.location) ? "http" : "https";
|
||||
if (!d.getElementById(id)) {
|
||||
js = d.createElement(s);
|
||||
js.id = id;
|
||||
js.src = p + "://platform.twitter.com/widgets.js";
|
||||
fjs.parentNode.insertBefore(js, fjs);
|
||||
}
|
||||
})(document, "script", "twitter-wjs");
|
||||
</script>
|
||||
</p>
|
||||
|
||||
<h3>Useful Links</h3>
|
||||
<ul>
|
||||
|
||||
<li><a href="http://github.com/kennethreitz/responder">Responder @ GitHub</a></li>
|
||||
<li><a href="http://pypi.python.org/pypi/responder">Responder @ PyPI</a></li>
|
||||
<li><a href="http://github.com/kennethreitz/responder/issues">Issue Tracker</a></li>
|
||||
|
||||
@@ -1,54 +1,93 @@
|
||||
<p class="logo">
|
||||
<a href="{{ pathto(master_doc) }}">
|
||||
<img class="logo" src="{{ pathto('_static/responder.png', 1) }}" title="https://kennethreitz.org/tattoos" />
|
||||
<img
|
||||
class="logo"
|
||||
src="{{ pathto('_static/responder.png', 1) }}"
|
||||
title="https://kennethreitz.org/tattoos"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<iframe src="https://ghbtns.com/github-btn.html?user=kennethreitz&repo=responder&type=watch&count=true&size=large"
|
||||
allowtransparency="true" frameborder="0" scrolling="0" width="200px" height="35px"></iframe>
|
||||
<iframe
|
||||
src="https://ghbtns.com/github-btn.html?user=kennethreitz&repo=responder&type=watch&count=true&size=large"
|
||||
allowtransparency="true"
|
||||
frameborder="0"
|
||||
scrolling="0"
|
||||
width="200px"
|
||||
height="35px"
|
||||
></iframe>
|
||||
</p>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.css"
|
||||
/>
|
||||
<style>
|
||||
.algolia-autocomplete{
|
||||
width: 100%;
|
||||
height: 1.5em
|
||||
}
|
||||
.algolia-autocomplete a{
|
||||
border-bottom: none !important;
|
||||
}
|
||||
#doc_search{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.algolia-autocomplete {
|
||||
width: 100%;
|
||||
height: 1.5em;
|
||||
}
|
||||
.algolia-autocomplete a {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
#doc_search {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
<input id="doc_search" placeholder="Search the doc" autofocus/>
|
||||
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.js" onload="docsearch({
|
||||
<input id="doc_search" placeholder="Search the doc" autofocus />
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.js"
|
||||
onload="docsearch({
|
||||
apiKey: 'ac965312db252e0496283c75c6f76f0b',
|
||||
indexName: 'python-responder',
|
||||
inputSelector: '#doc_search',
|
||||
debug: false // Set debug to true if you want to inspect the dropdown
|
||||
})" async></script>
|
||||
})"
|
||||
async
|
||||
></script>
|
||||
|
||||
<p>
|
||||
<strong>Responder</strong> is a web service framework, written for human beings.
|
||||
</p>
|
||||
<p><strong>Responder</strong> is a web service framework, written for human beings.</p>
|
||||
|
||||
<h3>Stay Informed</h3>
|
||||
<p>Receive updates on new releases and upcoming projects.</p>
|
||||
|
||||
<p><iframe src="https://ghbtns.com/github-btn.html?user=kennethreitz&type=follow&count=true" allowtransparency="true"
|
||||
frameborder="0" scrolling="0" width="200" height="20"></iframe></p>
|
||||
|
||||
<p><a href="https://twitter.com/kennethreitz" class="twitter-follow-button" data-show-count="false">Follow
|
||||
@kennethreitz</a>
|
||||
<script>!function (d, s, id) { var js, fjs = d.getElementsByTagName(s)[0], p = /^http:/.test(d.location) ? 'http' : 'https'; if (!d.getElementById(id)) { js = d.createElement(s); js.id = id; js.src = p + '://platform.twitter.com/widgets.js'; fjs.parentNode.insertBefore(js, fjs); } }(document, 'script', 'twitter-wjs');</script>
|
||||
<p>
|
||||
<iframe
|
||||
src="https://ghbtns.com/github-btn.html?user=kennethreitz&type=follow&count=true"
|
||||
allowtransparency="true"
|
||||
frameborder="0"
|
||||
scrolling="0"
|
||||
width="200"
|
||||
height="20"
|
||||
></iframe>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a
|
||||
href="https://x.com/kennethreitz42"
|
||||
class="twitter-follow-button"
|
||||
data-show-count="false"
|
||||
>Follow @kennethreitz</a
|
||||
>
|
||||
<script>
|
||||
!(function (d, s, id) {
|
||||
var js,
|
||||
fjs = d.getElementsByTagName(s)[0],
|
||||
p = /^http:/.test(d.location) ? "http" : "https";
|
||||
if (!d.getElementById(id)) {
|
||||
js = d.createElement(s);
|
||||
js.id = id;
|
||||
js.src = p + "://platform.twitter.com/widgets.js";
|
||||
fjs.parentNode.insertBefore(js, fjs);
|
||||
}
|
||||
})(document, "script", "twitter-wjs");
|
||||
</script>
|
||||
</p>
|
||||
|
||||
<h3>Useful Links</h3>
|
||||
<ul>
|
||||
|
||||
<li><a href="http://github.com/kennethreitz/responder">Responder @ GitHub</a></li>
|
||||
<li><a href="http://pypi.python.org/pypi/responder">Responder @ PyPI</a></li>
|
||||
<li><a href="http://github.com/kennethreitz/responder/issues">Issue Tracker</a></li>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Backlog
|
||||
|
||||
## Future Ideas
|
||||
- Consider adding `after_request` hooks (complement to `before_request`)
|
||||
- Explore WebSocket before_request short-circuit support
|
||||
- Add rate limiting middleware
|
||||
- Consider async template rendering by default
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../CHANGELOG.md
|
||||
@@ -0,0 +1,174 @@
|
||||
Responder CLI
|
||||
=============
|
||||
|
||||
Responder installs a command line program ``responder``. Use it to launch
|
||||
a Responder application from a file or module, either located on a local
|
||||
or remote filesystem, or object store.
|
||||
|
||||
Launch Module Entrypoint
|
||||
------------------------
|
||||
|
||||
For loading a Responder application from a Python module, you will refer to
|
||||
its ``API()`` instance using a `Python entry point object reference`_ that
|
||||
points to a Python object. It is either in the form ``importable.module``,
|
||||
or ``importable.module:object.attr``.
|
||||
|
||||
A basic invocation command to launch a Responder application:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder run acme.app
|
||||
|
||||
The command above assumes a Python package ``acme`` including an ``app``
|
||||
module ``acme/app.py`` that includes an attribute ``api`` that refers
|
||||
to a ``responder.API`` instance, reflecting the typical layout of
|
||||
a standard Responder application.
|
||||
|
||||
Loading a Responder application using an entrypoint specification will
|
||||
inherit the capacities of `Python's import system`_, as implemented by
|
||||
`importlib`_.
|
||||
|
||||
Launch Local File
|
||||
-----------------
|
||||
|
||||
Acquire a minimal example single-file application, ``helloworld.py`` [1]_,
|
||||
to your local filesystem, giving you the chance to edit it, and launch the
|
||||
Responder HTTP service.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
wget https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py
|
||||
responder run helloworld.py
|
||||
|
||||
.. note::
|
||||
|
||||
To validate the example application, invoke a HTTP request, for example using
|
||||
`curl`_, `HTTPie`_, or your favourite browser at hand.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
http http://127.0.0.1:5042/Hello
|
||||
|
||||
The response is no surprise.
|
||||
|
||||
::
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
content-length: 13
|
||||
content-type: text/plain
|
||||
date: Sat, 26 Oct 2024 13:16:55 GMT
|
||||
encoding: utf-8
|
||||
server: uvicorn
|
||||
|
||||
Hello, world!
|
||||
|
||||
.. [1] The Responder application `helloworld.py`_ implements a basic echo handler.
|
||||
|
||||
Launch Remote File
|
||||
------------------
|
||||
|
||||
You can also launch a single-file application where its Python file is stored
|
||||
on a remote location.
|
||||
|
||||
Responder supports all filesystem adapters compatible with `fsspec`_, and
|
||||
installs the adapters for Azure Blob Storage (az), Google Cloud Storage (gs),
|
||||
GitHub, HTTP, and AWS S3 by default.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
# Works 1:1.
|
||||
responder run https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py
|
||||
responder run github://kennethreitz:responder@/examples/helloworld.py
|
||||
|
||||
If you need access other kinds of remote targets, see the `list of
|
||||
fsspec-supported filesystems and protocols`_. The next section enumerates
|
||||
a few synthetic examples. The corresponding storage buckets do not even
|
||||
exist, so don't expect those commands to work.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
# Azure Blob Storage, Google Cloud Storage, and AWS S3.
|
||||
responder run az://kennethreitz-assets/responder/examples/helloworld.py
|
||||
responder run gs://kennethreitz-assets/responder/examples/helloworld.py
|
||||
responder run s3://kennethreitz-assets/responder/examples/helloworld.py
|
||||
|
||||
# Hadoop Distributed File System (hdfs), SSH File Transfer Protocol (sftp),
|
||||
# Common Internet File System (smb), Web-based Distributed Authoring and
|
||||
# Versioning (webdav).
|
||||
responder run hdfs://kennethreitz-assets/responder/examples/helloworld.py
|
||||
responder run sftp://user@host/kennethreitz/responder/examples/helloworld.py
|
||||
responder run smb://workgroup;user:password@server:port/responder/examples/helloworld.py
|
||||
responder run webdav+https://user:password@server:port/responder/examples/helloworld.py
|
||||
|
||||
.. tip::
|
||||
|
||||
In order to install support for all filesystem types supported by fsspec, run:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
uv pip install 'fsspec[full]'
|
||||
|
||||
When using ``uv``, this concludes within an acceptable time of approx.
|
||||
25 seconds. If you need to be more selectively instead of using ``full``,
|
||||
choose from one or multiple of the available `fsspec extras`_, which are:
|
||||
|
||||
abfs, arrow, dask, dropbox, fuse, gcs, git, github, hdfs, http, oci, s3,
|
||||
sftp, smb, ssh.
|
||||
|
||||
Launch with Non-Standard Instance Name
|
||||
--------------------------------------
|
||||
|
||||
By default, Responder will acquire an ``responder.API`` instance using the
|
||||
symbol name ``api`` from the specified Python module.
|
||||
|
||||
If your main application file uses a different name than ``api``, please
|
||||
append the designated symbol name to the launch target address.
|
||||
|
||||
It works like this for module entrypoints and local files:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder run acme.app:service
|
||||
responder run /path/to/acme/app.py:service
|
||||
|
||||
It works like this for URLs:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder run http://app.server.local/path/to/acme/app.py#service
|
||||
|
||||
Within your ``app.py``, the instance would have been defined to use
|
||||
the ``service`` symbol name instead of ``api``, like this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
service = responder.API()
|
||||
|
||||
Build JavaScript Application
|
||||
----------------------------
|
||||
|
||||
The ``build`` subcommand invokes ``npm run build``, optionally accepting
|
||||
a target directory. By default, it uses the current working directory,
|
||||
where it expects a regular NPM ``package.json`` file.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder build
|
||||
|
||||
When specifying a target directory, Responder will change to that
|
||||
directory beforehand.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder build /path/to/project
|
||||
|
||||
|
||||
.. _curl: https://curl.se/
|
||||
.. _fsspec: https://filesystem-spec.readthedocs.io/en/latest/
|
||||
.. _fsspec extras: https://github.com/fsspec/filesystem_spec/blob/2024.12.0/pyproject.toml#L27-L69
|
||||
.. _helloworld.py: https://github.com/kennethreitz/responder/blob/main/examples/helloworld.py
|
||||
.. _HTTPie: https://httpie.io/docs/cli
|
||||
.. _importlib: https://docs.python.org/3/library/importlib.html
|
||||
.. _list of fsspec-supported filesystems and protocols: https://github.com/fsspec/universal_pathlib#currently-supported-filesystems-and-protocols
|
||||
.. _Python entry point object reference: https://packaging.python.org/en/latest/specifications/entry-points/
|
||||
.. _Python's import system: https://docs.python.org/3/reference/import.html
|
||||
+74
-4
@@ -20,7 +20,7 @@
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = "responder"
|
||||
copyright = "2018, A Kenneth Reitz project"
|
||||
copyright = "2018-2026, A Kenneth Reitz project"
|
||||
author = "Kenneth Reitz"
|
||||
|
||||
# The short X.Y version
|
||||
@@ -57,6 +57,11 @@ extensions = [
|
||||
"sphinx.ext.ifconfig",
|
||||
"sphinx.ext.viewcode",
|
||||
"sphinx.ext.githubpages",
|
||||
"myst_parser",
|
||||
"sphinx_copybutton",
|
||||
"sphinx_design",
|
||||
"sphinx_design_elements",
|
||||
"sphinxext.opengraph",
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
@@ -66,7 +71,7 @@ templates_path = ["_templates"]
|
||||
# You can specify multiple suffix as a list of string:
|
||||
#
|
||||
# source_suffix = ['.rst', '.md']
|
||||
source_suffix = ".rst"
|
||||
source_suffix = {".rst": "restructuredtext"}
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = "index"
|
||||
@@ -76,7 +81,7 @@ master_doc = "index"
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
language = "en"
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
@@ -211,12 +216,77 @@ epub_exclude_files = ["search.html"]
|
||||
|
||||
# -- Extension configuration -------------------------------------------------
|
||||
|
||||
# -- Options for link checker ----------------------------------------------
|
||||
linkcheck_ignore = [
|
||||
# Feldroy.com links are ignored because it blocks GHA.
|
||||
r"https://www.feldroy.com/.*",
|
||||
]
|
||||
linkcheck_anchors_ignore_for_url = [
|
||||
# Requires JavaScript.
|
||||
# After opting-in to new GitHub issues, Sphinx can no longer grok the HTML anchor references.
|
||||
r"https://github.com",
|
||||
]
|
||||
|
||||
# -- Options for intersphinx extension ---------------------------------------
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {"https://docs.python.org/": None}
|
||||
intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
|
||||
|
||||
# -- Options for todo extension ----------------------------------------------
|
||||
|
||||
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
||||
todo_include_todos = True
|
||||
|
||||
# -- Options for MyST --------------------------------------------------------
|
||||
|
||||
myst_heading_anchors = 3
|
||||
myst_enable_extensions = [
|
||||
"attrs_block",
|
||||
"attrs_inline",
|
||||
"colon_fence",
|
||||
"deflist",
|
||||
"fieldlist",
|
||||
"html_admonition",
|
||||
"html_image",
|
||||
"linkify",
|
||||
"replacements",
|
||||
"strikethrough",
|
||||
"substitution",
|
||||
"tasklist",
|
||||
]
|
||||
myst_substitutions = {}
|
||||
|
||||
# -- Options for OpenGraph ---------------------------------------------------
|
||||
#
|
||||
# When making changes, check them using the RTD PR preview URL on https://www.opengraph.xyz/.
|
||||
#
|
||||
# About text lengths
|
||||
#
|
||||
# Original documentation says:
|
||||
# - ogp_description_length
|
||||
# Configure the amount of characters taken from a page. The default of 200 is probably good
|
||||
# for most people. If something other than a number is used, it defaults back to 200.
|
||||
# -- https://sphinxext-opengraph.readthedocs.io/en/latest/#options
|
||||
#
|
||||
# Other people say:
|
||||
# - og:title 40 chars
|
||||
# - og:description has 2 max lengths:
|
||||
# When the link is used in a Post, it's 300 chars. When a link is used in a Comment, it's 110 chars.
|
||||
# So you can either treat it as 110, or, write your Descriptions to 300 but make sure the first 110
|
||||
# is the critical part and still makes sense when it gets cut off.
|
||||
# -- https://stackoverflow.com/questions/8914476/facebook-open-graph-meta-tags-maximum-content-length
|
||||
ogp_site_url = "https://responder.kennethreitz.org/"
|
||||
ogp_description_length = 300
|
||||
ogp_site_name = "Responder Documentation"
|
||||
ogp_image = "https://responder.kennethreitz.org/_static/responder.png"
|
||||
ogp_image_alt = False
|
||||
ogp_use_first_image = False
|
||||
ogp_type = "website"
|
||||
ogp_enable_meta_description = True
|
||||
|
||||
# -- Options for sphinx-copybutton -------------------------------------------
|
||||
|
||||
copybutton_remove_prompts = True
|
||||
copybutton_line_continuation_character = "\\"
|
||||
copybutton_prompt_text = r">>> |\.\.\. |\$ |sh\$ |PS> |cr> |mysql> |In \[\d*\]: | {2,5}\.\.\.: | {5,8}: "
|
||||
copybutton_prompt_is_regexp = True
|
||||
|
||||
+18
-21
@@ -6,33 +6,30 @@ You can deploy Responder anywhere you can deploy a basic Python application.
|
||||
Docker Deployment
|
||||
-----------------
|
||||
|
||||
Assuming existing ``api.py`` and ``Pipfile.lock`` containing ``responder``.
|
||||
Assuming an existing ``api.py`` containing your Responder application.
|
||||
|
||||
``Dockerfile``::
|
||||
|
||||
FROM kennethreitz/pipenv
|
||||
ENV PORT '80'
|
||||
COPY . /app
|
||||
CMD python3 api.py
|
||||
FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN pip install responder
|
||||
ENV PORT=80
|
||||
EXPOSE 80
|
||||
CMD ["python", "api.py"]
|
||||
|
||||
That's it!
|
||||
|
||||
Heroku Deployment
|
||||
-----------------
|
||||
Cloud Deployment
|
||||
----------------
|
||||
|
||||
Responder automatically honors the ``PORT`` environment variable, which is
|
||||
set by most cloud platforms (Fly.io, Railway, Render, Google Cloud Run, etc.).
|
||||
|
||||
The basics::
|
||||
|
||||
$ mkdir my-api
|
||||
$ cd my-api
|
||||
$ git init
|
||||
$ heroku create
|
||||
...
|
||||
|
||||
Install Responder::
|
||||
|
||||
$ pipenv install responder
|
||||
...
|
||||
|
||||
Write out an ``api.py``::
|
||||
|
||||
@@ -47,12 +44,12 @@ Write out an ``api.py``::
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
|
||||
Write out a ``Procfile``::
|
||||
Deploy with your platform of choice. Responder will bind to ``0.0.0.0``
|
||||
on the port specified by ``PORT`` automatically.
|
||||
|
||||
web: python api.py
|
||||
Running with Uvicorn Directly
|
||||
-----------------------------
|
||||
|
||||
That's it! Next, we commit and push to Heroku::
|
||||
For production deployments, you can also run your app directly with uvicorn::
|
||||
|
||||
$ git add -A
|
||||
$ git commit -m 'initial commit'
|
||||
$ git push heroku master
|
||||
uvicorn api:api --host 0.0.0.0 --port 8000 --workers 4
|
||||
|
||||
+66
-33
@@ -6,19 +6,23 @@
|
||||
A familiar HTTP Service Framework
|
||||
=================================
|
||||
|
||||
|Build Status| |image1| |image2| |image3| |image4| |image5|
|
||||
|ci-tests| |version| |license| |python-versions| |downloads| |contributors| |say-thanks|
|
||||
|
||||
.. |Build Status| image:: https://travis-ci.org/kennethreitz/responder.svg?branch=master
|
||||
:target: https://travis-ci.org/kennethreitz/responder
|
||||
.. |image1| image:: https://img.shields.io/pypi/v/responder.svg
|
||||
.. |ci-tests| image:: https://github.com/kennethreitz/responder/actions/workflows/test.yaml/badge.svg
|
||||
:target: https://github.com/kennethreitz/responder/actions/workflows/test.yaml
|
||||
.. |ci-docs| image:: https://github.com/kennethreitz/responder/actions/workflows/docs.yaml/badge.svg
|
||||
:target: https://github.com/kennethreitz/responder/actions/workflows/docs.yaml
|
||||
.. |version| image:: https://img.shields.io/pypi/v/responder.svg
|
||||
:target: https://pypi.org/project/responder/
|
||||
.. |image2| image:: https://img.shields.io/pypi/l/responder.svg
|
||||
.. |license| image:: https://img.shields.io/pypi/l/responder.svg
|
||||
:target: https://pypi.org/project/responder/
|
||||
.. |image3| image:: https://img.shields.io/pypi/pyversions/responder.svg
|
||||
.. |python-versions| image:: https://img.shields.io/pypi/pyversions/responder.svg
|
||||
:target: https://pypi.org/project/responder/
|
||||
.. |image4| image:: https://img.shields.io/github/contributors/kennethreitz/responder.svg
|
||||
.. |downloads| image:: https://static.pepy.tech/badge/responder/month
|
||||
:target: https://www.pepy.tech/projects/responder
|
||||
.. |contributors| image:: https://img.shields.io/github/contributors/kennethreitz/responder.svg
|
||||
:target: https://github.com/kennethreitz/responder/graphs/contributors
|
||||
.. |image5| image:: https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg
|
||||
.. |say-thanks| image:: https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg
|
||||
:target: https://saythanks.io/to/kennethreitz
|
||||
|
||||
.. code:: python
|
||||
@@ -34,23 +38,24 @@ A familiar HTTP Service Framework
|
||||
if __name__ == '__main__':
|
||||
api.run()
|
||||
|
||||
Powered by `Starlette <https://www.starlette.io/>`_. That ``async`` declaration is optional.
|
||||
Responder is powered by `Starlette`_.
|
||||
|
||||
This gets you a ASGI app, with a production static files server
|
||||
(`WhiteNoise <http://whitenoise.evans.io/en/stable/>`_)
|
||||
pre-installed, jinja2 templating (without additional imports), and a
|
||||
production webserver based on uvloop, serving up requests with
|
||||
automatic gzip compression.
|
||||
The example program demonstrates an `ASGI`_ application using `Responder`_,
|
||||
including production-ready components like the `uvicorn`_ webserver, based
|
||||
on `uvloop`_, and the `Jinja`_ templating library pre-installed.
|
||||
The ``async`` declaration within the example program is optional.
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
- A pleasant API, with a single import statement.
|
||||
- Class-based views without inheritance.
|
||||
- `ASGI <https://asgi.readthedocs.io>`_ framework, the future of Python web services.
|
||||
- `ASGI`_, the future of Python web services.
|
||||
- Asynchronous Python frameworks and applications.
|
||||
- Automatic gzip compression.
|
||||
- WebSocket support!
|
||||
- The ability to mount any ASGI / WSGI app at a subroute.
|
||||
- `f-string syntax <https://docs.python.org/3/whatsnew/3.6.html#pep-498-formatted-string-literals>`_ route declaration.
|
||||
- `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!
|
||||
@@ -61,30 +66,23 @@ Testimonials
|
||||
------------
|
||||
|
||||
“Pleasantly very taken with python-responder.
|
||||
`@kennethreitz <https://twitter.com/kennethreitz>`_ at his absolute
|
||||
best.”
|
||||
|
||||
—Rudraksh M.K.
|
||||
|
||||
`@kennethreitz`_ at his absolute best.”
|
||||
|
||||
— Rudraksh M.K.
|
||||
|
||||
..
|
||||
|
||||
"ASGI is going to enable all sorts of new high-performance web services. It's awesome to see Responder starting to take advantage of that."
|
||||
|
||||
—Tom Christie, author of `Django REST Framework`_
|
||||
— Tom Christie, author of `Django REST Framework`_
|
||||
|
||||
..
|
||||
|
||||
|
||||
“I love that you are exploring new patterns. Go go go!”
|
||||
|
||||
— Danny Greenfield, author of `Two Scoops of Django`_
|
||||
— Danny Greenfield, author of `Two Scoops of Django`_
|
||||
|
||||
|
||||
.. _Django REST Framework: https://www.django-rest-framework.org/
|
||||
.. _Two Scoops of Django: https://www.twoscoopspress.com/products/two-scoops-of-django-1-11
|
||||
|
||||
User Guides
|
||||
-----------
|
||||
|
||||
@@ -96,17 +94,38 @@ User Guides
|
||||
deployment
|
||||
testing
|
||||
api
|
||||
cli
|
||||
|
||||
|
||||
Installing Responder
|
||||
--------------------
|
||||
|
||||
Use ``uv`` for fast installation.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
$ pipenv install responder
|
||||
✨🍰✨
|
||||
uv pip install --upgrade 'responder'
|
||||
|
||||
Only **Python 3.6+** is supported.
|
||||
Or use standard pip where ``uv`` is not available.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
pip install --upgrade 'responder'
|
||||
|
||||
Responder supports **Python 3.9+**.
|
||||
|
||||
Development
|
||||
-----------
|
||||
|
||||
If you are looking at installing Responder
|
||||
for hacking on it, please refer to the :ref:`sandbox` documentation.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
changes
|
||||
Sandbox <sandbox>
|
||||
backlog
|
||||
|
||||
|
||||
The Basic Idea
|
||||
@@ -123,14 +142,14 @@ The primary concept here is to bring the niceties that are brought forth from bo
|
||||
Ideas
|
||||
-----
|
||||
|
||||
- Flask-style route expression, with new capabilities -- all while using Python 3.6+'s new f-string syntax.
|
||||
- Flask-style route expression, with new capabilities -- using Python's f-string syntax.
|
||||
- I love Falcon's "every request and response is passed into each view and mutated" methodology, especially ``response.media``, and have used it here. In addition to supporting JSON, I have decided to support YAML as well, as Kubernetes is slowly taking over the world, and it uses YAML for all the things. Content-negotiation and all that.
|
||||
- **A built in testing client that uses the actual Requests you know and love**.
|
||||
- **A built in testing client** powered by Starlette's TestClient.
|
||||
- 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 <https://www.uvicorn.org/>`_ is built-in as a production web server. I would have chosen Gunicorn, but it doesn't run on Windows. Plus, Uvicorn serves well to protect against `slowloris <https://en.wikipedia.org/wiki/Slowloris_(computer_security)>`_ attacks, making nginx unnecessary in production.
|
||||
- `uvicorn`_ is built-in as a production web server. I would have chosen Gunicorn, but it doesn't run on Windows. Plus, uvicorn serves well to protect against `Slowloris`_ attacks, making Nginx unnecessary in production.
|
||||
- GraphQL support, via Graphene. The goal here is to have any GraphQL query exposable at any route, magically.
|
||||
|
||||
|
||||
@@ -140,3 +159,17 @@ Indices and tables
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
|
||||
|
||||
.. _@kennethreitz: https://x.com/kennethreitz42
|
||||
.. _ASGI: https://en.wikipedia.org/wiki/Asynchronous_Server_Gateway_Interface
|
||||
.. _Django REST Framework: https://www.django-rest-framework.org/
|
||||
.. _f-string syntax: https://docs.python.org/3/whatsnew/3.6.html#pep-498-formatted-string-literals
|
||||
.. _Jinja: https://jinja.palletsprojects.com/en/stable/
|
||||
.. _ServeStatic: https://archmonger.github.io/ServeStatic/latest/
|
||||
.. _Slowloris: https://en.wikipedia.org/wiki/Slowloris_(computer_security)
|
||||
.. _Starlette: https://www.starlette.io/
|
||||
.. _Responder: https://responder.kennethreitz.org/
|
||||
.. _Two Scoops of Django: https://www.feldroy.com/two-scoops-press#two-scoops-of-django
|
||||
.. _uvicorn: https://www.uvicorn.org/
|
||||
.. _uvloop: https://uvloop.readthedocs.io/
|
||||
|
||||
@@ -54,7 +54,7 @@ Type convertors are also available::
|
||||
async def add(req, resp, *, a, b):
|
||||
resp.text = f"{a} + {b} = {a + b}"
|
||||
|
||||
Supported types: ``str``, ``int`` and ``float``.
|
||||
Supported types: ``str``, ``int``, ``float``, ``uuid``, and ``path``.
|
||||
|
||||
Returning JSON / YAML
|
||||
---------------------
|
||||
@@ -73,7 +73,7 @@ If the client requests YAML instead (with a header of ``Accept: application/x-ya
|
||||
Rendering a Template
|
||||
--------------------
|
||||
|
||||
Responder provides a built-in light `jinja2 <http://jinja.pocoo.org/docs/>`_ wrapper ``templates.Templates``
|
||||
Responder provides a built-in light `Jinja`_ wrapper ``templates.Templates``
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -158,20 +158,19 @@ Here's a sample code to post a file with background::
|
||||
|
||||
@api.background.task
|
||||
def process_data(data):
|
||||
f = open('./{}'.format(data['file']['filename']), 'w')
|
||||
f.write(data['file']['content'].decode('utf-8'))
|
||||
f.close()
|
||||
with open(f"./{data['file']['filename']}", 'wb') as f:
|
||||
f.write(data['file']['content'])
|
||||
|
||||
data = await req.media(format='files')
|
||||
process_data(data)
|
||||
|
||||
resp.media = {'success': 'ok'}
|
||||
|
||||
You can send a file easily with requests::
|
||||
You can test file uploads using the built-in test client::
|
||||
|
||||
import requests
|
||||
files = {'file': ('hello.txt', b'hello, world!', 'text/plain')}
|
||||
r = api.requests.post(api.url_for(upload_file), files=files)
|
||||
print(r.json())
|
||||
|
||||
data = {'file': ('hello.txt', 'hello, world!', "text/plain")}
|
||||
r = requests.post('http://127.0.0.1:8210/file', files=data)
|
||||
|
||||
print(r.text)
|
||||
.. _Jinja: https://jinja.palletsprojects.com/en/stable/
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
(sandbox)=
|
||||
# Development Sandbox
|
||||
|
||||
## Setup
|
||||
Set up a development sandbox.
|
||||
|
||||
Acquire sources and create virtualenv.
|
||||
```shell
|
||||
git clone https://github.com/kennethreitz/responder.git
|
||||
cd responder
|
||||
uv venv
|
||||
```
|
||||
|
||||
Install project in editable mode, including
|
||||
all runtime extensions and development tools.
|
||||
```shell
|
||||
uv pip install --upgrade --editable '.[develop,docs,release,test]'
|
||||
```
|
||||
|
||||
## Operations
|
||||
Invoke linter and software tests.
|
||||
```shell
|
||||
source .venv/bin/activate
|
||||
poe check
|
||||
```
|
||||
|
||||
Format code.
|
||||
```shell
|
||||
poe format
|
||||
```
|
||||
|
||||
Documentation authoring.
|
||||
```shell
|
||||
poe docs-autobuild
|
||||
```
|
||||
+4
-37
@@ -1,16 +1,16 @@
|
||||
Building and Testing with Responder
|
||||
===================================
|
||||
|
||||
Responder comes with a first-class, well supported test client for your ASGI web services: **Requests**.
|
||||
Responder comes with a first-class, well supported test client for your ASGI web services (powered by Starlette's TestClient).
|
||||
|
||||
Here, we'll go over the basics of setting up a proper Python package and adding testing to it.
|
||||
Here, we'll go over the basics of setting up and testing a Responder application.
|
||||
|
||||
The Basics
|
||||
----------
|
||||
|
||||
Your repository should look like this::
|
||||
Your project should look like this::
|
||||
|
||||
Pipfile Pipfile.lock api.py test_api.py
|
||||
api.py test_api.py
|
||||
|
||||
``$ cat api.py``::
|
||||
|
||||
@@ -25,26 +25,6 @@ Your repository should look like this::
|
||||
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
|
||||
-------------
|
||||
|
||||
@@ -66,16 +46,3 @@ Writing Tests
|
||||
|
||||
...
|
||||
========================== 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.
|
||||
|
||||
+91
-11
@@ -2,6 +2,21 @@ Feature Tour
|
||||
============
|
||||
|
||||
|
||||
Route Method Filtering
|
||||
----------------------
|
||||
|
||||
You can restrict routes to specific HTTP methods::
|
||||
|
||||
@api.route("/items", methods=["GET"])
|
||||
def list_items(req, resp):
|
||||
resp.media = {"items": [...]}
|
||||
|
||||
@api.route("/items", methods=["POST"], check_existing=False)
|
||||
async def create_item(req, resp):
|
||||
data = await req.media()
|
||||
resp.media = {"created": data}
|
||||
|
||||
|
||||
Class-Based Views
|
||||
-----------------
|
||||
|
||||
@@ -15,6 +30,61 @@ Class-based views (and setting some headers and stuff)::
|
||||
resp.status_code = api.status_codes.HTTP_416
|
||||
|
||||
|
||||
Lifespan Events
|
||||
---------------
|
||||
|
||||
Use the lifespan context manager for startup and shutdown logic::
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
# Startup: connect to database, etc.
|
||||
print("Starting up...")
|
||||
yield
|
||||
# Shutdown: clean up resources
|
||||
print("Shutting down...")
|
||||
|
||||
api = responder.API(lifespan=lifespan)
|
||||
|
||||
You can also use the traditional event decorators::
|
||||
|
||||
@api.on_event('startup')
|
||||
async def startup():
|
||||
print("Starting up...")
|
||||
|
||||
@api.on_event('shutdown')
|
||||
async def shutdown():
|
||||
print("Shutting down...")
|
||||
|
||||
|
||||
Serving Files
|
||||
-------------
|
||||
|
||||
Serve files from disk with automatic content-type detection::
|
||||
|
||||
@api.route("/download")
|
||||
def download(req, resp):
|
||||
resp.file("reports/annual.pdf")
|
||||
|
||||
You can also specify the content type explicitly::
|
||||
|
||||
@api.route("/image")
|
||||
def image(req, resp):
|
||||
resp.file("photos/cat.jpg", content_type="image/jpeg")
|
||||
|
||||
|
||||
Custom Error Handling
|
||||
---------------------
|
||||
|
||||
Register handlers for specific exception types::
|
||||
|
||||
@api.exception_handler(ValueError)
|
||||
async def handle_value_error(req, resp, exc):
|
||||
resp.status_code = 400
|
||||
resp.media = {"error": str(exc)}
|
||||
|
||||
|
||||
Background Tasks
|
||||
----------------
|
||||
|
||||
@@ -34,10 +104,15 @@ Here, you can spawn off a background thread to run any function, out-of-request:
|
||||
|
||||
GraphQL
|
||||
-------
|
||||
Responder supports GraphQL, a query language for APIs that enables clients to
|
||||
request exactly the data they need.
|
||||
|
||||
For more information about GraphQL, visit https://graphql.org/.
|
||||
|
||||
Serve a GraphQL API::
|
||||
|
||||
import graphene
|
||||
from responder.ext.graphql import GraphQLView
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
@@ -46,7 +121,7 @@ Serve a GraphQL API::
|
||||
return f"Hello {name}"
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
view = responder.ext.GraphQLView(api=api, schema=schema)
|
||||
view = GraphQLView(api=api, schema=schema)
|
||||
|
||||
api.add_route("/graph", view)
|
||||
|
||||
@@ -58,12 +133,18 @@ You can make use of Responder's Request and Response objects in your GraphQL res
|
||||
OpenAPI Schema Support
|
||||
----------------------
|
||||
|
||||
Responder comes with built-in support for OpenAPI / marshmallow
|
||||
Responder comes with built-in support for OpenAPI / marshmallow.
|
||||
|
||||
New in Responder `1.4.0`::
|
||||
.. note::
|
||||
|
||||
If you're upgrading from a previous version, note that the OpenAPI module
|
||||
has been renamed from ``responder.ext.schema`` to ``responder.ext.openapi``.
|
||||
Update your imports accordingly.
|
||||
|
||||
New in Responder 1.4.0::
|
||||
|
||||
import responder
|
||||
from responder.ext.schema import Schema as OpenAPISchema
|
||||
from responder.ext.openapi import OpenAPISchema
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
contact = {
|
||||
@@ -194,12 +275,11 @@ Responder can automatically supply API Documentation for you. Using the example
|
||||
|
||||
The new and recommended way::
|
||||
|
||||
...
|
||||
from responder.ext.schema import Schema
|
||||
...
|
||||
from responder.ext.openapi import OpenAPISchema
|
||||
|
||||
api = responder.API()
|
||||
|
||||
schema = Schema(
|
||||
schema = OpenAPISchema(
|
||||
app=api,
|
||||
title="Web Service",
|
||||
version="1.0",
|
||||
@@ -214,7 +294,7 @@ The new and recommended way::
|
||||
)
|
||||
|
||||
|
||||
The old way ::
|
||||
The old way::
|
||||
|
||||
api = responder.API(
|
||||
title="Web Service",
|
||||
@@ -283,7 +363,7 @@ Supported directives:
|
||||
* ``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>`_
|
||||
For more information see `directives <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie>`_
|
||||
|
||||
|
||||
Using Cookie-Based Sessions
|
||||
@@ -353,7 +433,7 @@ Closing the connection::
|
||||
Using Requests Test Client
|
||||
--------------------------
|
||||
|
||||
Responder comes with a first-class, well supported test client for your ASGI web services: **Requests**.
|
||||
Responder comes with a first-class, well supported test client for your ASGI web services (powered by Starlette's TestClient).
|
||||
|
||||
Here's an example of a test (written with pytest)::
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Example HTTP service definition, using Responder.
|
||||
# https://pypi.org/project/responder/
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
|
||||
@api.route("/")
|
||||
async def index(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
|
||||
@api.route("/{greeting}")
|
||||
async def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
@@ -0,0 +1,26 @@
|
||||
# Example showing the lifespan context manager pattern.
|
||||
# https://pypi.org/project/responder/
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import responder
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
# Startup: initialize resources
|
||||
print("Starting up...")
|
||||
yield
|
||||
# Shutdown: clean up resources
|
||||
print("Shutting down...")
|
||||
|
||||
|
||||
api = responder.API(lifespan=lifespan)
|
||||
|
||||
|
||||
@api.route("/{greeting}")
|
||||
async def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
@@ -0,0 +1,25 @@
|
||||
# Example HTTP service definition, using Responder.
|
||||
# https://pypi.org/project/responder/
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
|
||||
@api.route("/")
|
||||
async def index(req, resp):
|
||||
resp.text = "Welcome"
|
||||
|
||||
|
||||
@api.route("/user")
|
||||
async def user_create(req, resp):
|
||||
data = await req.media()
|
||||
resp.text = f"Hello, {data['username']}"
|
||||
|
||||
|
||||
@api.route("/user/{identifier}")
|
||||
async def user_get(req, resp, *, identifier):
|
||||
resp.text = f"Hello, user {identifier}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
[build-system]
|
||||
build-backend = "setuptools.build_meta"
|
||||
requires = [
|
||||
"setuptools>=42",
|
||||
]
|
||||
|
||||
[project]
|
||||
name = "responder"
|
||||
description = "A familiar HTTP Service Framework for Python."
|
||||
readme = "README.md"
|
||||
license = {text = "Apache 2.0"}
|
||||
authors = [
|
||||
{ name = "Kenneth Reitz", email = "me@kennethreitz.org" },
|
||||
]
|
||||
requires-python = ">=3.9"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Web Environment",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
dependencies = [
|
||||
"a2wsgi",
|
||||
"apispec>=1.0.0",
|
||||
"chardet",
|
||||
"docopt-ng",
|
||||
"graphene>=3",
|
||||
"graphql-core>=3.1",
|
||||
"marshmallow",
|
||||
"pueblo[sfa-full]>=0.0.11",
|
||||
"python-multipart",
|
||||
"starlette[full]>=0.40",
|
||||
"uvicorn[standard]",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
develop = [
|
||||
"poethepoet",
|
||||
"pyproject-fmt",
|
||||
"ruff",
|
||||
"validate-pyproject",
|
||||
]
|
||||
docs = [
|
||||
"alabaster<1.1",
|
||||
"myst-parser[linkify]",
|
||||
"sphinx>=5,<9",
|
||||
"sphinx-autobuild",
|
||||
"sphinx-copybutton",
|
||||
"sphinx-design-elements",
|
||||
"sphinxext.opengraph",
|
||||
]
|
||||
release = ["build", "twine"]
|
||||
test = [
|
||||
"flask",
|
||||
"mypy",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-mock",
|
||||
"pytest-rerunfailures",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
responder = "responder.ext.cli:cli"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/kennethreitz/responder"
|
||||
Documentation = "https://responder.kennethreitz.org"
|
||||
Repository = "https://github.com/kennethreitz/responder"
|
||||
Issues = "https://github.com/kennethreitz/responder/issues"
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "responder.__version__.__version__"}
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
responder = ["py.typed"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
exclude = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 90
|
||||
|
||||
extend-exclude = [
|
||||
"docs/source/conf.py",
|
||||
]
|
||||
|
||||
lint.select = [
|
||||
# Builtins
|
||||
"A",
|
||||
# Bugbear
|
||||
"B",
|
||||
# comprehensions
|
||||
"C4",
|
||||
# Pycodestyle
|
||||
"E",
|
||||
# eradicate
|
||||
"ERA",
|
||||
# Pyflakes
|
||||
"F",
|
||||
# isort
|
||||
"I",
|
||||
# pandas-vet
|
||||
"PD",
|
||||
# return
|
||||
"RET",
|
||||
# Bandit
|
||||
"S",
|
||||
# print
|
||||
"T20",
|
||||
"W",
|
||||
# flake8-2020
|
||||
"YTT",
|
||||
]
|
||||
|
||||
lint.extend-ignore = [
|
||||
"S101", # Allow use of `assert`.
|
||||
]
|
||||
|
||||
lint.per-file-ignores."responder/util/cmd.py" = [ "A005" ] # Module shadows a Python standard-library module
|
||||
|
||||
lint.per-file-ignores."tests/*" = [
|
||||
"ERA001", # Found commented-out code.
|
||||
"S101", # Allow use of `assert`, and `print`.
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = """
|
||||
-rfEXs -p pytester --strict-markers --verbosity=3
|
||||
--cov --cov-report=term-missing --cov-report=xml
|
||||
"""
|
||||
filterwarnings = [
|
||||
"error::UserWarning",
|
||||
]
|
||||
log_level = "DEBUG"
|
||||
log_cli_level = "DEBUG"
|
||||
log_format = "%(asctime)-15s [%(name)-36s] %(levelname)-8s: %(message)s"
|
||||
minversion = "2.0"
|
||||
testpaths = [
|
||||
"responder",
|
||||
"tests",
|
||||
]
|
||||
markers = [
|
||||
]
|
||||
xfail_strict = true
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = false
|
||||
omit = [
|
||||
"*.html",
|
||||
"tests/*",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 0
|
||||
show_missing = true
|
||||
exclude_lines = [
|
||||
"# pragma: no cover",
|
||||
"raise NotImplemented",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
packages = [
|
||||
"responder",
|
||||
]
|
||||
exclude = [
|
||||
]
|
||||
check_untyped_defs = true
|
||||
explicit_package_bases = true
|
||||
ignore_missing_imports = true
|
||||
implicit_optional = true
|
||||
install_types = true
|
||||
namespace_packages = true
|
||||
non_interactive = true
|
||||
|
||||
[tool.poe.tasks]
|
||||
|
||||
check = [
|
||||
"test",
|
||||
]
|
||||
|
||||
docs-autobuild = [
|
||||
{ cmd = "sphinx-autobuild --open-browser --watch docs/source docs/source docs/build" },
|
||||
]
|
||||
docs-html = [
|
||||
{ cmd = "sphinx-build -W --keep-going docs/source docs/build" },
|
||||
]
|
||||
docs-linkcheck = [
|
||||
{ cmd = "sphinx-build -W --keep-going -b linkcheck docs/source docs/build" },
|
||||
]
|
||||
|
||||
format = [
|
||||
{ cmd = "ruff format ." },
|
||||
# Configure Ruff not to auto-fix (remove!):
|
||||
# unused imports (F401), unused variables (F841), `print` statements (T201), and commented-out code (ERA001).
|
||||
{ cmd = "ruff check --fix --ignore=ERA --ignore=F401 --ignore=F841 --ignore=T20 --ignore=ERA001 ." },
|
||||
{ cmd = "pyproject-fmt --keep-full-version pyproject.toml" },
|
||||
]
|
||||
|
||||
lint = [
|
||||
{ cmd = "ruff format --check ." },
|
||||
{ cmd = "ruff check ." },
|
||||
{ cmd = "validate-pyproject pyproject.toml" },
|
||||
{ cmd = "mypy" },
|
||||
]
|
||||
|
||||
release = [
|
||||
{ cmd = "python -m build" },
|
||||
{ cmd = "twine upload --skip-existing dist/*" },
|
||||
]
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
cmd = "pytest"
|
||||
help = "Invoke software tests"
|
||||
|
||||
[tool.poe.tasks.test.args.expression]
|
||||
options = [ "-k" ]
|
||||
|
||||
[tool.poe.tasks.test.args.marker]
|
||||
options = [ "-m" ]
|
||||
@@ -1,4 +0,0 @@
|
||||
[pytest]
|
||||
;addopts= -rsxX -s -v --strict
|
||||
filterwarnings =
|
||||
error::UserWarning
|
||||
@@ -1,5 +0,0 @@
|
||||
build:
|
||||
image: latest
|
||||
|
||||
python:
|
||||
version: 3.6
|
||||
+17
-1
@@ -1,2 +1,18 @@
|
||||
from .core import *
|
||||
"""
|
||||
Responder - a familiar HTTP Service Framework.
|
||||
|
||||
This module exports the core functionality of the Responder framework,
|
||||
including the API, Request, and Response classes.
|
||||
"""
|
||||
|
||||
from . import ext
|
||||
from .__version__ import __version__
|
||||
from .core import API, Request, Response
|
||||
|
||||
__all__ = [
|
||||
"API",
|
||||
"Request",
|
||||
"Response",
|
||||
"__version__",
|
||||
"ext",
|
||||
]
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.0.4"
|
||||
__version__ = "3.0.0"
|
||||
|
||||
+162
-75
@@ -1,41 +1,37 @@
|
||||
import json
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import jinja2
|
||||
__all__ = ["API"]
|
||||
|
||||
import uvicorn
|
||||
from starlette.exceptions import ExceptionMiddleware
|
||||
from starlette.middleware.wsgi import WSGIMiddleware
|
||||
from starlette.middleware.errors import ServerErrorMiddleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.errors import ServerErrorMiddleware
|
||||
from starlette.middleware.exceptions import ExceptionMiddleware
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.routing import Lifespan
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.testclient import TestClient
|
||||
from starlette.websockets import WebSocket
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
|
||||
from . import models, status_codes
|
||||
from . import status_codes
|
||||
from .background import BackgroundQueue
|
||||
from .formats import get_formats
|
||||
from .models import Request, Response
|
||||
from .routes import Router
|
||||
from .statics import DEFAULT_API_THEME, DEFAULT_CORS_PARAMS, DEFAULT_SECRET_KEY
|
||||
from .ext.schema import Schema as OpenAPISchema
|
||||
from .staticfiles import StaticFiles
|
||||
from .statics import DEFAULT_CORS_PARAMS, DEFAULT_OPENAPI_THEME, DEFAULT_SECRET_KEY
|
||||
from .templates import Templates
|
||||
|
||||
|
||||
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 static_dir: The directory to use for static files. Will be created for you if it doesn't already exist.
|
||||
:param templates_dir: The directory to use for templates. Will be created for you if it doesn't already exist.
|
||||
:param auto_escape: If ``True``, HTML and XML templates will automatically be escaped.
|
||||
:param enable_hsts: If ``True``, send all responses to HTTPS URLs.
|
||||
:param openapi_theme: OpenAPI documentation theme, must be one of ``elements``, ``rapidoc``, ``redoc``, ``swagger_ui``
|
||||
""" # noqa: E501
|
||||
|
||||
status_codes = status_codes
|
||||
|
||||
@@ -48,7 +44,7 @@ class API:
|
||||
description=None,
|
||||
terms_of_service=None,
|
||||
contact=None,
|
||||
license=None,
|
||||
license=None, # noqa: A002
|
||||
openapi=None,
|
||||
openapi_route="/schema.yml",
|
||||
static_dir="static",
|
||||
@@ -61,17 +57,19 @@ class API:
|
||||
cors=False,
|
||||
cors_params=DEFAULT_CORS_PARAMS,
|
||||
allowed_hosts=None,
|
||||
openapi_theme=DEFAULT_OPENAPI_THEME,
|
||||
lifespan=None,
|
||||
):
|
||||
self.background = BackgroundQueue()
|
||||
|
||||
self.secret_key = secret_key
|
||||
|
||||
self.router = Router()
|
||||
self.router = Router(lifespan=lifespan)
|
||||
|
||||
if static_dir is not None:
|
||||
if static_route is None:
|
||||
static_route = static_dir
|
||||
static_dir = Path(os.path.abspath(static_dir))
|
||||
static_route = ""
|
||||
static_dir = Path(static_dir).resolve()
|
||||
|
||||
self.static_dir = static_dir
|
||||
self.static_route = static_route
|
||||
@@ -82,22 +80,15 @@ class API:
|
||||
self.debug = debug
|
||||
|
||||
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
|
||||
|
||||
if self.static_dir is not None:
|
||||
os.makedirs(self.static_dir, exist_ok=True)
|
||||
|
||||
if self.static_dir is not None:
|
||||
self.static_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.mount(self.static_route, self.static_app)
|
||||
|
||||
self.formats = get_formats()
|
||||
|
||||
# Cached requests session.
|
||||
self._session = None
|
||||
|
||||
self.default_endpoint = None
|
||||
@@ -115,11 +106,19 @@ class API:
|
||||
self.add_middleware(SessionMiddleware, secret_key=self.secret_key)
|
||||
|
||||
if openapi or docs_route:
|
||||
try:
|
||||
from .ext.openapi import OpenAPISchema
|
||||
except ImportError as ex:
|
||||
raise ImportError(
|
||||
"The dependencies for the OpenAPI extension are not installed. "
|
||||
"Install them using: pip install responder"
|
||||
) from ex
|
||||
|
||||
self.openapi = OpenAPISchema(
|
||||
app=self,
|
||||
title="Web Service",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
title=title,
|
||||
version=version,
|
||||
openapi=openapi,
|
||||
docs_route=docs_route,
|
||||
description=description,
|
||||
terms_of_service=terms_of_service,
|
||||
@@ -127,13 +126,15 @@ class API:
|
||||
license=license,
|
||||
openapi_route=openapi_route,
|
||||
static_route=static_route,
|
||||
openapi_theme=openapi_theme,
|
||||
)
|
||||
|
||||
# TODO: Update docs for templates
|
||||
self.templates = Templates(directory=templates_dir)
|
||||
self.requests = (
|
||||
self.session()
|
||||
) #: A Requests session that is connected to the ASGI app.
|
||||
|
||||
@property
|
||||
def requests(self):
|
||||
"""A test client connected to the ASGI app. Lazily initialized."""
|
||||
return self.session()
|
||||
|
||||
@property
|
||||
def static_app(self):
|
||||
@@ -152,9 +153,59 @@ class API:
|
||||
def add_middleware(self, middleware_cls, **middleware_config):
|
||||
self.app = middleware_cls(self.app, **middleware_config)
|
||||
|
||||
def schema(self, name, **options):
|
||||
"""Decorator for creating new routes around function and class definitions.
|
||||
def exception_handler(self, exception_cls):
|
||||
"""Register a handler for a specific exception type.
|
||||
|
||||
Usage::
|
||||
|
||||
@api.exception_handler(ValueError)
|
||||
async def handle_value_error(req, resp, exc):
|
||||
resp.status_code = 400
|
||||
resp.media = {"error": str(exc)}
|
||||
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
async def _handler(request, exc):
|
||||
from starlette.responses import Response as StarletteResp
|
||||
|
||||
req = Request(request.scope, request.receive, formats=get_formats())
|
||||
resp = Response(req=req, formats=get_formats())
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
await func(req, resp, exc)
|
||||
else:
|
||||
func(req, resp, exc)
|
||||
if resp.status_code is None:
|
||||
resp.status_code = 500
|
||||
body, headers = await resp.body
|
||||
return StarletteResp(
|
||||
content=body, status_code=resp.status_code, headers=headers
|
||||
)
|
||||
|
||||
# Register with the ExceptionMiddleware
|
||||
self.router._exception_handlers = getattr(
|
||||
self.router, "_exception_handlers", {}
|
||||
)
|
||||
self.router._exception_handlers[exception_cls] = _handler
|
||||
# Also register on the ASGI app chain
|
||||
from starlette.middleware.exceptions import ExceptionMiddleware as EM
|
||||
|
||||
app = self.app
|
||||
while app is not None:
|
||||
if isinstance(app, EM):
|
||||
app.add_exception_handler(exception_cls, _handler)
|
||||
break
|
||||
app = getattr(app, "app", None)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def schema(self, name, **options):
|
||||
"""
|
||||
Decorator for creating new routes around function and class definitions.
|
||||
|
||||
Usage::
|
||||
|
||||
from marshmallow import Schema, fields
|
||||
@api.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
@@ -171,11 +222,12 @@ class API:
|
||||
"""Given a path portion of a URL, tests that it matches against any registered route.
|
||||
|
||||
:param path: The path portion of a URL, to test all known routes against.
|
||||
"""
|
||||
""" # noqa: E501 (Line too long)
|
||||
for route in self.router.routes:
|
||||
match, _ = route.matches(path)
|
||||
if match:
|
||||
return route
|
||||
return None
|
||||
|
||||
def add_route(
|
||||
self,
|
||||
@@ -187,16 +239,18 @@ class API:
|
||||
check_existing=True,
|
||||
websocket=False,
|
||||
before_request=False,
|
||||
methods=None,
|
||||
):
|
||||
"""Adds a route to the API.
|
||||
|
||||
:param route: A string representation of the route.
|
||||
:param endpoint: The endpoint for the route -- can be a callable, 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 static: If ``True``, and no endpoint was passed, render "static/index.html".
|
||||
Also, it will become a default route.
|
||||
:param methods: Optional list of HTTP methods (e.g. ``["GET", "POST"]``).
|
||||
""" # noqa: E501
|
||||
|
||||
# Path
|
||||
if static:
|
||||
assert self.static_dir is not None
|
||||
if not endpoint:
|
||||
@@ -210,27 +264,35 @@ class API:
|
||||
websocket=websocket,
|
||||
before_request=before_request,
|
||||
check_existing=check_existing,
|
||||
methods=methods,
|
||||
)
|
||||
|
||||
async def _static_response(self, req, resp):
|
||||
assert self.static_dir is not None
|
||||
|
||||
index = (self.static_dir / "index.html").resolve()
|
||||
if os.path.exists(index):
|
||||
with open(index, "r") as f:
|
||||
resp.html = f.read()
|
||||
if index.exists():
|
||||
resp.html = index.read_text()
|
||||
else:
|
||||
resp.status_code = status_codes.HTTP_404
|
||||
resp.status_code = status_codes.HTTP_404 # type: ignore[attr-defined]
|
||||
resp.text = "Not found."
|
||||
|
||||
def redirect(
|
||||
self, resp, location, *, set_text=True, status_code=status_codes.HTTP_301
|
||||
self,
|
||||
resp,
|
||||
location,
|
||||
*,
|
||||
set_text=True,
|
||||
status_code=status_codes.HTTP_301, # type: ignore[attr-defined]
|
||||
):
|
||||
"""Redirects a given response to a given location.
|
||||
"""
|
||||
Redirects a given response to a given location.
|
||||
|
||||
:param resp: The Response to mutate.
|
||||
:param location: The location of the redirect.
|
||||
:param set_text: If ``True``, sets the Redirect body content automatically.
|
||||
:param status_code: an `API.status_codes` attribute, or an integer, representing the HTTP status code of the redirect.
|
||||
:param status_code: an `API.status_codes` attribute, or an integer,
|
||||
representing the HTTP status code of the redirect.
|
||||
"""
|
||||
resp.redirect(location, set_text=set_text, status_code=status_code)
|
||||
|
||||
@@ -263,7 +325,7 @@ class API:
|
||||
:param handler: The function to run. Can be either a function or a coroutine.
|
||||
"""
|
||||
|
||||
self.router.lifespan_handler.add_event_handler(event_type, handler)
|
||||
self.router.add_event_handler(event_type, handler)
|
||||
|
||||
def route(self, route=None, **options):
|
||||
"""Decorator for creating new routes around function and class definitions.
|
||||
@@ -282,26 +344,50 @@ class API:
|
||||
|
||||
return decorator
|
||||
|
||||
def graphql(self, route="/graphql", *, schema):
|
||||
"""Mount a GraphQL API at the given route.
|
||||
|
||||
Usage::
|
||||
|
||||
import graphene
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
def resolve_hello(self, info, name):
|
||||
return f"Hello {name}"
|
||||
|
||||
api.graphql("/graphql", schema=graphene.Schema(query=Query))
|
||||
|
||||
:param route: The URL path for the GraphQL endpoint.
|
||||
:param schema: A Graphene schema instance.
|
||||
"""
|
||||
from .ext.graphql import GraphQLView
|
||||
|
||||
self.add_route(route, GraphQLView(api=self, schema=schema))
|
||||
|
||||
def mount(self, route, app):
|
||||
"""Mounts an WSGI / ASGI application at a given route.
|
||||
|
||||
:param route: String representation of the route to be used (shouldn't be parameterized).
|
||||
:param route: String representation of the route to be used
|
||||
(shouldn't be parameterized).
|
||||
:param app: The other WSGI / ASGI app.
|
||||
"""
|
||||
self.router.apps.update({route: app})
|
||||
|
||||
def session(self, base_url="http://;"):
|
||||
"""Testing HTTP client. Returns a Requests session object, able to send HTTP requests to the Responder application.
|
||||
"""Testing HTTP client. Returns a Starlette TestClient instance,
|
||||
able to send HTTP requests to the Responder application.
|
||||
|
||||
:param base_url: The URL to mount the connection adaptor to.
|
||||
:param base_url: The base URL for the test client.
|
||||
"""
|
||||
|
||||
if self._session is None:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
self._session = TestClient(self, base_url=base_url)
|
||||
return self._session
|
||||
|
||||
def url_for(self, endpoint, **params):
|
||||
# TODO: Absolute_url
|
||||
"""Given an endpoint, returns a rendered URL for its route.
|
||||
|
||||
:param endpoint: The route endpoint you're searching for.
|
||||
@@ -310,48 +396,49 @@ class API:
|
||||
return self.router.url_for(endpoint, **params)
|
||||
|
||||
def template(self, filename, *args, **kwargs):
|
||||
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template, 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``.
|
||||
r"""Render a Jinja2 template file with the provided values.
|
||||
|
||||
:param filename: The filename of the jinja2 template, in ``templates_dir``.
|
||||
:param *args: Data to pass into the template.
|
||||
:param *kwargs: Date to pass into the template.
|
||||
:param \*args: Data to pass into the template.
|
||||
:param \*\*kwargs: Data to pass into the template.
|
||||
"""
|
||||
return self.templates.render(filename, *args, **kwargs)
|
||||
|
||||
def template_string(self, source, *args, **kwargs):
|
||||
"""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 source: The template to use.
|
||||
:param *args: Data to pass into the template.
|
||||
:param **kwargs: Data to pass into the template.
|
||||
r"""Render a Jinja2 template string with the provided values.
|
||||
|
||||
:param source: The template to use, a Jinja2 template string.
|
||||
:param \*args: Data to pass into the template.
|
||||
:param \*\*kwargs: Data to pass into the template.
|
||||
"""
|
||||
return self.templates.render_string(source, *args, **kwargs)
|
||||
|
||||
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.
|
||||
"""
|
||||
Run 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 debug: Whether to run application in debug mode.
|
||||
:param options: Additional keyword arguments to send to ``uvicorn.run()``.
|
||||
"""
|
||||
""" # noqa: E501
|
||||
|
||||
if "PORT" in os.environ:
|
||||
if address is None:
|
||||
address = "0.0.0.0"
|
||||
address = "0.0.0.0" # noqa: S104
|
||||
port = int(os.environ["PORT"])
|
||||
|
||||
if address is None:
|
||||
address = "127.0.0.1"
|
||||
if port is None:
|
||||
port = 5042
|
||||
if debug:
|
||||
options["log_level"] = "debug"
|
||||
|
||||
def spawn():
|
||||
uvicorn.run(self, host=address, port=port, debug=debug, **options)
|
||||
|
||||
spawn()
|
||||
uvicorn.run(self, host=address, port=port, **options)
|
||||
|
||||
def run(self, **kwargs):
|
||||
if "debug" not in kwargs:
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import asyncio
|
||||
import functools
|
||||
import concurrent.futures
|
||||
import multiprocessing
|
||||
import traceback
|
||||
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
__all__ = ["BackgroundQueue"]
|
||||
|
||||
|
||||
class BackgroundQueue:
|
||||
def __init__(self, n=None):
|
||||
@@ -16,9 +18,6 @@ class BackgroundQueue:
|
||||
self.results = []
|
||||
|
||||
def run(self, f, *args, **kwargs):
|
||||
self.pool._max_workers = self.n
|
||||
self.pool._adjust_thread_count()
|
||||
|
||||
f = self.pool.submit(f, *args, **kwargs)
|
||||
self.results.append(f)
|
||||
return f
|
||||
@@ -27,7 +26,7 @@ class BackgroundQueue:
|
||||
def on_future_done(fs):
|
||||
try:
|
||||
fs.result()
|
||||
except:
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
def do_task(*args, **kwargs):
|
||||
@@ -39,6 +38,5 @@ class BackgroundQueue:
|
||||
|
||||
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)
|
||||
return await asyncio.create_task(func(*args, **kwargs))
|
||||
return await run_in_threadpool(func, *args, **kwargs)
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""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()
|
||||
+6
-1
@@ -1,3 +1,8 @@
|
||||
from .api import API
|
||||
from .models import Request, Response
|
||||
from .cli import cli
|
||||
|
||||
__all__ = [
|
||||
"API",
|
||||
"Request",
|
||||
"Response",
|
||||
]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .graphql import GraphQLView
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Responder CLI.
|
||||
|
||||
A web framework for Python.
|
||||
|
||||
Commands:
|
||||
run Start the application server
|
||||
build Build frontend assets using npm
|
||||
|
||||
Usage:
|
||||
responder
|
||||
responder run [--debug] [--limit-max-requests=] <target>
|
||||
responder build [<target>]
|
||||
responder --version
|
||||
|
||||
Options:
|
||||
-h --help Show this screen.
|
||||
-v --version Show version.
|
||||
--debug Enable debug mode with verbose logging.
|
||||
--limit-max-requests=<n> Maximum number of requests to handle before shutting down.
|
||||
|
||||
Arguments:
|
||||
<target> For run: Python module specifier (e.g., "app:api" loads api from app.py)
|
||||
Format: "module.submodule:variable_name" where variable_name is your API instance
|
||||
For build: Directory containing package.json (default: current directory)
|
||||
|
||||
Examples:
|
||||
responder run app:api # Run the 'api' instance from app.py
|
||||
responder run myapp/core.py:application # Run the 'application' instance from myapp/core.py
|
||||
responder build # Build frontend assets
|
||||
""" # noqa: E501
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import typing as t
|
||||
from pathlib import Path
|
||||
|
||||
import docopt
|
||||
|
||||
from responder.__version__ import __version__
|
||||
from responder.util.python import InvalidTarget, load_target
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cli() -> None:
|
||||
"""
|
||||
Main entry point for the Responder CLI.
|
||||
|
||||
Parses command line arguments and executes the appropriate command.
|
||||
Supports running the application, building assets, and displaying version info.
|
||||
"""
|
||||
args = docopt.docopt(__doc__, argv=None, version=__version__, options_first=False)
|
||||
setup_logging(args["--debug"])
|
||||
|
||||
target: t.Optional[str] = args["<target>"]
|
||||
build: bool = args["build"]
|
||||
debug: bool = args["--debug"]
|
||||
run: bool = args["run"]
|
||||
|
||||
if build:
|
||||
target_path = Path(target).resolve() if target else Path.cwd()
|
||||
if not target_path.is_dir() or not (target_path / "package.json").exists():
|
||||
logger.error(
|
||||
f"Invalid target directory or missing package.json: {target_path}"
|
||||
)
|
||||
sys.exit(1)
|
||||
npm_cmd = "npm.cmd" if platform.system() == "Windows" else "npm"
|
||||
try:
|
||||
logger.info("Starting frontend asset build")
|
||||
# S603, S607 are addressed by validating the target directory.
|
||||
subprocess.check_call( # noqa: S603, S607
|
||||
[npm_cmd, "run", "build"],
|
||||
cwd=target_path,
|
||||
timeout=300,
|
||||
)
|
||||
logger.info("Frontend asset build completed successfully")
|
||||
except FileNotFoundError:
|
||||
logger.error("npm not found. Please install Node.js and npm.")
|
||||
sys.exit(1)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Build failed with exit code {e.returncode}")
|
||||
sys.exit(1)
|
||||
|
||||
if run:
|
||||
if not target:
|
||||
logger.error("Target argument is required for run command")
|
||||
sys.exit(1)
|
||||
|
||||
# Maximum request limit. Terminating afterward. Suitable for software testing.
|
||||
limit_max_requests = args["--limit-max-requests"]
|
||||
if limit_max_requests is not None:
|
||||
try:
|
||||
limit_max_requests = int(limit_max_requests)
|
||||
if limit_max_requests <= 0:
|
||||
logger.error("limit-max-requests must be a positive integer")
|
||||
sys.exit(1)
|
||||
except ValueError:
|
||||
logger.error("limit-max-requests must be a valid integer")
|
||||
sys.exit(1)
|
||||
|
||||
# Load application from target.
|
||||
try:
|
||||
api = load_target(target=target)
|
||||
except InvalidTarget as ex:
|
||||
raise ValueError(
|
||||
f"{ex}. "
|
||||
"Use either a Python module entrypoint specification, "
|
||||
"a filesystem path, or a remote URL. "
|
||||
"See also https://responder.kennethreitz.org/cli.html."
|
||||
) from ex
|
||||
|
||||
# Launch Responder API server (uvicorn).
|
||||
api.run(debug=debug, limit_max_requests=limit_max_requests)
|
||||
|
||||
|
||||
def setup_logging(debug: bool) -> None:
|
||||
"""
|
||||
Configure logging based on debug mode.
|
||||
|
||||
Args:
|
||||
debug: When True, sets logging level to DEBUG; otherwise, sets to INFO
|
||||
"""
|
||||
log_level = logging.DEBUG if debug else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=log_level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
@@ -1,7 +1,4 @@
|
||||
import json
|
||||
from functools import partial
|
||||
|
||||
from graphql_server import default_format_error, encode_execution_results, json_encode
|
||||
|
||||
from .templates import GRAPHIQL
|
||||
|
||||
@@ -12,24 +9,19 @@ class GraphQLView:
|
||||
self.schema = schema
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_graphql_query(req):
|
||||
# TODO: Get variables and operation_name from form data, params, request text?
|
||||
|
||||
async def _resolve_graphql_query(req, resp):
|
||||
if "json" in req.mimetype:
|
||||
json_media = await req.media("json")
|
||||
if "query" not in json_media:
|
||||
resp.status_code = 400
|
||||
resp.media = {"errors": ["'query' key is required in the JSON payload"]}
|
||||
return None, None, None
|
||||
return (
|
||||
json_media["query"],
|
||||
json_media.get("variables"),
|
||||
json_media.get("operationName"),
|
||||
)
|
||||
|
||||
# Support query/q in 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
|
||||
@@ -37,34 +29,38 @@ class GraphQLView:
|
||||
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
|
||||
return await req.text, None, None
|
||||
|
||||
async def graphql_response(self, req, resp, schema):
|
||||
async def graphql_response(self, req, resp):
|
||||
show_graphiql = req.method == "get" and req.accepts("text/html")
|
||||
|
||||
if show_graphiql:
|
||||
resp.content = self.api.templates.render_string(
|
||||
GRAPHIQL, endpoint=req.url.path
|
||||
)
|
||||
return
|
||||
return None
|
||||
|
||||
query, variables, operation_name = await self._resolve_graphql_query(req, resp)
|
||||
if query is None:
|
||||
return None
|
||||
|
||||
query, variables, operation_name = await self._resolve_graphql_query(req)
|
||||
context = {"request": req, "response": resp}
|
||||
result = schema.execute(
|
||||
result = self.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)
|
||||
|
||||
response_data = {}
|
||||
if result.errors:
|
||||
response_data["errors"] = [{"message": str(e)} for e in result.errors]
|
||||
if result.data is not None:
|
||||
response_data["data"] = result.data
|
||||
|
||||
resp.media = response_data
|
||||
status_code = 200 if not result.errors else 400
|
||||
return (query, json.dumps(response_data), status_code)
|
||||
|
||||
async def on_request(self, req, resp):
|
||||
await self.graphql_response(req, resp, self.schema)
|
||||
await self.graphql_response(req, resp)
|
||||
|
||||
async def __call__(self, req, resp):
|
||||
await self.on_request(req, resp)
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
# ruff: noqa: E501
|
||||
GRAPHIQL = """
|
||||
{% set GRAPHIQL_VERSION = '0.12.0' %}
|
||||
{% set GRAPHIQL_VERSION = '3.0.6' %}
|
||||
{% set REACT_VERSION = '18.2.0' %}
|
||||
|
||||
<!--
|
||||
* Copyright (c) Facebook, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This source code is licensed under the license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -22,123 +17,17 @@ GRAPHIQL = """
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!--
|
||||
This GraphiQL example depends on Promise and fetch, which are available in
|
||||
modern browsers, but can be "polyfilled" for older browsers.
|
||||
GraphiQL itself depends on React DOM.
|
||||
If you do not want to rely on a CDN, you can host these files locally or
|
||||
include them directly in your favored resource bunder.
|
||||
-->
|
||||
<link href="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.css" rel="stylesheet"/>
|
||||
<script src="//cdn.jsdelivr.net/npm/whatwg-fetch@2.0.3/fetch.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/react@16.2.0/umd/react.production.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/react-dom@16.2.0/umd/react-dom.production.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.js"></script>
|
||||
<link href="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="graphiql">Loading...</div>
|
||||
<script crossorigin src="//cdn.jsdelivr.net/npm/react@{{ REACT_VERSION }}/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="//cdn.jsdelivr.net/npm/react-dom@{{ REACT_VERSION }}/umd/react-dom.production.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.js"></script>
|
||||
<script>
|
||||
|
||||
/**
|
||||
* This GraphiQL example illustrates how to use some of GraphiQL's props
|
||||
* in order to enable reading and updating the URL parameters, making
|
||||
* link sharing of queries a little bit easier.
|
||||
*
|
||||
* This is only one example of this kind of feature, GraphiQL exposes
|
||||
* various React params to enable interesting integrations.
|
||||
*/
|
||||
|
||||
// Parse the search string to get url parameters.
|
||||
var search = window.location.search;
|
||||
var parameters = {};
|
||||
search.substr(1).split('&').forEach(function (entry) {
|
||||
var eq = entry.indexOf('=');
|
||||
if (eq >= 0) {
|
||||
parameters[decodeURIComponent(entry.slice(0, eq))] =
|
||||
decodeURIComponent(entry.slice(eq + 1));
|
||||
}
|
||||
});
|
||||
|
||||
// if variables was provided, try to format it.
|
||||
if (parameters.variables) {
|
||||
try {
|
||||
parameters.variables =
|
||||
JSON.stringify(JSON.parse(parameters.variables), null, 2);
|
||||
} catch (e) {
|
||||
// Do nothing, we want to display the invalid JSON as a string, rather
|
||||
// than present an error.
|
||||
}
|
||||
}
|
||||
|
||||
// When the query and variables string is edited, update the URL bar so
|
||||
// that it can be easily shared
|
||||
function onEditQuery(newQuery) {
|
||||
parameters.query = newQuery;
|
||||
updateURL();
|
||||
}
|
||||
|
||||
function onEditVariables(newVariables) {
|
||||
parameters.variables = newVariables;
|
||||
updateURL();
|
||||
}
|
||||
|
||||
function onEditOperationName(newOperationName) {
|
||||
parameters.operationName = newOperationName;
|
||||
updateURL();
|
||||
}
|
||||
|
||||
function updateURL() {
|
||||
var newSearch = '?' + Object.keys(parameters).filter(function (key) {
|
||||
return Boolean(parameters[key]);
|
||||
}).map(function (key) {
|
||||
return encodeURIComponent(key) + '=' +
|
||||
encodeURIComponent(parameters[key]);
|
||||
}).join('&');
|
||||
history.replaceState(null, null, newSearch);
|
||||
}
|
||||
|
||||
// Defines a GraphQL fetcher using the fetch API. You're not required to
|
||||
// use fetch, and could instead implement graphQLFetcher however you like,
|
||||
// as long as it returns a Promise or Observable.
|
||||
function graphQLFetcher(graphQLParams) {
|
||||
// This example expects a GraphQL server at the path /graphql.
|
||||
// Change this to point wherever you host your GraphQL server.
|
||||
return fetch('{{ endpoint }}', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(graphQLParams),
|
||||
credentials: 'include',
|
||||
}).then(function (response) {
|
||||
return response.text();
|
||||
}).then(function (responseBody) {
|
||||
try {
|
||||
return JSON.parse(responseBody);
|
||||
} catch (error) {
|
||||
return responseBody;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Render <GraphiQL /> into the body.
|
||||
// See the README in the top level of this module to learn more about
|
||||
// how you can customize GraphiQL by providing different values or
|
||||
// additional child elements.
|
||||
ReactDOM.render(
|
||||
React.createElement(GraphiQL, {
|
||||
fetcher: graphQLFetcher,
|
||||
query: parameters.query,
|
||||
variables: parameters.variables,
|
||||
operationName: parameters.operationName,
|
||||
onEditQuery: onEditQuery,
|
||||
onEditVariables: onEditVariables,
|
||||
onEditOperationName: onEditOperationName
|
||||
}),
|
||||
document.getElementById('graphiql')
|
||||
);
|
||||
const fetcher = GraphiQL.createFetcher({ url: '{{ endpoint }}' });
|
||||
const root = ReactDOM.createRoot(document.getElementById('graphiql'));
|
||||
root.render(React.createElement(GraphiQL, { fetcher: fetcher }));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import apistar
|
||||
import jinja2
|
||||
import yaml
|
||||
from apispec import APISpec, yaml_utils
|
||||
from apispec.ext.marshmallow import MarshmallowPlugin
|
||||
|
||||
from responder.statics import DEFAULT_API_THEME
|
||||
from responder.staticfiles import StaticFiles
|
||||
from responder import status_codes
|
||||
from responder.statics import API_THEMES, DEFAULT_OPENAPI_THEME
|
||||
from responder.templates import Templates
|
||||
|
||||
|
||||
class Schema:
|
||||
class OpenAPISchema:
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
@@ -22,11 +18,12 @@ class Schema:
|
||||
description=None,
|
||||
terms_of_service=None,
|
||||
contact=None,
|
||||
license=None,
|
||||
license=None, # noqa: A002
|
||||
openapi=None,
|
||||
openapi_route="/schema.yml",
|
||||
docs_route="/docs/",
|
||||
static_route="/static",
|
||||
openapi_theme=DEFAULT_OPENAPI_THEME,
|
||||
):
|
||||
self.app = app
|
||||
self.schemas = {}
|
||||
@@ -40,7 +37,9 @@ class Schema:
|
||||
self.openapi_version = openapi
|
||||
self.openapi_route = openapi_route
|
||||
|
||||
self.docs_theme = DEFAULT_API_THEME
|
||||
self.docs_theme = (
|
||||
openapi_theme if openapi_theme in API_THEMES else DEFAULT_OPENAPI_THEME
|
||||
)
|
||||
self.docs_route = docs_route
|
||||
|
||||
self.plugins = [MarshmallowPlugin()] if plugins is None else plugins
|
||||
@@ -51,17 +50,13 @@ class Schema:
|
||||
if self.docs_route is not None:
|
||||
self.app.add_route(self.docs_route, self.docs_response)
|
||||
|
||||
theme_path = (
|
||||
Path(apistar.__file__).parent / "themes" / self.docs_theme / "static"
|
||||
).resolve()
|
||||
theme_path = (Path(__file__).parent / "docs").resolve()
|
||||
self.templates = Templates(directory=theme_path)
|
||||
|
||||
self.static_route = static_route
|
||||
|
||||
self.app.static_app.add_directory(theme_path)
|
||||
|
||||
@property
|
||||
def _apispec(self):
|
||||
|
||||
info = {}
|
||||
if self.description is not None:
|
||||
info["description"] = self.description
|
||||
@@ -82,9 +77,7 @@ class Schema:
|
||||
|
||||
for route in self.app.router.routes:
|
||||
if route.description:
|
||||
operations = yaml_utils.load_operations_from_docstring(
|
||||
route.description
|
||||
)
|
||||
operations = yaml_utils.load_operations_from_docstring(route.description)
|
||||
spec.path(path=route.route, operations=operations)
|
||||
|
||||
for name, schema in self.schemas.items():
|
||||
@@ -97,7 +90,7 @@ class Schema:
|
||||
return self._apispec.to_yaml()
|
||||
|
||||
def add_schema(self, name, schema, check_existing=True):
|
||||
"""Adds a mashmallow schema to the API specification."""
|
||||
"""Adds a marshmallow schema to the API specification."""
|
||||
if check_existing:
|
||||
assert name not in self.schemas
|
||||
|
||||
@@ -124,25 +117,10 @@ class Schema:
|
||||
|
||||
@property
|
||||
def docs(self):
|
||||
|
||||
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)
|
||||
|
||||
template = env.get_template("/".join([self.docs_theme, "index.html"]))
|
||||
|
||||
return template.render(
|
||||
document=document,
|
||||
langs=["javascript", "python"],
|
||||
code_style=None,
|
||||
static_url=self.static_url,
|
||||
return self.templates.render(
|
||||
f"{self.docs_theme}.html",
|
||||
title=self.title,
|
||||
version=self.version,
|
||||
schema_url="/schema.yml",
|
||||
)
|
||||
|
||||
@@ -155,6 +133,6 @@ class Schema:
|
||||
resp.html = self.docs
|
||||
|
||||
def schema_response(self, req, resp):
|
||||
resp.status_code = status_codes.HTTP_200
|
||||
resp.status_code = status_codes.HTTP_200 # type: ignore[attr-defined]
|
||||
resp.headers["Content-Type"] = "application/x-yaml"
|
||||
resp.content = self.openapi
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<title>{{ title }} {{ version }}</title>
|
||||
<!-- Embed elements Elements via Web Component -->
|
||||
<script src="https://unpkg.com/@stoplight/elements/web-components.min.js"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/@stoplight/elements/styles.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<elements-api
|
||||
apiDescriptionUrl="{{ schema_url }}"
|
||||
router="hash"
|
||||
layout="sidebar"
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Important: must specify -->
|
||||
<html>
|
||||
<head>
|
||||
<title>{{ title }} {{ version }}</title>
|
||||
<meta charset="utf-8" />
|
||||
<!-- Important: rapi-doc uses utf8 characters -->
|
||||
<script
|
||||
type="module"
|
||||
src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"
|
||||
></script>
|
||||
</head>
|
||||
<body>
|
||||
<rapi-doc spec-url="{{ schema_url }}" show-header="false"> </rapi-doc>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{ title }} {{ version }}</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url="{{ schema_url }}"></redoc>
|
||||
<script src="https://cdn.jsdelivr.net/npm/redoc@latest/bundles/redoc.standalone.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>{{ title }} {{ version }}</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href="https://unpkg.com/swagger-ui-dist/swagger-ui.css"
|
||||
/>
|
||||
<style>
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "{{ schema_url }}",
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
||||
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
|
||||
layout: "BaseLayout",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+101
-43
@@ -1,84 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import yaml
|
||||
from requests_toolbelt.multipart import decoder
|
||||
from python_multipart import MultipartParser
|
||||
|
||||
from .models import QueryDict
|
||||
|
||||
|
||||
class _PartData:
|
||||
__slots__ = ("headers", "body", "header_field")
|
||||
|
||||
def __init__(self):
|
||||
self.headers: dict[str, str] = {}
|
||||
self.body = b""
|
||||
self.header_field = ""
|
||||
|
||||
|
||||
def _parse_multipart(content: bytes, content_type: str) -> list[_PartData]:
|
||||
"""Parse multipart form data into a list of parts with headers and body."""
|
||||
boundary = None
|
||||
for segment in content_type.split(";"):
|
||||
segment = segment.strip()
|
||||
if segment.startswith("boundary="):
|
||||
boundary = segment.split("=", 1)[1].strip('"')
|
||||
break
|
||||
|
||||
if boundary is None:
|
||||
return []
|
||||
|
||||
parts: list[_PartData] = []
|
||||
current: list[_PartData | None] = [None]
|
||||
|
||||
def on_part_begin():
|
||||
current[0] = _PartData()
|
||||
|
||||
def on_part_data(data, start, end):
|
||||
current[0].body += data[start:end] # type: ignore[union-attr]
|
||||
|
||||
def on_header_field(data, start, end):
|
||||
current[0].header_field = data[start:end].decode("utf-8") # type: ignore[union-attr]
|
||||
|
||||
def on_header_value(data, start, end):
|
||||
part = current[0]
|
||||
assert part is not None
|
||||
part.headers[part.header_field] = data[start:end].decode("utf-8")
|
||||
|
||||
def on_part_end():
|
||||
parts.append(current[0]) # type: ignore[arg-type]
|
||||
|
||||
parser = MultipartParser(
|
||||
boundary.encode(),
|
||||
{ # type: ignore[arg-type]
|
||||
"on_part_begin": on_part_begin,
|
||||
"on_part_data": on_part_data,
|
||||
"on_header_field": on_header_field,
|
||||
"on_header_value": on_header_value,
|
||||
"on_part_end": on_part_end,
|
||||
},
|
||||
)
|
||||
parser.write(content)
|
||||
parser.finalize()
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
async def format_form(r, encode=False):
|
||||
if encode:
|
||||
pass
|
||||
elif "multipart/form-data" in r.headers.get("Content-Type"):
|
||||
decode = decoder.MultipartDecoder(await r.content, r.mimetype)
|
||||
querys = list()
|
||||
for part in decode.parts:
|
||||
header = part.headers.get(b"Content-Disposition").decode("utf-8")
|
||||
text = part.text
|
||||
return None
|
||||
if "multipart/form-data" in r.headers.get("Content-Type"):
|
||||
parts = _parse_multipart(await r.content, r.mimetype)
|
||||
queries = []
|
||||
for part in parts:
|
||||
header = part.headers.get("Content-Disposition", "")
|
||||
text = part.body.decode("utf-8")
|
||||
|
||||
for section in [h.strip() for h in header.split(";")]:
|
||||
split = section.split("=")
|
||||
if len(split) > 1:
|
||||
key = split[1]
|
||||
key = key[1:-1]
|
||||
querys.append((key, text))
|
||||
queries.append((key, text))
|
||||
|
||||
content = urlencode(querys)
|
||||
content = urlencode(queries)
|
||||
return QueryDict(content)
|
||||
else:
|
||||
return QueryDict(await r.text)
|
||||
return QueryDict(await r.text)
|
||||
|
||||
|
||||
async def format_yaml(r, encode=False):
|
||||
if encode:
|
||||
r.headers.update({"Content-Type": "application/x-yaml"})
|
||||
return yaml.safe_dump(r.media)
|
||||
else:
|
||||
return yaml.safe_load(await r.content)
|
||||
return yaml.safe_load(await r.content)
|
||||
|
||||
|
||||
async def format_json(r, encode=False):
|
||||
if encode:
|
||||
r.headers.update({"Content-Type": "application/json"})
|
||||
return json.dumps(r.media)
|
||||
else:
|
||||
return json.loads(await r.content)
|
||||
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
|
||||
return None
|
||||
parts = _parse_multipart(await r.content, r.mimetype)
|
||||
dump = {}
|
||||
for part in parts:
|
||||
header = part.headers.get("Content-Disposition", "")
|
||||
mimetype = part.headers.get("Content-Type", None)
|
||||
filename = None
|
||||
formname = None
|
||||
|
||||
for section in [h.strip() for h in header.split(";")]:
|
||||
split = section.split("=")
|
||||
if len(split) > 1:
|
||||
key = split[0]
|
||||
value = split[1]
|
||||
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]
|
||||
|
||||
value = value[1:-1]
|
||||
if key == "filename":
|
||||
filename = value
|
||||
elif key == "name":
|
||||
formname = value
|
||||
|
||||
if key == "filename":
|
||||
filename = value
|
||||
elif key == "name":
|
||||
formname = value
|
||||
if formname is None:
|
||||
continue
|
||||
|
||||
if mimetype is None:
|
||||
dump[formname] = part.content
|
||||
else:
|
||||
dump[formname] = {
|
||||
"filename": filename,
|
||||
"content": part.content,
|
||||
"content-type": mimetype.decode("utf-8"),
|
||||
}
|
||||
return dump
|
||||
if mimetype is None:
|
||||
dump[formname] = part.body
|
||||
else:
|
||||
dump[formname] = {
|
||||
"filename": filename,
|
||||
"content": part.body,
|
||||
"content-type": mimetype,
|
||||
}
|
||||
return dump
|
||||
|
||||
|
||||
def get_formats():
|
||||
|
||||
+127
-43
@@ -1,29 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import io
|
||||
import inspect
|
||||
import json
|
||||
import gzip
|
||||
from urllib.parse import parse_qs
|
||||
from base64 import b64decode
|
||||
from collections.abc import Callable
|
||||
from http.cookies import SimpleCookie
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import chardet
|
||||
import rfc3986
|
||||
import graphene
|
||||
import yaml
|
||||
__all__ = ["Request", "Response", "QueryDict"]
|
||||
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
from requests.cookies import RequestsCookieJar
|
||||
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.requests import Request as StarletteRequest, State
|
||||
try:
|
||||
import chardet
|
||||
except ImportError:
|
||||
chardet = None # type: ignore[assignment]
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
from starlette.requests import State
|
||||
from starlette.responses import (
|
||||
Response as StarletteResponse,
|
||||
)
|
||||
from starlette.responses import (
|
||||
StreamingResponse as StarletteStreamingResponse,
|
||||
)
|
||||
|
||||
from .status_codes import HTTP_200, HTTP_301
|
||||
from .statics import DEFAULT_ENCODING
|
||||
from .status_codes import HTTP_301 # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class CaseInsensitiveDict(dict):
|
||||
"""A case-insensitive dict for HTTP headers."""
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
super().__setitem__(key.lower(), value)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return super().__getitem__(key.lower())
|
||||
|
||||
def __contains__(self, key):
|
||||
return super().__contains__(key.lower())
|
||||
|
||||
def get(self, key, default=None):
|
||||
return super().get(key.lower(), default)
|
||||
|
||||
def update(self, other=None, **kwargs):
|
||||
if other:
|
||||
for key, value in other.items():
|
||||
self[key] = value
|
||||
for key, value in kwargs.items():
|
||||
self[key] = value
|
||||
|
||||
|
||||
class QueryDict(dict):
|
||||
@@ -112,7 +134,7 @@ class Request:
|
||||
self.api = api
|
||||
self._content = None
|
||||
|
||||
headers = CaseInsensitiveDict()
|
||||
headers: CaseInsensitiveDict = CaseInsensitiveDict()
|
||||
for key, value in self._starlette.headers.items():
|
||||
headers[key] = value
|
||||
|
||||
@@ -133,6 +155,11 @@ class Request:
|
||||
def mimetype(self):
|
||||
return self.headers.get("Content-Type", "")
|
||||
|
||||
@property
|
||||
def is_json(self):
|
||||
"""Returns ``True`` if the request content type is JSON."""
|
||||
return "json" in self.mimetype
|
||||
|
||||
@property
|
||||
def method(self):
|
||||
"""The incoming HTTP method used for the request, lower-cased."""
|
||||
@@ -146,20 +173,20 @@ class Request:
|
||||
@property
|
||||
def url(self):
|
||||
"""The parsed URL of the Request."""
|
||||
return rfc3986.urlparse(self.full_url)
|
||||
return urlparse(self.full_url)
|
||||
|
||||
@property
|
||||
def cookies(self):
|
||||
"""The cookies sent in the Request, as a dictionary."""
|
||||
if self._cookies is None:
|
||||
cookies = RequestsCookieJar()
|
||||
cookies = {}
|
||||
cookie_header = self.headers.get("Cookie", "")
|
||||
|
||||
bc = SimpleCookie(cookie_header)
|
||||
bc: SimpleCookie = SimpleCookie(cookie_header)
|
||||
for key, morsel in bc.items():
|
||||
cookies[key] = morsel.value
|
||||
|
||||
self._cookies = cookies.get_dict()
|
||||
self._cookies = cookies
|
||||
|
||||
return self._cookies
|
||||
|
||||
@@ -171,6 +198,16 @@ class Request:
|
||||
except AttributeError:
|
||||
return QueryDict({})
|
||||
|
||||
@property
|
||||
def path_params(self) -> dict:
|
||||
"""The path parameters extracted from the URL route."""
|
||||
return self._starlette.path_params
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
"""The client's address as a (host, port) named tuple, or None."""
|
||||
return self._starlette.client
|
||||
|
||||
@property
|
||||
def state(self) -> State:
|
||||
"""
|
||||
@@ -213,16 +250,23 @@ class Request:
|
||||
async def declared_encoding(self):
|
||||
if "Encoding" in self.headers:
|
||||
return self.headers["Encoding"]
|
||||
return None
|
||||
|
||||
@property
|
||||
async def apparent_encoding(self):
|
||||
"""The apparent encoding, provided by the chardet library. Must be awaited."""
|
||||
"""The apparent encoding, detected automatically. Must be awaited.
|
||||
|
||||
Uses chardet for detection if installed, otherwise falls back to UTF-8.
|
||||
"""
|
||||
declared_encoding = await self.declared_encoding
|
||||
|
||||
if declared_encoding:
|
||||
return declared_encoding
|
||||
|
||||
return chardet.detect(await self.content)["encoding"] or DEFAULT_ENCODING
|
||||
if chardet is not None:
|
||||
return chardet.detect(await self.content)["encoding"] or DEFAULT_ENCODING
|
||||
|
||||
return DEFAULT_ENCODING
|
||||
|
||||
@property
|
||||
def is_secure(self):
|
||||
@@ -232,20 +276,31 @@ class Request:
|
||||
"""Returns ``True`` if the incoming Request accepts the given ``content_type``."""
|
||||
return content_type in self.headers.get("Accept", [])
|
||||
|
||||
async def media(self, format=None):
|
||||
async def media(self, format: str | Callable = None): # noqa: A002
|
||||
"""Renders incoming json/yaml/form data as Python objects. Must be awaited.
|
||||
|
||||
:param format: The name of the format being used. Alternatively accepts a custom callable for the format type.
|
||||
:param format: The name of the format being used.
|
||||
Alternatively, accepts a custom callable for the format type.
|
||||
"""
|
||||
|
||||
if format is None:
|
||||
format = "yaml" if "yaml" in self.mimetype or "" else "json"
|
||||
format = "form" if "form" in self.mimetype or "" else format
|
||||
format = "yaml" if "yaml" in self.mimetype or "" else "json" # noqa: A001
|
||||
format = "form" if "form" in self.mimetype or "" else format # noqa: A001
|
||||
|
||||
formatter: Callable
|
||||
if isinstance(format, str):
|
||||
try:
|
||||
formatter = self.formats[format]
|
||||
except KeyError as ex:
|
||||
raise ValueError(f"Unable to process data in '{format}' format") from ex
|
||||
|
||||
elif callable(format):
|
||||
formatter = format
|
||||
|
||||
if format in self.formats:
|
||||
return await self.formats[format](self)
|
||||
else:
|
||||
return await format(self)
|
||||
raise TypeError(f"Invalid 'format' argument: {format}")
|
||||
|
||||
return await formatter(self)
|
||||
|
||||
|
||||
def content_setter(mimetype):
|
||||
@@ -279,24 +334,22 @@ class Response:
|
||||
|
||||
def __init__(self, req, *, formats):
|
||||
self.req = req
|
||||
self.status_code = None #: The HTTP Status Code to use for the Response.
|
||||
#: The HTTP Status Code to use for the Response.
|
||||
self.status_code: int | None = None
|
||||
self.content = None #: A bytes representation of the response body.
|
||||
self.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.media = None #: A Python object that will be content-negotiated and
|
||||
#: sent back to the client. Typically, in JSON formatting.
|
||||
self._stream = None
|
||||
self.headers = (
|
||||
{}
|
||||
) #: A Python dictionary of ``{key: value}``, representing the headers of the response.
|
||||
self.headers = {} #: 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.cookies: SimpleCookie = SimpleCookie() #: The cookies set in the Response
|
||||
self.session = (
|
||||
req.session
|
||||
) #: The cookie-based session data, in dict form, to add to the Response.
|
||||
|
||||
# Property or func/dec
|
||||
def stream(self, func, *args, **kwargs):
|
||||
assert inspect.isasyncgenfunction(func)
|
||||
|
||||
@@ -304,6 +357,25 @@ class Response:
|
||||
|
||||
return func
|
||||
|
||||
def file(self, path, *, content_type=None):
|
||||
"""Serve a file from disk as the response.
|
||||
|
||||
:param path: Path to the file to serve.
|
||||
:param content_type: Optional MIME type override.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(path)
|
||||
self.content = path.read_bytes()
|
||||
|
||||
if content_type:
|
||||
self.mimetype = content_type
|
||||
else:
|
||||
import mimetypes
|
||||
|
||||
guessed = mimetypes.guess_type(str(path))[0]
|
||||
self.mimetype = guessed or "application/octet-stream"
|
||||
|
||||
def redirect(self, location, *, set_text=True, status_code=HTTP_301):
|
||||
self.status_code = status_code
|
||||
if set_text:
|
||||
@@ -322,12 +394,13 @@ class Response:
|
||||
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)
|
||||
if isinstance(content, str):
|
||||
content = content.encode(self.encoding)
|
||||
return (content, headers)
|
||||
|
||||
for format in self.formats:
|
||||
if self.req.accepts(format):
|
||||
return (await self.formats[format](self, encode=True)), {}
|
||||
for format_ in self.formats:
|
||||
if self.req.accepts(format_):
|
||||
return (await self.formats[format_](self, encode=True)), {}
|
||||
|
||||
# Default to JSON anyway.
|
||||
return (
|
||||
@@ -371,12 +444,23 @@ class Response:
|
||||
if self.headers:
|
||||
headers.update(self.headers)
|
||||
|
||||
response_cls: type[StarletteResponse] | type[StarletteStreamingResponse]
|
||||
if self._stream is not None:
|
||||
response_cls = StarletteStreamingResponse
|
||||
else:
|
||||
response_cls = StarletteResponse
|
||||
|
||||
response = response_cls(body, status_code=self.status_code, headers=headers)
|
||||
response = response_cls(body, status_code=self.status_code_safe, headers=headers)
|
||||
self._prepare_cookies(response)
|
||||
|
||||
await response(scope, receive, send)
|
||||
|
||||
@property
|
||||
def ok(self):
|
||||
return 200 <= self.status_code_safe < 300
|
||||
|
||||
@property
|
||||
def status_code_safe(self) -> int:
|
||||
if self.status_code is None:
|
||||
raise RuntimeError("HTTP status code has not been defined")
|
||||
return self.status_code
|
||||
|
||||
+104
-37
@@ -1,24 +1,28 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import inspect
|
||||
import re
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
|
||||
__all__ = ["Route", "WebSocketRoute", "Router"]
|
||||
|
||||
from starlette.routing import Lifespan
|
||||
from starlette.middleware.wsgi import WSGIMiddleware
|
||||
from starlette.websockets import WebSocket, WebSocketClose
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.types import ASGIApp
|
||||
from starlette.websockets import WebSocket, WebSocketClose
|
||||
|
||||
from .models import Request, Response
|
||||
from . import status_codes
|
||||
from .formats import get_formats
|
||||
from .statics import DEFAULT_SESSION_COOKIE
|
||||
from .models import Request, Response
|
||||
|
||||
_UUID_RE = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
||||
|
||||
_CONVERTORS = {
|
||||
"int": (int, r"\d+"),
|
||||
"str": (str, r"[^/]+"),
|
||||
"float": (float, r"\d+(.\d+)?"),
|
||||
"path": (str, r".+"),
|
||||
"uuid": (str, _UUID_RE),
|
||||
}
|
||||
|
||||
PARAM_RE = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}")
|
||||
@@ -32,9 +36,9 @@ def compile_path(path):
|
||||
for match in PARAM_RE.finditer(path):
|
||||
param_name, convertor_type = match.groups(default="str")
|
||||
convertor_type = convertor_type.lstrip(":")
|
||||
assert (
|
||||
convertor_type in _CONVERTORS.keys()
|
||||
), f"Unknown path convertor '{convertor_type}'"
|
||||
assert convertor_type in _CONVERTORS.keys(), (
|
||||
f"Unknown path convertor '{convertor_type}'"
|
||||
)
|
||||
convertor, convertor_re = _CONVERTORS[convertor_type]
|
||||
|
||||
path_re += path[idx : match.start()]
|
||||
@@ -58,19 +62,22 @@ class BaseRoute:
|
||||
|
||||
|
||||
class Route(BaseRoute):
|
||||
def __init__(self, route, endpoint, *, before_request=False):
|
||||
def __init__(self, route, endpoint, *, before_request=False, methods=None):
|
||||
assert route.startswith("/"), "Route path must start with '/'"
|
||||
self.route = route
|
||||
self.endpoint = endpoint
|
||||
self.before_request = before_request
|
||||
self.methods = {m.upper() for m in methods} if methods else None
|
||||
|
||||
self.path_re, self.param_convertors = compile_path(route)
|
||||
# Strip type annotations for URL generation (e.g. {id:int} -> {id})
|
||||
self._url_template = PARAM_RE.sub(r"{\1}", route)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Route {self.route!r}={self.endpoint!r}>"
|
||||
|
||||
def url(self, **params):
|
||||
return self.route.format(**params)
|
||||
return self._url_template.format(**params)
|
||||
|
||||
@property
|
||||
def endpoint_name(self):
|
||||
@@ -84,6 +91,9 @@ class Route(BaseRoute):
|
||||
if scope["type"] != "http":
|
||||
return False, {}
|
||||
|
||||
if self.methods and scope.get("method", "").upper() not in self.methods:
|
||||
return False, {}
|
||||
|
||||
path = scope["path"]
|
||||
match = self.path_re.match(path)
|
||||
|
||||
@@ -108,6 +118,10 @@ class Route(BaseRoute):
|
||||
await before_request(request, response)
|
||||
else:
|
||||
await run_in_threadpool(before_request, request, response)
|
||||
# If a before_request hook set a status code, short-circuit
|
||||
if response.status_code is not None:
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
views = []
|
||||
|
||||
@@ -121,14 +135,14 @@ class Route(BaseRoute):
|
||||
try:
|
||||
view = getattr(endpoint, method_name)
|
||||
views.append(view)
|
||||
except AttributeError:
|
||||
except AttributeError as ex:
|
||||
if on_request is None:
|
||||
raise HTTPException(status_code=status_codes.HTTP_405)
|
||||
raise HTTPException(status_code=status_codes.HTTP_405) from ex # type: ignore[attr-defined]
|
||||
else:
|
||||
views.append(self.endpoint)
|
||||
|
||||
for view in views:
|
||||
# "Monckey patch" for graphql: explicitly checking __call__
|
||||
# Check __call__ for class-based views (e.g. GraphQL)
|
||||
if asyncio.iscoroutinefunction(view) or asyncio.iscoroutinefunction(
|
||||
view.__call__
|
||||
):
|
||||
@@ -137,12 +151,11 @@ class Route(BaseRoute):
|
||||
await run_in_threadpool(view, request, response, **path_params)
|
||||
|
||||
if response.status_code is None:
|
||||
response.status_code = status_codes.HTTP_200
|
||||
response.status_code = status_codes.HTTP_200 # type: ignore[attr-defined]
|
||||
|
||||
await response(scope, receive, send)
|
||||
|
||||
def __eq__(self, other):
|
||||
# [TODO] compare to str ?
|
||||
return self.route == other.route and self.endpoint == other.endpoint
|
||||
|
||||
def __hash__(self):
|
||||
@@ -157,12 +170,13 @@ class WebSocketRoute(BaseRoute):
|
||||
self.before_request = before_request
|
||||
|
||||
self.path_re, self.param_convertors = compile_path(route)
|
||||
self._url_template = PARAM_RE.sub(r"{\1}", route)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Route {self.route!r}={self.endpoint!r}>"
|
||||
|
||||
def url(self, **params):
|
||||
return self.route.format(**params)
|
||||
return self._url_template.format(**params)
|
||||
|
||||
@property
|
||||
def endpoint_name(self):
|
||||
@@ -198,7 +212,6 @@ class WebSocketRoute(BaseRoute):
|
||||
await self.endpoint(ws)
|
||||
|
||||
def __eq__(self, other):
|
||||
# [TODO] compare to str ?
|
||||
return self.route == other.route and self.endpoint == other.endpoint
|
||||
|
||||
def __hash__(self):
|
||||
@@ -206,17 +219,20 @@ class WebSocketRoute(BaseRoute):
|
||||
|
||||
|
||||
class Router:
|
||||
def __init__(self, routes=None, default_response=None, before_requests=None):
|
||||
def __init__(
|
||||
self, routes=None, default_response=None, before_requests=None, lifespan=None
|
||||
):
|
||||
self.routes = [] if routes is None else list(routes)
|
||||
# [TODO] Make its own router
|
||||
self.apps = {}
|
||||
|
||||
self.apps: dict[str, ASGIApp] = {}
|
||||
self.default_endpoint = (
|
||||
self.default_response if default_response is None else default_response
|
||||
)
|
||||
self.lifespan_handler = Lifespan()
|
||||
self.before_requests = (
|
||||
{"http": [], "ws": []} if before_requests is None else before_requests
|
||||
)
|
||||
self.events = defaultdict(list)
|
||||
self._lifespan_handler = lifespan
|
||||
|
||||
def add_route(
|
||||
self,
|
||||
@@ -227,11 +243,13 @@ class Router:
|
||||
websocket=False,
|
||||
before_request=False,
|
||||
check_existing=False,
|
||||
methods=None,
|
||||
):
|
||||
""" Adds a route to the router.
|
||||
"""Adds a route to the router.
|
||||
:param route: A string representation of the route
|
||||
:param endpoint: The endpoint for the route -- can be callable, or class.
|
||||
:param default: If ``True``, all unknown requests will route to this view.
|
||||
:param methods: Optional list of HTTP methods (e.g. ["GET", "POST"]).
|
||||
"""
|
||||
if before_request:
|
||||
if websocket:
|
||||
@@ -241,9 +259,9 @@ class Router:
|
||||
return
|
||||
|
||||
if check_existing:
|
||||
assert not self.routes or route not in (
|
||||
item.route for item in self.routes
|
||||
), f"Route '{route}' already exists"
|
||||
assert not self.routes or route not in (item.route for item in self.routes), (
|
||||
f"Route '{route}' already exists"
|
||||
)
|
||||
|
||||
if default:
|
||||
self.default_endpoint = endpoint
|
||||
@@ -251,14 +269,27 @@ class Router:
|
||||
if websocket:
|
||||
route = WebSocketRoute(route, endpoint)
|
||||
else:
|
||||
route = Route(route, endpoint)
|
||||
route = Route(route, endpoint, methods=methods)
|
||||
|
||||
self.routes.append(route)
|
||||
|
||||
def mount(self, route, app):
|
||||
"""Mounts ASGI / WSGI applications at a given route
|
||||
"""
|
||||
self.apps.update(route, app)
|
||||
"""Mounts ASGI / WSGI applications at a given route"""
|
||||
self.apps.update({route: app})
|
||||
|
||||
def add_event_handler(self, event_type, handler):
|
||||
assert event_type in (
|
||||
"startup",
|
||||
"shutdown",
|
||||
), f"Only 'startup' and 'shutdown' events are supported, not {event_type}."
|
||||
self.events[event_type].append(handler)
|
||||
|
||||
async def trigger_event(self, event_type):
|
||||
for handler in self.events.get(event_type, []):
|
||||
if asyncio.iscoroutinefunction(handler):
|
||||
await handler()
|
||||
else:
|
||||
handler()
|
||||
|
||||
def before_request(self, endpoint, websocket=False):
|
||||
if websocket:
|
||||
@@ -267,7 +298,6 @@ class Router:
|
||||
self.before_requests.setdefault("http", []).append(endpoint)
|
||||
|
||||
def url_for(self, endpoint, **params):
|
||||
# TODO: Check for params
|
||||
for route in self.routes:
|
||||
if endpoint in (route.endpoint, route.endpoint.__name__):
|
||||
return route.url(**params)
|
||||
@@ -276,13 +306,13 @@ class Router:
|
||||
async def default_response(self, scope, receive, send):
|
||||
if scope["type"] == "websocket":
|
||||
websocket_close = WebSocketClose()
|
||||
await websocket_close(receive, send)
|
||||
await websocket_close(scope, receive, send)
|
||||
return
|
||||
|
||||
request = Request(scope, receive)
|
||||
response = Response(request, formats=get_formats())
|
||||
response = Response(request, formats=get_formats()) # noqa: F841
|
||||
|
||||
raise HTTPException(status_code=status_codes.HTTP_404)
|
||||
raise HTTPException(status_code=status_codes.HTTP_404) # type: ignore[attr-defined]
|
||||
|
||||
def _resolve_route(self, scope):
|
||||
for route in self.routes:
|
||||
@@ -292,11 +322,46 @@ class Router:
|
||||
return route
|
||||
return None
|
||||
|
||||
async def lifespan(self, scope, receive, send):
|
||||
message = await receive()
|
||||
assert message["type"] == "lifespan.startup"
|
||||
|
||||
if self._lifespan_handler is not None:
|
||||
# Modern lifespan context manager pattern
|
||||
try:
|
||||
ctx = self._lifespan_handler(scope.get("app"))
|
||||
await ctx.__aenter__()
|
||||
except BaseException:
|
||||
msg = traceback.format_exc()
|
||||
await send({"type": "lifespan.startup.failed", "message": msg})
|
||||
raise
|
||||
|
||||
await send({"type": "lifespan.startup.complete"})
|
||||
message = await receive()
|
||||
assert message["type"] == "lifespan.shutdown"
|
||||
|
||||
await ctx.__aexit__(None, None, None)
|
||||
else:
|
||||
# Legacy on_event("startup") / on_event("shutdown") pattern
|
||||
try:
|
||||
await self.trigger_event("startup")
|
||||
except BaseException:
|
||||
msg = traceback.format_exc()
|
||||
await send({"type": "lifespan.startup.failed", "message": msg})
|
||||
raise
|
||||
|
||||
await send({"type": "lifespan.startup.complete"})
|
||||
message = await receive()
|
||||
assert message["type"] == "lifespan.shutdown"
|
||||
await self.trigger_event("shutdown")
|
||||
|
||||
await send({"type": "lifespan.shutdown.complete"})
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
assert scope["type"] in ("http", "websocket", "lifespan")
|
||||
|
||||
if scope["type"] == "lifespan":
|
||||
await self.lifespan_handler(scope, receive, send)
|
||||
await self.lifespan(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope["path"]
|
||||
@@ -320,8 +385,10 @@ class Router:
|
||||
await app(scope, receive, send)
|
||||
return
|
||||
except TypeError:
|
||||
from a2wsgi import WSGIMiddleware
|
||||
|
||||
app = WSGIMiddleware(app)
|
||||
await app(scope, receive, send)
|
||||
return
|
||||
|
||||
await self.default_response(scope, receive, send)
|
||||
await self.default_endpoint(scope, receive, send)
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import typing
|
||||
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.staticfiles import StaticFiles as StarletteStaticFiles
|
||||
|
||||
|
||||
class StaticFiles(StaticFiles):
|
||||
"""I've created an issue to disccuss allowing multiple directories in starletter's `StaticFiles`.
|
||||
|
||||
https://github.com/encode/starlette/issues/625
|
||||
|
||||
I've also made a PR to add this method to starlette StaticFiles
|
||||
Once accepted we will remove this.
|
||||
|
||||
https://github.com/encode/starlette/pull/626
|
||||
"""
|
||||
class StaticFiles(StarletteStaticFiles):
|
||||
"""Extension to Starlette's StaticFiles with support for multiple directories."""
|
||||
|
||||
def add_directory(self, directory: str) -> None:
|
||||
self.all_directories = [*self.all_directories, *self.get_directories(directory)]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
API_THEMES = ["elements", "rapidoc", "redoc", "swagger_ui"]
|
||||
DEFAULT_ENCODING = "utf-8"
|
||||
DEFAULT_API_THEME = "swaggerui"
|
||||
DEFAULT_OPENAPI_THEME = "swagger_ui"
|
||||
DEFAULT_SESSION_COOKIE = "Responder-Session"
|
||||
DEFAULT_SECRET_KEY = "NOTASECRET"
|
||||
DEFAULT_SECRET_KEY = "NOTASECRET" # noqa: S105
|
||||
|
||||
DEFAULT_CORS_PARAMS = {
|
||||
"allow_origins": (),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# from: https://github.com/requests/requests/blob/master/requests/status_codes.py
|
||||
|
||||
codes = {
|
||||
# Informational.
|
||||
100: ("continue",),
|
||||
@@ -26,11 +24,7 @@ codes = {
|
||||
305: ("use_proxy",),
|
||||
306: ("switch_proxy",),
|
||||
307: ("temporary_redirect", "temporary_moved", "temporary"),
|
||||
308: (
|
||||
"permanent_redirect",
|
||||
"resume_incomplete",
|
||||
"resume",
|
||||
), # These 2 to be removed in 3.0
|
||||
308: ("permanent_redirect",),
|
||||
# Client Error.
|
||||
400: ("bad_request", "bad"),
|
||||
401: ("unauthorized",),
|
||||
|
||||
@@ -2,6 +2,8 @@ from contextlib import contextmanager
|
||||
|
||||
import jinja2
|
||||
|
||||
__all__ = ["Templates"]
|
||||
|
||||
|
||||
class Templates:
|
||||
def __init__(
|
||||
@@ -10,7 +12,7 @@ class Templates:
|
||||
self.directory = directory
|
||||
self._env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader([str(self.directory)]),
|
||||
autoescape=autoescape,
|
||||
autoescape=autoescape, # noqa: S701
|
||||
enable_async=enable_async,
|
||||
)
|
||||
self.default_context = {} if context is None else {**context}
|
||||
@@ -33,7 +35,7 @@ class Templates:
|
||||
:param template: The filename of the jinja2 template.
|
||||
:param **kwargs: Data to pass into the template.
|
||||
:param **kwargs: Data to pass into the template.
|
||||
"""
|
||||
""" # noqa: E501
|
||||
return self.get_template(template).render(*args, **kwargs)
|
||||
|
||||
@contextmanager
|
||||
@@ -54,6 +56,6 @@ class Templates:
|
||||
:param source: The template to use.
|
||||
:param *args, **kwargs: Data to pass into the template.
|
||||
:param **kwargs: Data to pass into the template.
|
||||
"""
|
||||
""" # noqa: E501
|
||||
template = self._env.from_string(source)
|
||||
return template.render(*args, **kwargs)
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# ruff: noqa: S603 # Subprocess call - output not captured
|
||||
# ruff: noqa: S607 # Starting a process with a partial executable path
|
||||
# Security considerations for subprocess usage:
|
||||
# 1. Only execute the 'responder' binary from PATH
|
||||
# 2. Validate all user inputs before passing to subprocess
|
||||
# 3. Use Path.resolve() to prevent path traversal
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResponderProgram:
|
||||
"""
|
||||
Utility class for managing Responder program execution.
|
||||
|
||||
This class provides methods for:
|
||||
- Locating the responder executable in PATH
|
||||
- Building frontend assets using npm
|
||||
|
||||
Example:
|
||||
>>> program_path = ResponderProgram.path()
|
||||
>>> build_status = ResponderProgram.build(Path("app_dir"))
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def path():
|
||||
name = "responder"
|
||||
if sys.platform == "win32":
|
||||
name = "responder.exe"
|
||||
program = shutil.which(name)
|
||||
if program is None:
|
||||
paths = os.environ.get("PATH", "").split(os.pathsep)
|
||||
raise RuntimeError(
|
||||
f"Could not find '{name}' executable in PATH. "
|
||||
f"Please install Responder with 'pip install --upgrade responder'. "
|
||||
f"Searched in: {', '.join(paths)}"
|
||||
)
|
||||
logger.debug(f"Found responder program: {program}")
|
||||
return program
|
||||
|
||||
@classmethod
|
||||
def build(cls, path: Path) -> int:
|
||||
"""
|
||||
Invoke `responder build` command.
|
||||
|
||||
Args:
|
||||
path: Path to the application to build
|
||||
|
||||
Returns:
|
||||
int: The return code from the build process
|
||||
|
||||
Raises:
|
||||
ValueError: If the path is invalid
|
||||
RuntimeError: If the responder executable is not found
|
||||
subprocess.SubprocessError: If the build process fails
|
||||
"""
|
||||
|
||||
if not isinstance(path, Path):
|
||||
raise ValueError(f"Expected a Path object, got {type(path).__name__}")
|
||||
if not path.exists():
|
||||
raise ValueError(f"Path does not exist: {path}")
|
||||
if not path.is_dir():
|
||||
raise FileNotFoundError(f"Path is not a directory: {path}")
|
||||
|
||||
command = [
|
||||
cls.path(),
|
||||
"build",
|
||||
str(path),
|
||||
]
|
||||
return subprocess.call(command)
|
||||
|
||||
|
||||
class ResponderServer(threading.Thread):
|
||||
"""
|
||||
A threaded wrapper around the `responder run` command for testing purposes.
|
||||
|
||||
This class allows running a Responder application in a separate thread,
|
||||
making it suitable for integration testing scenarios.
|
||||
|
||||
Args:
|
||||
target (str): The path to the Responder application to run
|
||||
port (int, optional): The port to run the server on. Defaults to 5042.
|
||||
limit_max_requests (int, optional): Maximum number of requests to handle
|
||||
before shutting down. Useful for testing scenarios.
|
||||
|
||||
Example:
|
||||
>>> server = ResponderServer("app.py", port=8000)
|
||||
>>> server.start()
|
||||
>>> # Run tests
|
||||
>>> server.stop()
|
||||
"""
|
||||
|
||||
def __init__(self, target: str, port: int = 5042, limit_max_requests: int = None):
|
||||
super().__init__()
|
||||
self._stopping = False
|
||||
|
||||
# Validate input variables.
|
||||
if not target or not isinstance(target, str):
|
||||
raise ValueError("Target must be a non-empty string")
|
||||
if not isinstance(port, int) or port < 1:
|
||||
raise ValueError("Port must be a positive integer")
|
||||
if limit_max_requests is not None and (
|
||||
not isinstance(limit_max_requests, int) or limit_max_requests < 1
|
||||
):
|
||||
raise ValueError("limit_max_requests must be a positive integer if specified")
|
||||
|
||||
# Check if port is available.
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("localhost", port))
|
||||
except OSError as ex:
|
||||
raise ValueError(f"Port {port} is already in use") from ex
|
||||
|
||||
# Instance variables after validation.
|
||||
self.target = target
|
||||
self.port = port
|
||||
self.limit_max_requests = limit_max_requests
|
||||
self.shutdown_timeout = 5 # seconds
|
||||
|
||||
# Allow the thread to be terminated when the main program exits.
|
||||
self.process: subprocess.Popen
|
||||
self.daemon = True
|
||||
self._process_lock = threading.Lock()
|
||||
|
||||
# Setup signal handlers.
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
|
||||
def run(self):
|
||||
command = [
|
||||
ResponderProgram.path(),
|
||||
"run",
|
||||
self.target,
|
||||
]
|
||||
if self.limit_max_requests is not None:
|
||||
command += [f"--limit-max-requests={self.limit_max_requests}"]
|
||||
|
||||
# Preserve existing environment
|
||||
env = os.environ.copy()
|
||||
|
||||
if self.port is not None:
|
||||
env["PORT"] = str(self.port)
|
||||
|
||||
with self._process_lock:
|
||||
self.process = subprocess.Popen(
|
||||
command,
|
||||
env=env,
|
||||
universal_newlines=True,
|
||||
)
|
||||
self.process.wait()
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
Gracefully stop the process (API).
|
||||
"""
|
||||
if self._stopping:
|
||||
return
|
||||
with self._process_lock:
|
||||
self._stop()
|
||||
|
||||
def _stop(self):
|
||||
"""
|
||||
Gracefully stop the process (impl).
|
||||
"""
|
||||
self._stopping = True
|
||||
if self.process and self.process.poll() is None:
|
||||
logger.info("Attempting to terminate server process...")
|
||||
self.process.terminate()
|
||||
try:
|
||||
# Wait for graceful shutdown.
|
||||
self.process.wait(timeout=self.shutdown_timeout)
|
||||
logger.info("Server process terminated gracefully")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
"Server process did not terminate gracefully, forcing kill"
|
||||
)
|
||||
self.process.kill() # Force kill if not terminated
|
||||
|
||||
def _signal_handler(self, signum, frame):
|
||||
"""
|
||||
Handle termination signals gracefully.
|
||||
"""
|
||||
logger.info("Received signal %d, shutting down...", signum)
|
||||
self.stop()
|
||||
|
||||
def wait_until_ready(self, timeout=30, request_timeout=1, delay=0.1) -> bool:
|
||||
"""
|
||||
Wait until the server is ready to accept connections.
|
||||
|
||||
Args:
|
||||
timeout (int, optional): Maximum time to wait in seconds. Defaults to 30.
|
||||
|
||||
Returns:
|
||||
bool: True if server is ready and accepting connections, False otherwise.
|
||||
"""
|
||||
start_time = time.time()
|
||||
last_error = None
|
||||
while time.time() - start_time < timeout:
|
||||
if not self.is_running():
|
||||
if self.process is None:
|
||||
logger.error("Server process was never started")
|
||||
else:
|
||||
returncode = self.process.poll()
|
||||
logger.error("Server process exited with code: %d", returncode)
|
||||
return False
|
||||
try:
|
||||
with socket.create_connection(
|
||||
("localhost", self.port), timeout=request_timeout
|
||||
):
|
||||
return True
|
||||
except (
|
||||
socket.timeout,
|
||||
ConnectionRefusedError,
|
||||
socket.gaierror,
|
||||
OSError,
|
||||
) as ex:
|
||||
last_error = ex
|
||||
logger.debug(f"Server not ready yet: {ex}")
|
||||
time.sleep(delay)
|
||||
logger.error(
|
||||
"Server failed to start within %d seconds. Last error: %s",
|
||||
timeout,
|
||||
last_error,
|
||||
)
|
||||
return False
|
||||
|
||||
def is_running(self):
|
||||
"""
|
||||
Check if the server process is still running.
|
||||
"""
|
||||
return self.process is not None and self.process.poll() is None
|
||||
@@ -0,0 +1,44 @@
|
||||
import logging
|
||||
import typing as t
|
||||
|
||||
from pueblo.sfa.core import InvalidTarget, SingleFileApplication
|
||||
|
||||
__all__ = [
|
||||
"InvalidTarget",
|
||||
"SingleFileApplication",
|
||||
"load_target",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_target(target: str, default_property: str = "api", method: str = "run") -> t.Any:
|
||||
"""
|
||||
Load Python code from a file path or module name.
|
||||
|
||||
Warning:
|
||||
This function executes arbitrary Python code. Ensure the target is from a trusted
|
||||
source to prevent security vulnerabilities.
|
||||
|
||||
Args:
|
||||
target: Module address (e.g., 'acme.app:foo'), file path (e.g., '/path/to/acme/app.py'),
|
||||
or URL.
|
||||
default_property: Name of the property to load if not specified in target (default: "api")
|
||||
method: Name of the method to invoke on the API instance (default: "run")
|
||||
|
||||
Returns:
|
||||
The API instance, loaded from the given property.
|
||||
|
||||
Raises:
|
||||
ValueError: If target format is invalid
|
||||
ImportError: If module cannot be imported
|
||||
AttributeError: If property or method is not found
|
||||
|
||||
Example:
|
||||
>>> api = load_target("myapp.api:server")
|
||||
>>> api.run()
|
||||
""" # noqa: E501
|
||||
|
||||
app = SingleFileApplication.from_spec(spec=target, default_property=default_property)
|
||||
app.load()
|
||||
return app.entrypoint
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user