├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── docs.yaml │ └── test.yaml ├── .gitignore ├── .readthedocs.yml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── Makefile ├── make.bat └── source │ ├── _static │ ├── custom.css │ ├── fonts │ │ └── 692185 │ │ │ ├── 005CED86771E6F899.eot │ │ │ ├── 00A4BE4586F361993.css │ │ │ ├── 046446108A9C4012D.css │ │ │ ├── 05C79D32FCC9C51A0.css │ │ │ ├── 07B3028D11D5A1878.css │ │ │ ├── 0AC552691F872135E.eot │ │ │ ├── 0B52E1CB4088B5DEF.css │ │ │ ├── 11DF858A6FFC9DF70.css │ │ │ ├── 13B223000FA5C8685.eot │ │ │ ├── 16B543D7FF26ED8B0.css │ │ │ ├── 196C9CB6EE09F9C87.eot │ │ │ ├── 22F9BA1EA3A789050.css │ │ │ ├── 253E2FC4CFE470ABE.css │ │ │ ├── 34448F455F4637FAE.css │ │ │ ├── 36CE8674C2969C023.css │ │ │ ├── 38E1928CA439EA8B1.css │ │ │ ├── 3938E30FCCC16A40D.css │ │ │ ├── 393BC2C22D281021A.eot │ │ │ ├── 395CE06A8C69E5D38.eot │ │ │ ├── 3A39878E22934F8AD.eot │ │ │ ├── 3A8BFAC68DBAFAE43.eot │ │ │ ├── 40351B0A9DF3B9622.eot │ │ │ ├── 40860C7F369388FE3.css │ │ │ ├── 498BCE09390668AE9.css │ │ │ ├── 4A801A74B6CEC6B76.eot │ │ │ ├── 4BB5B4844BF0A1875.css │ │ │ ├── 4FC1B9C72E25ACB98.css │ │ │ ├── 60FE49CEF587B82D3.eot │ │ │ ├── 715711BFDC3B6F0A1.css │ │ │ ├── 71DF7DE2F12CD232B.css │ │ │ ├── 74B8D26ED26CF75D5.css │ │ │ ├── 74CFCDB908279DAC2.css │ │ │ ├── 777143010DB6642D4.eot │ │ │ ├── 78DFDCB65B1F55769.css │ │ │ ├── 7E9ADDBCA2C8BD433.eot │ │ │ ├── 8CE05FA3739464866.css │ │ │ ├── 96D5C750E04949CB8.css │ │ │ ├── A4436C301A663F119.css │ │ │ ├── A96F219E64C7FE405.eot │ │ │ ├── A9A88AFAC61805CF6.css │ │ │ ├── AA83D0999C9464BC6.eot │ │ │ ├── B7B44228B57BA7808.css │ │ │ ├── B9040B1EB4DD5A23C.css │ │ │ ├── BBFEE8CE0B4A78181.css │ │ │ ├── C6C963850F2B23EF7.css │ │ │ ├── D34A6D5BEB30A3D93.eot │ │ │ ├── D3FEA31F226436F7E.css │ │ │ ├── D846106C3A83BE1AF.css │ │ │ ├── DD7AD6D5FDE05ABCA.eot │ │ │ ├── E0416EDBE90327601.eot │ │ │ ├── E39173F6B506EEF59.eot │ │ │ ├── EB335654CC32EEECB.css │ │ │ ├── EE098E2FDB7C3FD59.css │ │ │ ├── F0CC66E00773EB848.css │ │ │ ├── F3359BE670A39380E.css │ │ │ ├── F660DE0AB2F25A0AE.eot │ │ │ ├── FA5D2C248B6AEC96E.css │ │ │ ├── FBA2DF26930B5D39C.css │ │ │ ├── FDCC918E4493D6D80.css │ │ │ └── FEF51E0A8EF01CA90.css │ ├── konami.js │ └── responder.png │ ├── _templates │ ├── hacks.html │ ├── sidebarintro.html │ └── sidebarlogo.html │ ├── api.rst │ ├── backlog.md │ ├── changes.md │ ├── cli.rst │ ├── conf.py │ ├── deployment.rst │ ├── index.rst │ ├── quickstart.rst │ ├── sandbox.md │ ├── testing.rst │ └── tour.rst ├── examples ├── helloworld.py └── user.py ├── ext ├── Artboard 1-100.jpg ├── Artboard 1.png ├── Artboard 1@2x-100.jpg ├── assets │ ├── noun_https_1875421_000000.png │ └── noun_https_1875421_000000.svg ├── responder-logo.ai └── small.jpg ├── pyproject.toml ├── responder ├── __init__.py ├── __version__.py ├── api.py ├── background.py ├── core.py ├── ext │ ├── __init__.py │ ├── cli.py │ ├── graphql │ │ ├── __init__.py │ │ └── templates.py │ └── openapi │ │ ├── __init__.py │ │ └── docs │ │ ├── elements.html │ │ ├── rapidoc.html │ │ ├── redoc.html │ │ └── swagger_ui.html ├── formats.py ├── models.py ├── routes.py ├── staticfiles.py ├── statics.py ├── status_codes.py ├── templates.py └── util │ ├── __init__.py │ ├── cmd.py │ └── python.py ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── test_cli.py ├── test_encodings.py ├── test_graphql.py ├── test_models.py ├── test_responder.py ├── test_status_codes.py └── util.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: kennethreitz 2 | thanks_dev: kennethreitz 3 | custom: https://cash.app/$KennethReitz 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "monthly" 17 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: "Documentation" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: ~ 7 | workflow_dispatch: 8 | 9 | # Cancel redundant in-progress jobs. 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | 16 | documentation: 17 | name: "Documentation: Python ${{ matrix.python-version }} on ${{ matrix.os }}" 18 | runs-on: ${{ matrix.os }} 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | os: ["ubuntu-latest"] 23 | python-version: ["3.13"] 24 | env: 25 | UV_SYSTEM_PYTHON: true 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: Set up Python 31 | uses: actions/setup-python@v5 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | 35 | - name: Set up uv 36 | uses: astral-sh/setup-uv@v5 37 | with: 38 | version: "latest" 39 | enable-cache: true 40 | cache-suffix: ${{ matrix.python-version }} 41 | cache-dependency-glob: | 42 | pyproject.toml 43 | 44 | - name: Install package and documentation dependencies 45 | run: | 46 | uv pip install '.[develop,docs]' 47 | 48 | - name: Run link checker 49 | run: | 50 | poe docs-linkcheck 51 | 52 | - name: Build static HTML documentation 53 | run: | 54 | poe docs-html 55 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: "Tests" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: ~ 7 | workflow_dispatch: 8 | 9 | # Cancel redundant in-progress jobs. 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | 16 | test-full: 17 | name: "Full: Python ${{ matrix.python-version }} on ${{ matrix.os }}" 18 | runs-on: ${{ matrix.os }} 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | os: [ 23 | "ubuntu-20.04", 24 | "macos-13", 25 | "macos-latest", 26 | "windows-latest", 27 | ] 28 | python-version: [ 29 | "3.6", 30 | "3.7", 31 | "3.8", 32 | "3.9", 33 | "3.10", 34 | "3.11", 35 | "3.12", 36 | "3.13", 37 | "pypy3.10", 38 | ] 39 | 40 | exclude: 41 | 42 | # Exclude test matrix slots that are no longer supported by GHA runners. 43 | - os: 'ubuntu-20.04' 44 | python-version: '3.6' 45 | - os: 'macos-latest' 46 | python-version: '3.6' 47 | - os: 'macos-latest' 48 | python-version: '3.7' 49 | - os: 'macos-latest' 50 | python-version: '3.8' 51 | - os: 'macos-latest' 52 | python-version: '3.9' 53 | - os: 'macos-latest' 54 | python-version: '3.10' 55 | 56 | # Exclude Python 3.7 on Windows, because GHA fails on it. 57 | # 58 | # SyntaxError: Non-UTF-8 code starting with '\x83' in file 59 | # C:\hostedtoolcache\windows\Python\3.7.9\x64\Scripts\poe.exe 60 | # on line 2, but no encoding declared; 61 | # see http://python.org/dev/peps/pep-0263/ for details 62 | # 63 | # https://github.com/kennethreitz/responder/actions/runs/11526258626/job/32090071392?pr=546#step:6:73 64 | - os: 'windows-latest' 65 | python-version: '3.7' 66 | env: 67 | UV_SYSTEM_PYTHON: true 68 | 69 | steps: 70 | - uses: actions/checkout@v4 71 | - uses: actions/setup-node@v4 72 | with: 73 | node-version: 22 74 | 75 | - name: Set up Python 76 | uses: actions/setup-python@v5 77 | with: 78 | python-version: ${{ matrix.python-version }} 79 | architecture: x64 80 | cache: 'pip' 81 | cache-dependency-path: | 82 | pyproject.toml 83 | 84 | - name: Set up uv 85 | uses: astral-sh/setup-uv@v5 86 | with: 87 | version: "latest" 88 | enable-cache: true 89 | cache-suffix: ${{ matrix.python-version }} 90 | cache-dependency-glob: | 91 | pyproject.toml 92 | 93 | - name: Install and validate package 94 | run: | 95 | uv pip install '.[full,develop,test]' 96 | poe check 97 | 98 | 99 | test-minimal: 100 | name: "Minimal: Python ${{ matrix.python-version }} on ${{ matrix.os }}" 101 | runs-on: ${{ matrix.os }} 102 | strategy: 103 | fail-fast: false 104 | matrix: 105 | os: ["ubuntu-latest"] 106 | python-version: ["3.13"] 107 | env: 108 | UV_SYSTEM_PYTHON: true 109 | 110 | steps: 111 | - uses: actions/checkout@v4 112 | 113 | - name: Set up Python 114 | uses: actions/setup-python@v5 115 | with: 116 | python-version: ${{ matrix.python-version }} 117 | architecture: x64 118 | cache: 'pip' 119 | cache-dependency-path: | 120 | pyproject.toml 121 | 122 | - name: Set up uv 123 | uses: astral-sh/setup-uv@v5 124 | with: 125 | version: "latest" 126 | enable-cache: true 127 | cache-suffix: ${{ matrix.python-version }} 128 | cache-dependency-glob: | 129 | pyproject.toml 130 | 131 | - name: Install and validate package 132 | run: | 133 | uv pip install '.[develop,test]' 134 | poe check 135 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv* 2 | .vscode/ 3 | .cache 4 | .idea 5 | .python-version 6 | .coverage 7 | .pytest_cache 8 | .DS_Store 9 | coverage.xml 10 | .coverage* 11 | 12 | __pycache__ 13 | tests/__pycache__ 14 | 15 | build 16 | responder.egg-info/ 17 | dist/ 18 | app.py 19 | app2.py 20 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | 4 | # Details 5 | # - https://docs.readthedocs.io/en/stable/config-file/v2.html 6 | 7 | # Required 8 | version: 2 9 | 10 | build: 11 | os: "ubuntu-24.04" 12 | tools: 13 | python: "3.12" 14 | 15 | python: 16 | install: 17 | - method: pip 18 | path: . 19 | extra_requirements: 20 | - docs 21 | 22 | sphinx: 23 | configuration: docs/source/conf.py 24 | 25 | # Use standard HTML builder. 26 | builder: html 27 | 28 | # Fail on all warnings to avoid broken references. 29 | fail_on_warning: true 30 | 31 | # Optionally build your docs in additional formats such as PDF 32 | #formats: 33 | # - pdf 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and 6 | this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [v3.0.0] - 2024-11-xx 11 | 12 | ### Added 13 | 14 | - Platform: Added support for Python 3.10 - Python 3.13 15 | - Platform: Verified support for macOS and Windows 16 | - CLI: `responder run` now also accepts a filesystem path on its `` 17 | argument, enabling usage on single-file applications. Beforehand, only 18 | invocations of Python modules were possible. 19 | ```shell 20 | # Install Responder with CLI extension. 21 | pip install 'responder[cli]' 22 | 23 | # Start Responder application defined in Python module. 24 | responder run acme.app:api 25 | 26 | # Start Responder application defined in a single Python file. 27 | responder run examples/helloworld.py:api 28 | ``` 29 | - CLI: `responder run` now also accepts URLs. 30 | ```shell 31 | # Install Responder with CLI extension (full). 32 | pip install 'responder[cli-full]' 33 | 34 | # Start Responder application defined in Python module at remote location. 35 | responder run https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py 36 | ``` 37 | 38 | ### Changed 39 | 40 | - Core: Updated API requests from GET to POST 41 | - Extensions: All of CLI-, GraphQL-, and OpenAPI-Support 42 | modules are extensions to Responder now, to be found within the 43 | `responder.ext` module namespace. Their runtime dependencies 44 | must be installed explicitly using Python package extras. 45 | ```shell 46 | pip install 'responder[cli]' 47 | pip install 'responder[graphql]' 48 | pip install 'responder[openapi]' 49 | ``` 50 | - Extensions: They are no longer available through the package's 51 | top-level module namespace. From now on, import them explicitly 52 | from `responder.ext`. 53 | ```python 54 | from responder.ext.cli import cli 55 | from responder.ext.graphql import GraphQLView 56 | from responder.ext.openapi import OpenAPISchema 57 | ``` 58 | - Dependencies: Modernized and trimmed list of runtime dependencies 59 | - Dependencies: Switched from WhiteNoise to ServeStatic 60 | - Sandbox: Modernized development sandbox installation and documentation 61 | 62 | ### Removed 63 | 64 | - CLI: `responder run --build` ceased to exist, because `responder build` 65 | now also accepts an optional `` argument, that overlaps with 66 | the `` argument of `responder run`, but is semantically different, 67 | as the former accepts a filesystem directory to the `package.json` file, 68 | but the latter expects a Python entrypoint specification. 69 | - Platform: Removed support for EOL Python 3.6 70 | 71 | ### Fixed 72 | 73 | - Routing: Fixed dispatching `static_route=None` on Windows 74 | - uvicorn: Recent `uvicorn.run()` method lacks the `debug` argument. Now, 75 | using `--debug` will map to uvicorn's `log_level = "debug"`. 76 | - GraphQL: Improved dependency pinning to match Responder's needs 77 | 78 | ## [v2.0.5] - 2019-12-15 79 | 80 | ### Added 81 | 82 | - Update requirements to support python 3.8 83 | 84 | ## [v2.0.4] - 2019-11-19 85 | 86 | ### Fixed 87 | 88 | - Fix static app resolving 89 | 90 | ## [v2.0.3] - 2019-09-20 91 | 92 | ### Fixed 93 | 94 | - Fix template conflicts 95 | 96 | ## [v2.0.2] - 2019-09-20 97 | 98 | ### Fixed 99 | 100 | - Fix template conflicts 101 | 102 | ## [v2.0.1] - 2019-09-20 103 | 104 | ### Fixed 105 | 106 | - Fix template import 107 | 108 | ## [v2.0.0] - 2019-09-19 109 | 110 | ### Changed 111 | 112 | - Refactor Router and Schema 113 | 114 | ## [v1.3.2] - 2019-08-15 115 | 116 | ### Added 117 | 118 | - ASGI 3 support 119 | - CI tests for python 3.8-dev 120 | - Now requests have `state` a mapping object 121 | 122 | ### Deprecated 123 | 124 | - ASGI 2 125 | 126 | ## [v1.3.1] - 2019-04-28 127 | 128 | ### Added 129 | 130 | - Route params Converters 131 | - Add search for documentation pages 132 | 133 | ### Changed 134 | 135 | - Bump dependencies 136 | 137 | ## [v1.3.0] - 2019-02-22 138 | 139 | ### Fixed 140 | 141 | - Versioning issue 142 | - Multiple cookies. 143 | - Whitenoise returns not found. 144 | - Other bugfixes. 145 | 146 | ### Added 147 | 148 | - Stream support via `resp.stream`. 149 | - Cookie directives via `resp.set_cookie`. 150 | - Add `resp.html` to send HTML. 151 | - Other improvements. 152 | 153 | ## [v1.1.3] - 2019-01-12 154 | 155 | ### Changed 156 | 157 | - Refactor `_route_for` 158 | 159 | ### Fixed 160 | 161 | - Resolve startup/shutdwown events 162 | 163 | ## [v1.2.0] - 2018-12-29 164 | 165 | ### Added 166 | 167 | - Documentations 168 | 169 | ### Changed 170 | 171 | - Use Starlette's LifeSpan middleware 172 | - Update denpendencies 173 | 174 | ### Fixed 175 | 176 | - Fix route.is_class_based 177 | - Fix test_500 178 | - Typos 179 | 180 | ## [v1.1.2] - 2018-11-11 181 | 182 | ### Fixed 183 | 184 | - Minor fixes for Open API 185 | - Typos 186 | 187 | ## [v1.1.1] - 2018-10-29 188 | 189 | ### Changed 190 | 191 | - Run sync views in a threadpoolexecutor. 192 | 193 | ## [v1.1.0] - 2018-10-27 194 | 195 | ### Added 196 | 197 | - Support for `before_request`. 198 | 199 | ## [v1.0.5]- 2018-10-27 200 | 201 | ### Fixed 202 | 203 | - Fix sessions. 204 | 205 | ## [v1.0.4] - 2018-10-27 206 | 207 | ### Fixed 208 | 209 | - Potential bufix for cookies. 210 | 211 | ## [v1.0.3] - 2018-10-27 212 | 213 | ### Fixed 214 | 215 | - Bugfix for redirects. 216 | 217 | ## [v1.0.2] - 2018-10-27 218 | 219 | ### Changed 220 | 221 | - Improvement for static file hosting. 222 | 223 | ## [v1.0.1] - 2018-10-26 224 | 225 | ### Changed 226 | 227 | - Improve cors configuration settings. 228 | 229 | ## [v1.0.0] - 2018-10-26 230 | 231 | ### Changed 232 | 233 | - Move GraphQL support into a built-in plugin. 234 | 235 | ## [v0.3.3] - 2018-10-25 236 | 237 | ### Added 238 | 239 | - CORS support 240 | 241 | ### Changed 242 | 243 | - Improved exceptions. 244 | 245 | ## [v0.3.2] - 2018-10-25 246 | 247 | ### Changed 248 | 249 | - Subtle improvements. 250 | 251 | ## [v0.3.1] - 2018-10-24 252 | 253 | ### Fixed 254 | 255 | - Packaging fix. 256 | 257 | ## [v0.3.0] - 2018-10-24 258 | 259 | ### Changed 260 | 261 | - Interactive Documentation endpoint. 262 | - Minor improvements. 263 | 264 | ## [v0.2.3] - 2018-10-24 265 | 266 | ### Changed 267 | 268 | - Overall improvements. 269 | 270 | ## [v0.2.2] - 2018-10-23 271 | 272 | ### Added 273 | 274 | - Show traceback info when background tasks raise exceptions. 275 | 276 | ## [v0.2.1] - 2018-10-23 277 | 278 | ### Added 279 | 280 | - api.requests. 281 | 282 | ## [v0.2.0] - 2018-10-22 283 | 284 | ### Added 285 | 286 | - WebSocket support. 287 | 288 | ## [v0.1.6] - 2018-10-20 289 | 290 | ### Added 291 | 292 | - 500 support. 293 | 294 | ## [v0.1.5] - 2018-10-20 295 | 296 | ### Added 297 | 298 | - File upload support 299 | 300 | ### Changed 301 | 302 | - Improvements to sequential media reading. 303 | 304 | ## [v0.1.4] - 2018-10-19 305 | 306 | ### Fixed 307 | 308 | - Stability. 309 | 310 | ## [v0.1.3] - 2018-10-18 311 | 312 | ### Added 313 | 314 | - Sessions support. 315 | 316 | ## [v0.1.2] - 2018-10-18 317 | 318 | ### Added 319 | 320 | - Cookies support. 321 | 322 | ## [v0.1.1] - 2018-10-17 323 | 324 | ### Changed 325 | 326 | - Default routes. 327 | 328 | ## [v0.1.0] - 2018-10-17 329 | 330 | ### Added 331 | 332 | - Prototype of static application support. 333 | 334 | ## [v0.0.10] - 2018-10-17 335 | 336 | ### Fixed 337 | 338 | - Bugfix for async class-based views. 339 | 340 | ## [v0.0.9] - 2018-10-17 341 | 342 | ### Fixed 343 | 344 | - Bugfix for async class-based views. 345 | 346 | ## [v0.0.8] - 2018-10-17 347 | 348 | ### Added 349 | 350 | - GraphiQL Support. 351 | 352 | ### Changed 353 | 354 | - Improvement to route selection. 355 | 356 | ## [v0.0.7] - 2018-10-16 357 | 358 | ### Changed 359 | 360 | - Immutable Request object. 361 | 362 | ## [v0.0.6] - 2018-10-16 363 | 364 | ### Added 365 | 366 | - Ability to mount WSGI apps. 367 | - Supply content-type when serving up the schema. 368 | 369 | ## [v0.0.5] - 2018-10-15 370 | 371 | ### Added 372 | 373 | - OpenAPI Schema support. 374 | - Safe load/dump yaml. 375 | 376 | ## [v0.0.4] - 2018-10-15 377 | 378 | ### Added 379 | 380 | - Asynchronous support for data uploads. 381 | 382 | ### Fixed 383 | 384 | - Bug fixes. 385 | 386 | ## [v0.0.3] - 2018-10-13 387 | 388 | ### Fixed 389 | 390 | - Bug fixes. 391 | 392 | ## [v0.0.2] - 2018-10-13 393 | 394 | ### Changed 395 | 396 | - Switch to ASGI/Starlette. 397 | 398 | ## [v0.0.1] - 2018-10-12 399 | 400 | ### Added 401 | 402 | - Conception! 403 | 404 | [unreleased]: https://github.com/taoufik07/responder/compare/v2.0.5..HEAD 405 | [v2.0.5]: https://github.com/taoufik07/responder/compare/v2.0.4..v2.0.5 406 | [v2.0.4]: https://github.com/taoufik07/responder/compare/v2.0.3..v2.0.4 407 | [v2.0.3]: https://github.com/taoufik07/responder/compare/v2.0.2..v2.0.3 408 | [v2.0.2]: https://github.com/taoufik07/responder/compare/v2.0.1..v2.0.2 409 | [v2.0.1]: https://github.com/taoufik07/responder/compare/v2.0.0..v2.0.1 410 | [v2.0.0]: https://github.com/taoufik07/responder/compare/v1.3.2..v2.0.0 411 | [v1.3.2]: https://github.com/taoufik07/responder/compare/v1.3.1..v1.3.2 412 | [v1.3.1]: https://github.com/taoufik07/responder/compare/v1.3.0..v1.3.1 413 | [v1.3.0]: https://github.com/taoufik07/responder/compare/v1.2.0..v1.3.0 414 | [v1.2.0]: https://github.com/taoufik07/responder/compare/v1.1.3..v1.2.0 415 | [v1.1.3]: https://github.com/taoufik07/responder/compare/v1.1.2..v1.1.3 416 | [v1.1.2]: https://github.com/taoufik07/responder/compare/v1.1.1..v1.1.2 417 | [v1.1.1]: https://github.com/taoufik07/responder/compare/v1.1.0..v1.1.1 418 | [v1.1.0]: https://github.com/taoufik07/responder/compare/v1.0.5..v1.1.0 419 | [v1.0.5]: https://github.com/taoufik07/responder/compare/v1.0.4..v1.0.5 420 | [v1.0.4]: https://github.com/taoufik07/responder/compare/v1.0.3..v1.0.4 421 | [v1.0.3]: https://github.com/taoufik07/responder/compare/v1.0.2..v1.0.3 422 | [v1.0.2]: https://github.com/taoufik07/responder/compare/v1.0.1..v1.0.2 423 | [v1.0.1]: https://github.com/taoufik07/responder/compare/v1.0.0..v1.0.1 424 | [v1.0.0]: https://github.com/taoufik07/responder/compare/v0.3.3..v1.0.0 425 | [v0.3.3]: https://github.com/taoufik07/responder/compare/v0.3.2..v0.3.3 426 | [v0.3.2]: https://github.com/taoufik07/responder/compare/v0.3.1..v0.3.2 427 | [v0.3.1]: https://github.com/taoufik07/responder/compare/v0.3.0..v0.3.1 428 | [v0.3.0]: https://github.com/taoufik07/responder/compare/v0.2.3..v0.3.0 429 | [v0.2.3]: https://github.com/taoufik07/responder/compare/v0.2.2..v0.2.3 430 | [v0.2.2]: https://github.com/taoufik07/responder/compare/v0.2.1..v0.2.2 431 | [v0.2.1]: https://github.com/taoufik07/responder/compare/v0.2.0..v0.2.1 432 | [v0.2.0]: https://github.com/taoufik07/responder/compare/v0.1.6..v0.2.0 433 | [v0.1.6]: https://github.com/taoufik07/responder/compare/v0.1.5..v0.1.6 434 | [v0.1.5]: https://github.com/taoufik07/responder/compare/v0.1.4..v0.1.5 435 | [v0.1.4]: https://github.com/taoufik07/responder/compare/v0.1.3..v0.1.4 436 | [v0.1.3]: https://github.com/taoufik07/responder/compare/v0.1.2..v0.1.3 437 | [v0.1.2]: https://github.com/taoufik07/responder/compare/v0.1.1..v0.1.2 438 | [v0.1.1]: https://github.com/taoufik07/responder/compare/v0.1.0..v0.1.1 439 | [v0.1.0]: https://github.com/taoufik07/responder/compare/v0.0.10..v0.1.0 440 | [v0.0.10]: https://github.com/taoufik07/responder/compare/v0.0.9..v0.0.10 441 | [v0.0.9]: https://github.com/taoufik07/responder/compare/v0.0.8..v0.0.9 442 | [v0.0.8]: https://github.com/taoufik07/responder/compare/v0.0.7..v0.0.8 443 | [v0.0.7]: https://github.com/taoufik07/responder/compare/v0.0.6..v0.0.7 444 | [v0.0.6]: https://github.com/taoufik07/responder/compare/v0.0.5..v0.0.6 445 | [v0.0.5]: https://github.com/taoufik07/responder/compare/v0.0.4..v0.0.5 446 | [v0.0.4]: https://github.com/taoufik07/responder/compare/v0.0.3..v0.0.4 447 | [v0.0.3]: https://github.com/taoufik07/responder/compare/v0.0.2..v0.0.3 448 | [v0.0.2]: https://github.com/taoufik07/responder/compare/v0.0.1..v0.0.2 449 | [v0.0.1]: https://github.com/taoufik07/responder/compare/v0.0.0..v0.0.1 450 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | 3 | Apache License 4 | Version 2.0, January 2004 5 | http://www.apache.org/licenses/ 6 | 7 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 8 | 9 | 1. Definitions. 10 | 11 | "License" shall mean the terms and conditions for use, reproduction, 12 | and distribution as defined by Sections 1 through 9 of this document. 13 | 14 | "Licensor" shall mean the copyright owner or entity authorized by 15 | the copyright owner that is granting the License. 16 | 17 | "Legal Entity" shall mean the union of the acting entity and all 18 | other entities that control, are controlled by, or are under common 19 | control with that entity. For the purposes of this definition, 20 | "control" means (i) the power, direct or indirect, to cause the 21 | direction or management of such entity, whether by contract or 22 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 23 | outstanding shares, or (iii) beneficial ownership of such entity. 24 | 25 | "You" (or "Your") shall mean an individual or Legal Entity 26 | exercising permissions granted by this License. 27 | 28 | "Source" form shall mean the preferred form for making modifications, 29 | including but not limited to software source code, documentation 30 | source, and configuration files. 31 | 32 | "Object" form shall mean any form resulting from mechanical 33 | transformation or translation of a Source form, including but 34 | not limited to compiled object code, generated documentation, 35 | and conversions to other media types. 36 | 37 | "Work" shall mean the work of authorship, whether in Source or 38 | Object form, made available under the License, as indicated by a 39 | copyright notice that is included in or attached to the work 40 | (an example is provided in the Appendix below). 41 | 42 | "Derivative Works" shall mean any work, whether in Source or Object 43 | form, that is based on (or derived from) the Work and for which the 44 | editorial revisions, annotations, elaborations, or other modifications 45 | represent, as a whole, an original work of authorship. For the purposes 46 | of this License, Derivative Works shall not include works that remain 47 | separable from, or merely link (or bind by name) to the interfaces of, 48 | the Work and Derivative Works thereof. 49 | 50 | "Contribution" shall mean any work of authorship, including 51 | the original version of the Work and any modifications or additions 52 | to that Work or Derivative Works thereof, that is intentionally 53 | submitted to Licensor for inclusion in the Work by the copyright owner 54 | or by an individual or Legal Entity authorized to submit on behalf of 55 | the copyright owner. For the purposes of this definition, "submitted" 56 | means any form of electronic, verbal, or written communication sent 57 | to the Licensor or its representatives, including but not limited to 58 | communication on electronic mailing lists, source code control systems, 59 | and issue tracking systems that are managed by, or on behalf of, the 60 | Licensor for the purpose of discussing and improving the Work, but 61 | excluding communication that is conspicuously marked or otherwise 62 | designated in writing by the copyright owner as "Not a Contribution." 63 | 64 | "Contributor" shall mean Licensor and any individual or Legal Entity 65 | on behalf of whom a Contribution has been received by Licensor and 66 | subsequently incorporated within the Work. 67 | 68 | 2. Grant of Copyright License. Subject to the terms and conditions of 69 | this License, each Contributor hereby grants to You a perpetual, 70 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 71 | copyright license to reproduce, prepare Derivative Works of, 72 | publicly display, publicly perform, sublicense, and distribute the 73 | Work and such Derivative Works in Source or Object form. 74 | 75 | 3. Grant of Patent License. Subject to the terms and conditions of 76 | this License, each Contributor hereby grants to You a perpetual, 77 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 78 | (except as stated in this section) patent license to make, have made, 79 | use, offer to sell, sell, import, and otherwise transfer the Work, 80 | where such license applies only to those patent claims licensable 81 | by such Contributor that are necessarily infringed by their 82 | Contribution(s) alone or by combination of their Contribution(s) 83 | with the Work to which such Contribution(s) was submitted. If You 84 | institute patent litigation against any entity (including a 85 | cross-claim or counterclaim in a lawsuit) alleging that the Work 86 | or a Contribution incorporated within the Work constitutes direct 87 | or contributory patent infringement, then any patent licenses 88 | granted to You under this License for that Work shall terminate 89 | as of the date such litigation is filed. 90 | 91 | 4. Redistribution. You may reproduce and distribute copies of the 92 | Work or Derivative Works thereof in any medium, with or without 93 | modifications, and in Source or Object form, provided that You 94 | meet the following conditions: 95 | 96 | (a) You must give any other recipients of the Work or 97 | Derivative Works a copy of this License; and 98 | 99 | (b) You must cause any modified files to carry prominent notices 100 | stating that You changed the files; and 101 | 102 | (c) You must retain, in the Source form of any Derivative Works 103 | that You distribute, all copyright, patent, trademark, and 104 | attribution notices from the Source form of the Work, 105 | excluding those notices that do not pertain to any part of 106 | the Derivative Works; and 107 | 108 | (d) If the Work includes a "NOTICE" text file as part of its 109 | distribution, then any Derivative Works that You distribute must 110 | include a readable copy of the attribution notices contained 111 | within such NOTICE file, excluding those notices that do not 112 | pertain to any part of the Derivative Works, in at least one 113 | of the following places: within a NOTICE text file distributed 114 | as part of the Derivative Works; within the Source form or 115 | documentation, if provided along with the Derivative Works; or, 116 | within a display generated by the Derivative Works, if and 117 | wherever such third-party notices normally appear. The contents 118 | of the NOTICE file are for informational purposes only and 119 | do not modify the License. You may add Your own attribution 120 | notices within Derivative Works that You distribute, alongside 121 | or as an addendum to the NOTICE text from the Work, provided 122 | that such additional attribution notices cannot be construed 123 | as modifying the License. 124 | 125 | You may add Your own copyright statement to Your modifications and 126 | may provide additional or different license terms and conditions 127 | for use, reproduction, or distribution of Your modifications, or 128 | for any such Derivative Works as a whole, provided Your use, 129 | reproduction, and distribution of the Work otherwise complies with 130 | the conditions stated in this License. 131 | 132 | 5. Submission of Contributions. Unless You explicitly state otherwise, 133 | any Contribution intentionally submitted for inclusion in the Work 134 | by You to the Licensor shall be under the terms and conditions of 135 | this License, without any additional terms or conditions. 136 | Notwithstanding the above, nothing herein shall supersede or modify 137 | the terms of any separate license agreement you may have executed 138 | with Licensor regarding such Contributions. 139 | 140 | 6. Trademarks. This License does not grant permission to use the trade 141 | names, trademarks, service marks, or product names of the Licensor, 142 | except as required for reasonable and customary use in describing the 143 | origin of the Work and reproducing the content of the NOTICE file. 144 | 145 | 7. Disclaimer of Warranty. Unless required by applicable law or 146 | agreed to in writing, Licensor provides the Work (and each 147 | Contributor provides its Contributions) on an "AS IS" BASIS, 148 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 149 | implied, including, without limitation, any warranties or conditions 150 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 151 | PARTICULAR PURPOSE. You are solely responsible for determining the 152 | appropriateness of using or redistributing the Work and assume any 153 | risks associated with Your exercise of permissions under this License. 154 | 155 | 8. Limitation of Liability. In no event and under no legal theory, 156 | whether in tort (including negligence), contract, or otherwise, 157 | unless required by applicable law (such as deliberate and grossly 158 | negligent acts) or agreed to in writing, shall any Contributor be 159 | liable to You for damages, including any direct, indirect, special, 160 | incidental, or consequential damages of any character arising as a 161 | result of this License or out of the use or inability to use the 162 | Work (including but not limited to damages for loss of goodwill, 163 | work stoppage, computer failure or malfunction, or any and all 164 | other commercial damages or losses), even if such Contributor 165 | has been advised of the possibility of such damages. 166 | 167 | 9. Accepting Warranty or Additional Liability. While redistributing 168 | the Work or Derivative Works thereof, You may choose to offer, 169 | and charge a fee for, acceptance of support, warranty, indemnity, 170 | or other liability obligations and/or rights consistent with this 171 | License. However, in accepting such obligations, You may act only 172 | on Your own behalf and on Your sole responsibility, not on behalf 173 | of any other Contributor, and only if You agree to indemnify, 174 | defend, and hold each Contributor harmless for any liability 175 | incurred by, or claims asserted against, such Contributor by reason 176 | of your accepting any such warranty or additional liability. 177 | 178 | END OF TERMS AND CONDITIONS 179 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Responder: a familiar HTTP Service Framework for Python 2 | 3 | [![ci-tests](https://github.com/kennethreitz/responder/actions/workflows/test.yaml/badge.svg)](https://github.com/kennethreitz/responder/actions/workflows/test.yaml) 4 | [![ci-docs](https://github.com/kennethreitz/responder/actions/workflows/docs.yaml/badge.svg)](https://github.com/kennethreitz/responder/actions/workflows/docs.yaml) 5 | [![Documentation Status](https://github.com/kennethreitz/responder/actions/workflows/pages/pages-build-deployment/badge.svg)](https://responder.kennethreitz.org/) 6 | [![version](https://img.shields.io/pypi/v/responder.svg)](https://pypi.org/project/responder/) 7 | [![license](https://img.shields.io/pypi/l/responder.svg)](https://pypi.org/project/responder/) 8 | [![python-versions](https://img.shields.io/pypi/pyversions/responder.svg)](https://pypi.org/project/responder/) 9 | [![downloads](https://static.pepy.tech/badge/responder/month)](https://pepy.tech/project/responder) 10 | [![contributors](https://img.shields.io/github/contributors/kennethreitz/responder.svg)](https://github.com/kennethreitz/responder/graphs/contributors) 11 | [![status](https://img.shields.io/pypi/status/responder.svg)](https://pypi.org/project/responder/) 12 | 13 | [![responder-synopsis](https://farm2.staticflickr.com/1959/43750081370_a4e20752de_o_d.png)](https://responder.readthedocs.io) 14 | 15 | Responder is powered by [Starlette](https://www.starlette.io/). 16 | [View documentation](https://responder.readthedocs.io). 17 | 18 | Responder gets you an ASGI app, with a production static files server pre-installed, 19 | Jinja templating, and a production webserver based on uvloop, automatically serving 20 | up requests with gzip compression. 21 | The `async` declaration within the example program is optional. 22 | 23 | ## Testimonials 24 | 25 | > "Pleasantly very taken with python-responder. 26 | > [@kennethreitz](https://x.com/kennethreitz42) at his absolute best." —Rudraksh 27 | > M.K. 28 | 29 | > "ASGI is going to enable all sorts of new high-performance web services. It's awesome 30 | > to see Responder starting to take advantage of that." — Tom Christie author of 31 | > [Django REST Framework](https://www.django-rest-framework.org/) 32 | 33 | > "I love that you are exploring new patterns. Go go go!" — Danny Greenfield, author of 34 | > [Two Scoops of Django](https://www.feldroy.com/two-scoops-press#two-scoops-of-django) 35 | 36 | ## More Examples 37 | 38 | See 39 | [the documentation's feature tour](https://responder.readthedocs.io/tour.html) 40 | for more details on features available in Responder. 41 | 42 | # Installing Responder 43 | 44 | Install the most recent stable release: 45 | 46 | pip install --upgrade 'responder' 47 | 48 | Include support for all extensions and interfaces: 49 | 50 | pip install --upgrade 'responder[full]' 51 | 52 | Individual optional installation extras are: 53 | 54 | - graphql: Adds GraphQL support via Graphene 55 | - openapi: Adds OpenAPI/Swagger interface support 56 | 57 | Install package with CLI and GraphQL support: 58 | 59 | uv pip install --upgrade 'responder[cli,graphql]' 60 | 61 | Alternatively, install directly from the repository: 62 | 63 | pip install 'responder[full] @ git+https://github.com/kennethreitz/responder.git' 64 | 65 | Responder supports **Python 3.7+**. 66 | 67 | # The Basic Idea 68 | 69 | The primary concept here is to bring the niceties that are brought forth from both Flask 70 | and Falcon and unify them into a single framework, along with some new ideas I have. I 71 | also wanted to take some of the API primitives that are instilled in the Requests 72 | library and put them into a web framework. So, you'll find a lot of parallels here with 73 | Requests. 74 | 75 | - Setting `resp.content` sends back bytes. 76 | - Setting `resp.text` sends back unicode, while setting `resp.html` sends back HTML. 77 | - Setting `resp.media` sends back JSON/YAML (`.text`/`.html`/`.content` override this). 78 | - Case-insensitive `req.headers` dict (from Requests directly). 79 | - `resp.status_code`, `req.method`, `req.url`, and other familiar friends. 80 | 81 | ## Ideas 82 | 83 | - Flask-style route expression, with new capabilities -- all while using Python 3.6+'s 84 | new f-string syntax. 85 | - I love Falcon's "every request and response is passed into to each view and mutated" 86 | methodology, especially `response.media`, and have used it here. In addition to 87 | supporting JSON, I have decided to support YAML as well, as Kubernetes is slowly 88 | taking over the world, and it uses YAML for all the things. Content-negotiation and 89 | all that. 90 | - **A built in testing client that uses the actual Requests you know and love**. 91 | - The ability to mount other WSGI apps easily. 92 | - Automatic gzipped-responses. 93 | - In addition to Falcon's `on_get`, `on_post`, etc methods, Responder features an 94 | `on_request` method, which gets called on every type of request, much like Requests. 95 | - A production static file server is built-in. 96 | - Uvicorn built-in as a production web server. I would have chosen Gunicorn, but it 97 | doesn't run on Windows. Plus, Uvicorn serves well to protect against slowloris 98 | attacks, making nginx unnecessary in production. 99 | - GraphQL support, via Graphene. The goal here is to have any GraphQL query exposable at 100 | any route, magically. 101 | - Provide an official way to run webpack. 102 | 103 | ## Development 104 | 105 | See [Development Sandbox](https://responder.kennethreitz.org/sandbox.html). 106 | 107 | ## Supported by 108 | 109 | [![JetBrains logo.](https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg)](https://jb.gg/OpenSourceSupport) 110 | 111 | Special thanks to the kind people at JetBrains s.r.o. for supporting us with 112 | excellent development tooling. 113 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/_static/custom.css: -------------------------------------------------------------------------------- 1 | /* Hide module name and default value for environment variable section */ 2 | div[id$="environment-variables"] code.descclassname { 3 | display: none; 4 | } 5 | div[id$="environment-variables"] em.property { 6 | display: none; 7 | } 8 | -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/005CED86771E6F899.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/005CED86771E6F899.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/07B3028D11D5A1878.css: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | Copyright (C) 2011-2018 Hoefler & Co. 4 | This software is the property of Hoefler & Co. (H&Co). 5 | Your right to access and use this software is subject to the 6 | applicable License Agreement, or Terms of Service, that exists 7 | between you and H&Co. If no such agreement exists, you may not 8 | access or use this software for any purpose. 9 | This software may only be hosted at the locations specified in 10 | the applicable License Agreement or Terms of Service, and only 11 | for the purposes expressly set forth therein. You may not copy, 12 | modify, convert, create derivative works from or distribute this 13 | software in any way, or make it accessible to any third party, 14 | without first obtaining the written permission of H&Co. 15 | For more information, please visit us at http://typography.com. 16 | 148887-130097-20181011 17 | */ 18 | 19 | -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/0AC552691F872135E.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/0AC552691F872135E.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/13B223000FA5C8685.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/13B223000FA5C8685.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/196C9CB6EE09F9C87.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/196C9CB6EE09F9C87.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/393BC2C22D281021A.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/393BC2C22D281021A.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/395CE06A8C69E5D38.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/395CE06A8C69E5D38.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/3A39878E22934F8AD.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/3A39878E22934F8AD.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/3A8BFAC68DBAFAE43.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/3A8BFAC68DBAFAE43.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/40351B0A9DF3B9622.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/40351B0A9DF3B9622.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/4A801A74B6CEC6B76.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/4A801A74B6CEC6B76.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/60FE49CEF587B82D3.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/60FE49CEF587B82D3.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/71DF7DE2F12CD232B.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) 2011-2018 Hoefler & Co. 3 | This software is the property of Hoefler & Co. (H&Co). 4 | Your right to access and use this software is subject to the 5 | applicable License Agreement, or Terms of Service, that exists 6 | between you and H&Co. If no such agreement exists, you may not 7 | access or use this software for any purpose. 8 | This software may only be hosted at the locations specified in 9 | the applicable License Agreement or Terms of Service, and only 10 | for the purposes expressly set forth therein. You may not copy, 11 | modify, convert, create derivative works from or distribute this 12 | software in any way, or make it accessible to any third party, 13 | without first obtaining the written permission of H&Co. 14 | For more information, please visit us at http://typography.com. 15 | 148887-130097-20181011 16 | */ 17 | 18 | @font-face { 19 | font-family: "Mercury Text G1 A"; 20 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/AA83D0999C9464BC6.eot"); 21 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/AA83D0999C9464BC6.eot?#hco") 22 | format("embedded-opentype"); 23 | font-style: normal; 24 | font-weight: 400; 25 | } 26 | @font-face { 27 | font-family: "Mercury Text G1 4r"; 28 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/AA83D0999C9464BC6.eot"); 29 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/AA83D0999C9464BC6.eot?#hco") 30 | format("embedded-opentype"); 31 | font-style: normal; 32 | font-weight: 400; 33 | } 34 | @font-face { 35 | font-family: "Mercury Text G1 A"; 36 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/005CED86771E6F899.eot"); 37 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/005CED86771E6F899.eot?#hco") 38 | format("embedded-opentype"); 39 | font-style: italic; 40 | font-weight: 400; 41 | } 42 | @font-face { 43 | font-family: "Mercury Text G1 4i"; 44 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/005CED86771E6F899.eot"); 45 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/005CED86771E6F899.eot?#hco") 46 | format("embedded-opentype"); 47 | font-style: italic; 48 | font-weight: 400; 49 | } 50 | @font-face { 51 | font-family: "Mercury Text G1 A"; 52 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/40351B0A9DF3B9622.eot"); 53 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/40351B0A9DF3B9622.eot?#hco") 54 | format("embedded-opentype"); 55 | font-style: normal; 56 | font-weight: 600; 57 | } 58 | @font-face { 59 | font-family: "Mercury Text G1 6r"; 60 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/40351B0A9DF3B9622.eot"); 61 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/40351B0A9DF3B9622.eot?#hco") 62 | format("embedded-opentype"); 63 | font-style: normal; 64 | font-weight: 600; 65 | } 66 | @font-face { 67 | font-family: "Mercury Text G1 A"; 68 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/3A39878E22934F8AD.eot"); 69 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/3A39878E22934F8AD.eot?#hco") 70 | format("embedded-opentype"); 71 | font-style: italic; 72 | font-weight: 600; 73 | } 74 | @font-face { 75 | font-family: "Mercury Text G1 6i"; 76 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/3A39878E22934F8AD.eot"); 77 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/3A39878E22934F8AD.eot?#hco") 78 | format("embedded-opentype"); 79 | font-style: italic; 80 | font-weight: 600; 81 | } 82 | @font-face { 83 | font-family: "Mercury Text G1 A"; 84 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/DD7AD6D5FDE05ABCA.eot"); 85 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/DD7AD6D5FDE05ABCA.eot?#hco") 86 | format("embedded-opentype"); 87 | font-style: normal; 88 | font-weight: 700; 89 | } 90 | @font-face { 91 | font-family: "Mercury Text G1 7r"; 92 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/DD7AD6D5FDE05ABCA.eot"); 93 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/DD7AD6D5FDE05ABCA.eot?#hco") 94 | format("embedded-opentype"); 95 | font-style: normal; 96 | font-weight: 700; 97 | } 98 | @font-face { 99 | font-family: "Mercury Text G1 A"; 100 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/777143010DB6642D4.eot"); 101 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/777143010DB6642D4.eot?#hco") 102 | format("embedded-opentype"); 103 | font-style: italic; 104 | font-weight: 700; 105 | } 106 | @font-face { 107 | font-family: "Mercury Text G1 7i"; 108 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/777143010DB6642D4.eot"); 109 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/777143010DB6642D4.eot?#hco") 110 | format("embedded-opentype"); 111 | font-style: italic; 112 | font-weight: 700; 113 | } 114 | @font-face { 115 | font-family: "Operator Mono SSm A"; 116 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/4A801A74B6CEC6B76.eot"); 117 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/4A801A74B6CEC6B76.eot?#hco") 118 | format("embedded-opentype"); 119 | font-style: normal; 120 | font-weight: 400; 121 | } 122 | @font-face { 123 | font-family: "Operator Mono SSm 4r"; 124 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/4A801A74B6CEC6B76.eot"); 125 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/4A801A74B6CEC6B76.eot?#hco") 126 | format("embedded-opentype"); 127 | font-style: normal; 128 | font-weight: 400; 129 | } 130 | @font-face { 131 | font-family: "Operator Mono SSm A"; 132 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/7E9ADDBCA2C8BD433.eot"); 133 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/7E9ADDBCA2C8BD433.eot?#hco") 134 | format("embedded-opentype"); 135 | font-style: italic; 136 | font-weight: 400; 137 | } 138 | @font-face { 139 | font-family: "Operator Mono SSm 4i"; 140 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/7E9ADDBCA2C8BD433.eot"); 141 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/7E9ADDBCA2C8BD433.eot?#hco") 142 | format("embedded-opentype"); 143 | font-style: italic; 144 | font-weight: 400; 145 | } 146 | @font-face { 147 | font-family: "Operator Mono SSm A"; 148 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/13B223000FA5C8685.eot"); 149 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/13B223000FA5C8685.eot?#hco") 150 | format("embedded-opentype"); 151 | font-style: normal; 152 | font-weight: 700; 153 | } 154 | @font-face { 155 | font-family: "Operator Mono SSm 7r"; 156 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/13B223000FA5C8685.eot"); 157 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/13B223000FA5C8685.eot?#hco") 158 | format("embedded-opentype"); 159 | font-style: normal; 160 | font-weight: 700; 161 | } 162 | @font-face { 163 | font-family: "Operator Mono SSm A"; 164 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/0AC552691F872135E.eot"); 165 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/0AC552691F872135E.eot?#hco") 166 | format("embedded-opentype"); 167 | font-style: italic; 168 | font-weight: 700; 169 | } 170 | @font-face { 171 | font-family: "Operator Mono SSm 7i"; 172 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/0AC552691F872135E.eot"); 173 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/0AC552691F872135E.eot?#hco") 174 | format("embedded-opentype"); 175 | font-style: italic; 176 | font-weight: 700; 177 | } 178 | -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/777143010DB6642D4.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/777143010DB6642D4.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/7E9ADDBCA2C8BD433.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/7E9ADDBCA2C8BD433.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/8CE05FA3739464866.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) 2011-2018 Hoefler & Co. 3 | This software is the property of Hoefler & Co. (H&Co). 4 | Your right to access and use this software is subject to the 5 | applicable License Agreement, or Terms of Service, that exists 6 | between you and H&Co. If no such agreement exists, you may not 7 | access or use this software for any purpose. 8 | This software may only be hosted at the locations specified in 9 | the applicable License Agreement or Terms of Service, and only 10 | for the purposes expressly set forth therein. You may not copy, 11 | modify, convert, create derivative works from or distribute this 12 | software in any way, or make it accessible to any third party, 13 | without first obtaining the written permission of H&Co. 14 | For more information, please visit us at http://typography.com. 15 | 148887-130097-20181011 16 | */ 17 | 18 | @font-face { 19 | font-family: "Mercury Text G1 A"; 20 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/7D0605C11BA3A93EF.eot"); 21 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/7D0605C11BA3A93EF.eot?#hco") 22 | format("embedded-opentype"); 23 | font-style: normal; 24 | font-weight: 400; 25 | } 26 | @font-face { 27 | font-family: "Mercury Text G1 4r"; 28 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/7D0605C11BA3A93EF.eot"); 29 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/7D0605C11BA3A93EF.eot?#hco") 30 | format("embedded-opentype"); 31 | font-style: normal; 32 | font-weight: 400; 33 | } 34 | @font-face { 35 | font-family: "Mercury Text G1 A"; 36 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/81A13EBFC10447CAC.eot"); 37 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/81A13EBFC10447CAC.eot?#hco") 38 | format("embedded-opentype"); 39 | font-style: italic; 40 | font-weight: 400; 41 | } 42 | @font-face { 43 | font-family: "Mercury Text G1 4i"; 44 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/81A13EBFC10447CAC.eot"); 45 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/81A13EBFC10447CAC.eot?#hco") 46 | format("embedded-opentype"); 47 | font-style: italic; 48 | font-weight: 400; 49 | } 50 | @font-face { 51 | font-family: "Mercury Text G1 A"; 52 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/25E3F5D50DDE1C555.eot"); 53 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/25E3F5D50DDE1C555.eot?#hco") 54 | format("embedded-opentype"); 55 | font-style: normal; 56 | font-weight: 600; 57 | } 58 | @font-face { 59 | font-family: "Mercury Text G1 6r"; 60 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/25E3F5D50DDE1C555.eot"); 61 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/25E3F5D50DDE1C555.eot?#hco") 62 | format("embedded-opentype"); 63 | font-style: normal; 64 | font-weight: 600; 65 | } 66 | @font-face { 67 | font-family: "Mercury Text G1 A"; 68 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/F526A6C670B9765E2.eot"); 69 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/F526A6C670B9765E2.eot?#hco") 70 | format("embedded-opentype"); 71 | font-style: italic; 72 | font-weight: 600; 73 | } 74 | @font-face { 75 | font-family: "Mercury Text G1 6i"; 76 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/F526A6C670B9765E2.eot"); 77 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/F526A6C670B9765E2.eot?#hco") 78 | format("embedded-opentype"); 79 | font-style: italic; 80 | font-weight: 600; 81 | } 82 | @font-face { 83 | font-family: "Mercury Text G1 A"; 84 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/7DCD4B5CCAEE3223E.eot"); 85 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/7DCD4B5CCAEE3223E.eot?#hco") 86 | format("embedded-opentype"); 87 | font-style: normal; 88 | font-weight: 700; 89 | } 90 | @font-face { 91 | font-family: "Mercury Text G1 7r"; 92 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/7DCD4B5CCAEE3223E.eot"); 93 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/7DCD4B5CCAEE3223E.eot?#hco") 94 | format("embedded-opentype"); 95 | font-style: normal; 96 | font-weight: 700; 97 | } 98 | @font-face { 99 | font-family: "Mercury Text G1 A"; 100 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/C08332ABD7F145352.eot"); 101 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/C08332ABD7F145352.eot?#hco") 102 | format("embedded-opentype"); 103 | font-style: italic; 104 | font-weight: 700; 105 | } 106 | @font-face { 107 | font-family: "Mercury Text G1 7i"; 108 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/C08332ABD7F145352.eot"); 109 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/C08332ABD7F145352.eot?#hco") 110 | format("embedded-opentype"); 111 | font-style: italic; 112 | font-weight: 700; 113 | } 114 | @font-face { 115 | font-family: "Operator Mono SSm A"; 116 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/C579DF5B35B145D49.eot"); 117 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/C579DF5B35B145D49.eot?#hco") 118 | format("embedded-opentype"); 119 | font-style: normal; 120 | font-weight: 400; 121 | } 122 | @font-face { 123 | font-family: "Operator Mono SSm 4r"; 124 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/C579DF5B35B145D49.eot"); 125 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/C579DF5B35B145D49.eot?#hco") 126 | format("embedded-opentype"); 127 | font-style: normal; 128 | font-weight: 400; 129 | } 130 | @font-face { 131 | font-family: "Operator Mono SSm A"; 132 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/E3597D43523236FD8.eot"); 133 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/E3597D43523236FD8.eot?#hco") 134 | format("embedded-opentype"); 135 | font-style: italic; 136 | font-weight: 400; 137 | } 138 | @font-face { 139 | font-family: "Operator Mono SSm 4i"; 140 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/E3597D43523236FD8.eot"); 141 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/E3597D43523236FD8.eot?#hco") 142 | format("embedded-opentype"); 143 | font-style: italic; 144 | font-weight: 400; 145 | } 146 | @font-face { 147 | font-family: "Operator Mono SSm A"; 148 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/3CBFC66855DD9B6EA.eot"); 149 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/3CBFC66855DD9B6EA.eot?#hco") 150 | format("embedded-opentype"); 151 | font-style: normal; 152 | font-weight: 700; 153 | } 154 | @font-face { 155 | font-family: "Operator Mono SSm 7r"; 156 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/3CBFC66855DD9B6EA.eot"); 157 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/3CBFC66855DD9B6EA.eot?#hco") 158 | format("embedded-opentype"); 159 | font-style: normal; 160 | font-weight: 700; 161 | } 162 | @font-face { 163 | font-family: "Operator Mono SSm A"; 164 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/09E151FC31ECEF374.eot"); 165 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/09E151FC31ECEF374.eot?#hco") 166 | format("embedded-opentype"); 167 | font-style: italic; 168 | font-weight: 700; 169 | } 170 | @font-face { 171 | font-family: "Operator Mono SSm 7i"; 172 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/09E151FC31ECEF374.eot"); 173 | src: url("http://python-responder.org/en/latest/_static/fonts/692185/09E151FC31ECEF374.eot?#hco") 174 | format("embedded-opentype"); 175 | font-style: italic; 176 | font-weight: 700; 177 | } 178 | -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/A96F219E64C7FE405.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/A96F219E64C7FE405.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/AA83D0999C9464BC6.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/AA83D0999C9464BC6.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/D34A6D5BEB30A3D93.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/D34A6D5BEB30A3D93.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/DD7AD6D5FDE05ABCA.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/DD7AD6D5FDE05ABCA.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/E0416EDBE90327601.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/E0416EDBE90327601.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/E39173F6B506EEF59.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/E39173F6B506EEF59.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/F660DE0AB2F25A0AE.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/fonts/692185/F660DE0AB2F25A0AE.eot -------------------------------------------------------------------------------- /docs/source/_static/fonts/692185/FDCC918E4493D6D80.css: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | Copyright (C) 2011-2018 Hoefler & Co. 4 | This software is the property of Hoefler & Co. (H&Co). 5 | Your right to access and use this software is subject to the 6 | applicable License Agreement, or Terms of Service, that exists 7 | between you and H&Co. If no such agreement exists, you may not 8 | access or use this software for any purpose. 9 | This software may only be hosted at the locations specified in 10 | the applicable License Agreement or Terms of Service, and only 11 | for the purposes expressly set forth therein. You may not copy, 12 | modify, convert, create derivative works from or distribute this 13 | software in any way, or make it accessible to any third party, 14 | without first obtaining the written permission of H&Co. 15 | For more information, please visit us at http://typography.com. 16 | 148887-130097-20181011 17 | */ 18 | 19 | -------------------------------------------------------------------------------- /docs/source/_static/konami.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Konami-JS ~ 3 | * :: Now with support for touch events and multiple instances for 4 | * :: those situations that call for multiple easter eggs! 5 | * Code: https://github.com/snaptortoise/konami-js 6 | * Copyright (c) 2009 George Mandis (georgemandis.com, snaptortoise.com) 7 | * Version: 1.6.2 (7/17/2018) 8 | * Licensed under the MIT License (http://opensource.org/licenses/MIT) 9 | * Tested in: Safari 4+, Google Chrome 4+, Firefox 3+, IE7+, Mobile Safari 2.2.1+ and Android 10 | */ 11 | 12 | var Konami = function (callback) { 13 | var konami = { 14 | addEvent: function (obj, type, fn, ref_obj) { 15 | if (obj.addEventListener) obj.addEventListener(type, fn, false); 16 | else if (obj.attachEvent) { 17 | // IE 18 | obj["e" + type + fn] = fn; 19 | obj[type + fn] = function () { 20 | obj["e" + type + fn](window.event, ref_obj); 21 | }; 22 | obj.attachEvent("on" + type, obj[type + fn]); 23 | } 24 | }, 25 | removeEvent: function (obj, eventName, eventCallback) { 26 | if (obj.removeEventListener) { 27 | obj.removeEventListener(eventName, eventCallback); 28 | } else if (obj.attachEvent) { 29 | obj.detachEvent(eventName); 30 | } 31 | }, 32 | input: "", 33 | pattern: "38384040373937396665", 34 | keydownHandler: function (e, ref_obj) { 35 | if (ref_obj) { 36 | konami = ref_obj; 37 | } // IE 38 | konami.input += e ? e.keyCode : event.keyCode; 39 | if (konami.input.length > konami.pattern.length) { 40 | konami.input = konami.input.substr(konami.input.length - konami.pattern.length); 41 | } 42 | if (konami.input === konami.pattern) { 43 | konami.code(konami._currentLink); 44 | konami.input = ""; 45 | e.preventDefault(); 46 | return false; 47 | } 48 | }, 49 | load: function (link) { 50 | this._currentLink = link; 51 | this.addEvent(document, "keydown", this.keydownHandler, this); 52 | this.iphone.load(link); 53 | }, 54 | unload: function () { 55 | this.removeEvent(document, "keydown", this.keydownHandler); 56 | this.iphone.unload(); 57 | }, 58 | code: function (link) { 59 | window.location = link; 60 | }, 61 | iphone: { 62 | start_x: 0, 63 | start_y: 0, 64 | stop_x: 0, 65 | stop_y: 0, 66 | tap: false, 67 | capture: false, 68 | orig_keys: "", 69 | keys: [ 70 | "UP", 71 | "UP", 72 | "DOWN", 73 | "DOWN", 74 | "LEFT", 75 | "RIGHT", 76 | "LEFT", 77 | "RIGHT", 78 | "TAP", 79 | "TAP", 80 | ], 81 | input: [], 82 | code: function (link) { 83 | konami.code(link); 84 | }, 85 | touchmoveHandler: function (e) { 86 | if (e.touches.length === 1 && konami.iphone.capture === true) { 87 | var touch = e.touches[0]; 88 | konami.iphone.stop_x = touch.pageX; 89 | konami.iphone.stop_y = touch.pageY; 90 | konami.iphone.tap = false; 91 | konami.iphone.capture = false; 92 | konami.iphone.check_direction(); 93 | } 94 | }, 95 | touchendHandler: function () { 96 | konami.iphone.input.push(konami.iphone.check_direction()); 97 | 98 | if (konami.iphone.input.length > konami.iphone.keys.length) 99 | konami.iphone.input.shift(); 100 | 101 | if (konami.iphone.input.length === konami.iphone.keys.length) { 102 | var match = true; 103 | for (var i = 0; i < konami.iphone.keys.length; i++) { 104 | if (konami.iphone.input[i] !== konami.iphone.keys[i]) { 105 | match = false; 106 | } 107 | } 108 | if (match) { 109 | konami.iphone.code(konami._currentLink); 110 | } 111 | } 112 | }, 113 | touchstartHandler: function (e) { 114 | konami.iphone.start_x = e.changedTouches[0].pageX; 115 | konami.iphone.start_y = e.changedTouches[0].pageY; 116 | konami.iphone.tap = true; 117 | konami.iphone.capture = true; 118 | }, 119 | load: function (link) { 120 | this.orig_keys = this.keys; 121 | konami.addEvent(document, "touchmove", this.touchmoveHandler); 122 | konami.addEvent(document, "touchend", this.touchendHandler, false); 123 | konami.addEvent(document, "touchstart", this.touchstartHandler); 124 | }, 125 | unload: function () { 126 | konami.removeEvent(document, "touchmove", this.touchmoveHandler); 127 | konami.removeEvent(document, "touchend", this.touchendHandler); 128 | konami.removeEvent(document, "touchstart", this.touchstartHandler); 129 | }, 130 | check_direction: function () { 131 | x_magnitude = Math.abs(this.start_x - this.stop_x); 132 | y_magnitude = Math.abs(this.start_y - this.stop_y); 133 | x = this.start_x - this.stop_x < 0 ? "RIGHT" : "LEFT"; 134 | y = this.start_y - this.stop_y < 0 ? "DOWN" : "UP"; 135 | result = x_magnitude > y_magnitude ? x : y; 136 | result = this.tap === true ? "TAP" : result; 137 | return result; 138 | }, 139 | }, 140 | }; 141 | 142 | typeof callback === "string" && konami.load(callback); 143 | if (typeof callback === "function") { 144 | konami.code = callback; 145 | konami.load(); 146 | } 147 | 148 | return konami; 149 | }; 150 | 151 | if (typeof module !== "undefined" && typeof module.exports !== "undefined") { 152 | module.exports = Konami; 153 | } else { 154 | if (typeof define === "function" && define.amd) { 155 | define([], function () { 156 | return Konami; 157 | }); 158 | } else { 159 | window.Konami = Konami; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /docs/source/_static/responder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/docs/source/_static/responder.png -------------------------------------------------------------------------------- /docs/source/_templates/hacks.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | 120 | 121 | 122 | 123 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 144 | 145 | 150 | 151 | 152 | 157 | 177 | 178 | 210 | 211 | 214 | 224 | 225 | 226 | 243 | -------------------------------------------------------------------------------- /docs/source/_templates/sidebarintro.html: -------------------------------------------------------------------------------- 1 | 10 |

11 | Star 17 |

18 | 22 | 35 | 36 | 47 | 48 |

Responder is a web service framework, written for human beings.

49 | 50 |

Stay Informed

51 |

Receive updates on new releases and upcoming projects.

52 | 53 |

54 | Follow @kennethreitz 60 |

61 | 62 |

63 | 69 | 82 |

83 | 84 |

Useful Links

85 | 90 | -------------------------------------------------------------------------------- /docs/source/_templates/sidebarlogo.html: -------------------------------------------------------------------------------- 1 | 10 | 11 |

12 | 20 |

21 | 25 | 38 | 39 | 50 | 51 |

Responder is a web service framework, written for human beings.

52 | 53 |

Stay Informed

54 |

Receive updates on new releases and upcoming projects.

55 | 56 |

57 | 65 |

66 | 67 |

68 | 74 | 87 |

88 | 89 |

Useful Links

90 | 95 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | 2 | API Documentation 3 | ================= 4 | 5 | 6 | Web Service (API) Class 7 | ----------------------- 8 | .. module:: responder 9 | 10 | .. autoclass:: API 11 | :inherited-members: 12 | 13 | Requests & Responses 14 | -------------------- 15 | 16 | 17 | .. autoclass:: Request 18 | :inherited-members: 19 | 20 | .. autoclass:: Response 21 | :inherited-members: 22 | 23 | 24 | Utility Functions 25 | ----------------- 26 | 27 | .. autofunction:: responder.API.status_codes.is_100 28 | 29 | .. autofunction:: responder.API.status_codes.is_200 30 | 31 | .. autofunction:: responder.API.status_codes.is_300 32 | 33 | .. autofunction:: responder.API.status_codes.is_400 34 | 35 | .. autofunction:: responder.API.status_codes.is_500 36 | -------------------------------------------------------------------------------- /docs/source/backlog.md: -------------------------------------------------------------------------------- 1 | # Backlog 2 | 3 | ## Iteration +1 4 | - Release 3.0.0 dev 5 | - Release 3.0.0 GA 6 | - Check if `tour.rst` is still valid. What about running it as a 7 | doctest, or converting it into a text-based notebook using MyST-NB? 8 | - Document all extensions in `responder.ext`. 9 | - Add `index.html` to standard `helloworld.py` example, 10 | so that the user does not receive a 404 Not Found. 11 | -------------------------------------------------------------------------------- /docs/source/changes.md: -------------------------------------------------------------------------------- 1 | ../../CHANGELOG.md -------------------------------------------------------------------------------- /docs/source/cli.rst: -------------------------------------------------------------------------------- 1 | Responder CLI 2 | ============= 3 | 4 | Responder installs a command line program ``responder``. Use it to launch 5 | a Responder application from a file or module, either located on a local 6 | or remote filesystem, or object store. 7 | 8 | Launch Module Entrypoint 9 | ------------------------ 10 | 11 | For loading a Responder application from a Python module, you will refer to 12 | its ``API()`` instance using a `Python entry point object reference`_ that 13 | points to a Python object. It is either in the form ``importable.module``, 14 | or ``importable.module:object.attr``. 15 | 16 | A basic invocation command to launch a Responder application: 17 | 18 | .. code-block:: shell 19 | 20 | responder run acme.app 21 | 22 | The command above assumes a Python package ``acme`` including an ``app`` 23 | module ``acme/app.py`` that includes an attribute ``api`` that refers 24 | to a ``responder.API`` instance, reflecting the typical layout of 25 | a standard Responder application. 26 | 27 | Loading a Responder application using an entrypoint specification will 28 | inherit the capacities of `Python's import system`_, as implemented by 29 | `importlib`_. 30 | 31 | Launch Local File 32 | ----------------- 33 | 34 | Acquire a minimal example single-file application, ``helloworld.py`` [1]_, 35 | to your local filesystem, giving you the chance to edit it, and launch the 36 | Responder HTTP service. 37 | 38 | .. code-block:: shell 39 | 40 | wget https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py 41 | responder run helloworld.py 42 | 43 | .. note:: 44 | 45 | To validate the example application, invoke a HTTP request, for example using 46 | `curl`_, `HTTPie`_, or your favourite browser at hand. 47 | 48 | .. code-block:: shell 49 | 50 | http http://127.0.0.1:5042/Hello 51 | 52 | The response is no surprise. 53 | 54 | :: 55 | 56 | HTTP/1.1 200 OK 57 | content-length: 13 58 | content-type: text/plain 59 | date: Sat, 26 Oct 2024 13:16:55 GMT 60 | encoding: utf-8 61 | server: uvicorn 62 | 63 | Hello, world! 64 | 65 | .. [1] The Responder application `helloworld.py`_ implements a basic echo handler. 66 | 67 | Launch Remote File 68 | ------------------ 69 | 70 | You can also launch a single-file application where its Python file is stored 71 | on a remote location after installing the ``cli-full`` extra. 72 | 73 | .. code-block:: shell 74 | 75 | uv pip install 'responder[cli-full]' 76 | 77 | Responder supports all filesystem adapters compatible with `fsspec`_, and 78 | installs the adapters for Azure Blob Storage (az), Google Cloud Storage (gs), 79 | GitHub, HTTP, and AWS S3 by default. 80 | 81 | .. code-block:: shell 82 | 83 | # Works 1:1. 84 | responder run https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py 85 | responder run github://kennethreitz:responder@/examples/helloworld.py 86 | 87 | If you need access other kinds of remote targets, see the `list of 88 | fsspec-supported filesystems and protocols`_. The next section enumerates 89 | a few synthetic examples. The corresponding storage buckets do not even 90 | exist, so don't expect those commands to work. 91 | 92 | .. code-block:: shell 93 | 94 | # Azure Blob Storage, Google Cloud Storage, and AWS S3. 95 | responder run az://kennethreitz-assets/responder/examples/helloworld.py 96 | responder run gs://kennethreitz-assets/responder/examples/helloworld.py 97 | responder run s3://kennethreitz-assets/responder/examples/helloworld.py 98 | 99 | # Hadoop Distributed File System (hdfs), SSH File Transfer Protocol (sftp), 100 | # Common Internet File System (smb), Web-based Distributed Authoring and 101 | # Versioning (webdav). 102 | responder run hdfs://kennethreitz-assets/responder/examples/helloworld.py 103 | responder run sftp://user@host/kennethreitz/responder/examples/helloworld.py 104 | responder run smb://workgroup;user:password@server:port/responder/examples/helloworld.py 105 | responder run webdav+https://user:password@server:port/responder/examples/helloworld.py 106 | 107 | .. tip:: 108 | 109 | In order to install support for all filesystem types supported by fsspec, run: 110 | 111 | .. code-block:: shell 112 | 113 | uv pip install 'fsspec[full]' 114 | 115 | When using ``uv``, this concludes within an acceptable time of approx. 116 | 25 seconds. If you need to be more selectively instead of using ``full``, 117 | choose from one or multiple of the available `fsspec extras`_, which are: 118 | 119 | abfs, arrow, dask, dropbox, fuse, gcs, git, github, hdfs, http, oci, s3, 120 | sftp, smb, ssh. 121 | 122 | Launch with Non-Standard Instance Name 123 | -------------------------------------- 124 | 125 | By default, Responder will acquire an ``responder.API`` instance using the 126 | symbol name ``api`` from the specified Python module. 127 | 128 | If your main application file uses a different name than ``api``, please 129 | append the designated symbol name to the launch target address. 130 | 131 | It works like this for module entrypoints and local files: 132 | 133 | .. code-block:: shell 134 | 135 | responder run acme.app:service 136 | responder run /path/to/acme/app.py:service 137 | 138 | It works like this for URLs: 139 | 140 | .. code-block:: shell 141 | 142 | responder run http://app.server.local/path/to/acme/app.py#service 143 | 144 | Within your ``app.py``, the instance would have been defined to use 145 | the ``service`` symbol name instead of ``api``, like this: 146 | 147 | .. code-block:: python 148 | 149 | service = responder.API() 150 | 151 | Build JavaScript Application 152 | ---------------------------- 153 | 154 | The ``build`` subcommand invokes ``npm run build``, optionally accepting 155 | a target directory. By default, it uses the current working directory, 156 | where it expects a regular NPM ``package.json`` file. 157 | 158 | .. code-block:: shell 159 | 160 | responder build 161 | 162 | When specifying a target directory, Responder will change to that 163 | directory beforehand. 164 | 165 | .. code-block:: shell 166 | 167 | responder build /path/to/project 168 | 169 | 170 | .. _curl: https://curl.se/ 171 | .. _fsspec: https://filesystem-spec.readthedocs.io/en/latest/ 172 | .. _fsspec extras: https://github.com/fsspec/filesystem_spec/blob/2024.12.0/pyproject.toml#L27-L69 173 | .. _helloworld.py: https://github.com/kennethreitz/responder/blob/main/examples/helloworld.py 174 | .. _HTTPie: https://httpie.io/docs/cli 175 | .. _importlib: https://docs.python.org/3/library/importlib.html 176 | .. _list of fsspec-supported filesystems and protocols: https://github.com/fsspec/universal_pathlib#currently-supported-filesystems-and-protocols 177 | .. _Python entry point object reference: https://packaging.python.org/en/latest/specifications/entry-points/ 178 | .. _Python's import system: https://docs.python.org/3/reference/import.html 179 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | # import os 16 | # import sys 17 | # sys.path.insert(0, os.path.abspath('.')) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = "responder" 23 | copyright = "2018, A Kenneth Reitz project" 24 | author = "Kenneth Reitz" 25 | 26 | # The short X.Y version 27 | import os 28 | 29 | # Path hackery to get current version number. 30 | here = os.path.abspath(os.path.dirname(__file__)) 31 | 32 | about = {} 33 | with open(os.path.join(here, "..", "..", "responder", "__version__.py")) as f: 34 | exec(f.read(), about) 35 | 36 | version = about["__version__"] 37 | # The full version, including alpha/beta/rc tags 38 | release = about["__version__"] 39 | 40 | 41 | # -- General configuration --------------------------------------------------- 42 | 43 | # If your documentation needs a minimal Sphinx version, state it here. 44 | # 45 | # needs_sphinx = '1.0' 46 | 47 | # Add any Sphinx extension module names here, as strings. They can be 48 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 49 | # ones. 50 | extensions = [ 51 | "sphinx.ext.autodoc", 52 | "sphinx.ext.doctest", 53 | "sphinx.ext.intersphinx", 54 | "sphinx.ext.todo", 55 | "sphinx.ext.coverage", 56 | "sphinx.ext.mathjax", 57 | "sphinx.ext.ifconfig", 58 | "sphinx.ext.viewcode", 59 | "sphinx.ext.githubpages", 60 | "myst_parser", 61 | "sphinx_copybutton", 62 | "sphinx_design", 63 | "sphinx_design_elements", 64 | "sphinxext.opengraph", 65 | ] 66 | 67 | # Add any paths that contain templates here, relative to this directory. 68 | templates_path = ["_templates"] 69 | 70 | # The suffix(es) of source filenames. 71 | # You can specify multiple suffix as a list of string: 72 | # 73 | # source_suffix = ['.rst', '.md'] 74 | source_suffix = {".rst": "restructuredtext"} 75 | 76 | # The master toctree document. 77 | master_doc = "index" 78 | 79 | # The language for content autogenerated by Sphinx. Refer to documentation 80 | # for a list of supported languages. 81 | # 82 | # This is also used if you do content translation via gettext catalogs. 83 | # Usually you set "language" from the command line for these cases. 84 | language = "en" 85 | 86 | # List of patterns, relative to source directory, that match files and 87 | # directories to ignore when looking for source files. 88 | # This pattern also affects html_static_path and html_extra_path. 89 | exclude_patterns = [] 90 | 91 | # The name of the Pygments (syntax highlighting) style to use. 92 | pygments_style = None 93 | 94 | 95 | # -- Options for HTML output ------------------------------------------------- 96 | 97 | # The theme to use for HTML and HTML Help pages. See the documentation for 98 | # a list of builtin themes. 99 | # 100 | html_theme = "alabaster" 101 | 102 | # Theme options are theme-specific and customize the look and feel of a theme 103 | # further. For a list of options available for each theme, see the 104 | # documentation. 105 | # 106 | html_theme_options = { 107 | "show_powered_by": False, 108 | "github_user": "kennethreitz", 109 | "github_repo": "responder", 110 | "github_banner": False, 111 | "show_related": False, 112 | } 113 | 114 | 115 | html_sidebars = { 116 | "index": ["sidebarintro.html", "sourcelink.html", "searchbox.html", "hacks.html"], 117 | "**": [ 118 | "sidebarlogo.html", 119 | "localtoc.html", 120 | "relations.html", 121 | "sourcelink.html", 122 | "searchbox.html", 123 | "hacks.html", 124 | ], 125 | } 126 | 127 | # Add any paths that contain custom static files (such as style sheets) here, 128 | # relative to this directory. They are copied after the builtin static files, 129 | # so a file named "default.css" will overwrite the builtin "default.css". 130 | html_static_path = ["_static"] 131 | 132 | # Custom sidebar templates, must be a dictionary that maps document names 133 | # to template names. 134 | # 135 | # The default sidebars (for documents that don't match any pattern) are 136 | # defined by theme itself. Builtin themes are using these templates by 137 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 138 | # 'searchbox.html']``. 139 | # 140 | # html_sidebars = {} 141 | 142 | 143 | # -- Options for HTMLHelp output --------------------------------------------- 144 | 145 | # Output file base name for HTML help builder. 146 | htmlhelp_basename = "responderdoc" 147 | 148 | 149 | # -- Options for LaTeX output ------------------------------------------------ 150 | 151 | latex_elements = { 152 | # The paper size ('letterpaper' or 'a4paper'). 153 | # 154 | # 'papersize': 'letterpaper', 155 | # The font size ('10pt', '11pt' or '12pt'). 156 | # 157 | # 'pointsize': '10pt', 158 | # Additional stuff for the LaTeX preamble. 159 | # 160 | # 'preamble': '', 161 | # Latex figure (float) alignment 162 | # 163 | # 'figure_align': 'htbp', 164 | } 165 | 166 | # Grouping the document tree into LaTeX files. List of tuples 167 | # (source start file, target name, title, 168 | # author, documentclass [howto, manual, or own class]). 169 | latex_documents = [ 170 | (master_doc, "responder.tex", "responder Documentation", "Kenneth Reitz", "manual") 171 | ] 172 | 173 | 174 | # -- Options for manual page output ------------------------------------------ 175 | 176 | # One entry per manual page. List of tuples 177 | # (source start file, name, description, authors, manual section). 178 | man_pages = [(master_doc, "responder", "responder Documentation", [author], 1)] 179 | 180 | 181 | # -- Options for Texinfo output ---------------------------------------------- 182 | 183 | # Grouping the document tree into Texinfo files. List of tuples 184 | # (source start file, target name, title, author, 185 | # dir menu entry, description, category) 186 | texinfo_documents = [ 187 | ( 188 | master_doc, 189 | "responder", 190 | "responder Documentation", 191 | author, 192 | "responder", 193 | "One line description of project.", 194 | "Miscellaneous", 195 | ) 196 | ] 197 | 198 | 199 | # -- Options for Epub output ------------------------------------------------- 200 | 201 | # Bibliographic Dublin Core info. 202 | epub_title = project 203 | 204 | # The unique identifier of the text. This can be a ISBN number 205 | # or the project homepage. 206 | # 207 | # epub_identifier = '' 208 | 209 | # A unique identification for the text. 210 | # 211 | # epub_uid = '' 212 | 213 | # A list of files that should not be packed into the epub file. 214 | epub_exclude_files = ["search.html"] 215 | 216 | 217 | # -- Extension configuration ------------------------------------------------- 218 | 219 | # -- Options for link checker ---------------------------------------------- 220 | linkcheck_ignore = [ 221 | # Feldroy.com links are ignored because it blocks GHA. 222 | r"https://www.feldroy.com/.*", 223 | ] 224 | linkcheck_anchors_ignore_for_url = [ 225 | # Requires JavaScript. 226 | # After opting-in to new GitHub issues, Sphinx can no longer grok the HTML anchor references. 227 | r"https://github.com", 228 | ] 229 | 230 | # -- Options for intersphinx extension --------------------------------------- 231 | 232 | # Example configuration for intersphinx: refer to the Python standard library. 233 | intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} 234 | 235 | # -- Options for todo extension ---------------------------------------------- 236 | 237 | # If true, `todo` and `todoList` produce output, else they produce nothing. 238 | todo_include_todos = True 239 | 240 | # -- Options for MyST -------------------------------------------------------- 241 | 242 | myst_heading_anchors = 3 243 | myst_enable_extensions = [ 244 | "attrs_block", 245 | "attrs_inline", 246 | "colon_fence", 247 | "deflist", 248 | "fieldlist", 249 | "html_admonition", 250 | "html_image", 251 | "linkify", 252 | "replacements", 253 | "strikethrough", 254 | "substitution", 255 | "tasklist", 256 | ] 257 | myst_substitutions = {} 258 | 259 | # -- Options for OpenGraph --------------------------------------------------- 260 | # 261 | # When making changes, check them using the RTD PR preview URL on https://www.opengraph.xyz/. 262 | # 263 | # About text lengths 264 | # 265 | # Original documentation says: 266 | # - ogp_description_length 267 | # Configure the amount of characters taken from a page. The default of 200 is probably good 268 | # for most people. If something other than a number is used, it defaults back to 200. 269 | # -- https://sphinxext-opengraph.readthedocs.io/en/latest/#options 270 | # 271 | # Other people say: 272 | # - og:title 40 chars 273 | # - og:description has 2 max lengths: 274 | # When the link is used in a Post, it's 300 chars. When a link is used in a Comment, it's 110 chars. 275 | # So you can either treat it as 110, or, write your Descriptions to 300 but make sure the first 110 276 | # is the critical part and still makes sense when it gets cut off. 277 | # -- https://stackoverflow.com/questions/8914476/facebook-open-graph-meta-tags-maximum-content-length 278 | ogp_site_url = "https://responder.kennethreitz.org/" 279 | ogp_description_length = 300 280 | ogp_site_name = "Responder Documentation" 281 | ogp_image = "https://responder.kennethreitz.org/_static/responder.png" 282 | ogp_image_alt = False 283 | ogp_use_first_image = False 284 | ogp_type = "website" 285 | ogp_enable_meta_description = True 286 | 287 | # -- Options for sphinx-copybutton ------------------------------------------- 288 | 289 | copybutton_remove_prompts = True 290 | copybutton_line_continuation_character = "\\" 291 | copybutton_prompt_text = r">>> |\.\.\. |\$ |sh\$ |PS> |cr> |mysql> |In \[\d*\]: | {2,5}\.\.\.: | {5,8}: " 292 | copybutton_prompt_is_regexp = True 293 | -------------------------------------------------------------------------------- /docs/source/deployment.rst: -------------------------------------------------------------------------------- 1 | Deploying Responder 2 | =================== 3 | 4 | You can deploy Responder anywhere you can deploy a basic Python application. 5 | 6 | Docker Deployment 7 | ----------------- 8 | 9 | Assuming existing ``api.py`` and ``Pipfile.lock`` containing ``responder``. 10 | 11 | ``Dockerfile``:: 12 | 13 | FROM kennethreitz/pipenv 14 | ENV PORT '80' 15 | COPY . /app 16 | CMD python3 api.py 17 | EXPOSE 80 18 | 19 | That's it! 20 | 21 | Heroku Deployment 22 | ----------------- 23 | 24 | The basics:: 25 | 26 | $ mkdir my-api 27 | $ cd my-api 28 | $ git init 29 | $ heroku create 30 | ... 31 | 32 | Install Responder:: 33 | 34 | $ pipenv install responder 35 | ... 36 | 37 | Write out an ``api.py``:: 38 | 39 | import responder 40 | 41 | api = responder.API() 42 | 43 | @api.route("/") 44 | async def hello(req, resp): 45 | resp.text = "hello, world!" 46 | 47 | if __name__ == "__main__": 48 | api.run() 49 | 50 | Write out a ``Procfile``:: 51 | 52 | web: python api.py 53 | 54 | That's it! Next, we commit and push to Heroku:: 55 | 56 | $ git add -A 57 | $ git commit -m 'initial commit' 58 | $ git push heroku master 59 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. responder documentation master file, created by 2 | sphinx-quickstart on Thu Oct 11 12:58:34 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | A familiar HTTP Service Framework 7 | ================================= 8 | 9 | |ci-tests| |version| |license| |python-versions| |downloads| |contributors| |say-thanks| 10 | 11 | .. |ci-tests| image:: https://github.com/kennethreitz/responder/actions/workflows/test.yaml/badge.svg 12 | :target: https://github.com/kennethreitz/responder/actions/workflows/test.yaml 13 | .. |ci-docs| image:: https://github.com/kennethreitz/responder/actions/workflows/docs.yaml/badge.svg 14 | :target: https://github.com/kennethreitz/responder/actions/workflows/docs.yaml 15 | .. |version| image:: https://img.shields.io/pypi/v/responder.svg 16 | :target: https://pypi.org/project/responder/ 17 | .. |license| image:: https://img.shields.io/pypi/l/responder.svg 18 | :target: https://pypi.org/project/responder/ 19 | .. |python-versions| image:: https://img.shields.io/pypi/pyversions/responder.svg 20 | :target: https://pypi.org/project/responder/ 21 | .. |downloads| image:: https://static.pepy.tech/badge/responder/month 22 | :target: https://www.pepy.tech/projects/responder 23 | .. |contributors| image:: https://img.shields.io/github/contributors/kennethreitz/responder.svg 24 | :target: https://github.com/kennethreitz/responder/graphs/contributors 25 | .. |say-thanks| image:: https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg 26 | :target: https://saythanks.io/to/kennethreitz 27 | 28 | .. code:: python 29 | 30 | import responder 31 | 32 | api = responder.API() 33 | 34 | @api.route("/{greeting}") 35 | async def greet_world(req, resp, *, greeting): 36 | resp.text = f"{greeting}, world!" 37 | 38 | if __name__ == '__main__': 39 | api.run() 40 | 41 | Responder is powered by `Starlette`_. 42 | 43 | The example program demonstrates an `ASGI`_ application using `Responder`_, 44 | including production-ready components like the `uvicorn`_ webserver, based 45 | on `uvloop`_, the static files server `ServeStatic`_, and the `Jinja`_ 46 | templating library pre-installed. 47 | The ``async`` declaration within the example program is optional. 48 | 49 | Features 50 | -------- 51 | 52 | - A pleasant API, with a single import statement. 53 | - Class-based views without inheritance. 54 | - `ASGI`_, the future of Python web services. 55 | - Asynchronous Python frameworks and applications. 56 | - Automatic gzip compression. 57 | - WebSocket support! 58 | - The ability to mount any ASGI / WSGI app at a subroute. 59 | - `f-string syntax`_ route declaration. 60 | - Mutable response object, passed into each view. No need to return anything. 61 | - Background tasks, spawned off in a ``ThreadPoolExecutor``. 62 | - GraphQL (with *GraphiQL*) support! 63 | - OpenAPI schema generation, with interactive documentation! 64 | - Single-page webapp support! 65 | 66 | Testimonials 67 | ------------ 68 | 69 | “Pleasantly very taken with python-responder. 70 | `@kennethreitz`_ at his absolute best.” 71 | 72 | — Rudraksh M.K. 73 | 74 | .. 75 | 76 | "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." 77 | 78 | — Tom Christie, author of `Django REST Framework`_ 79 | 80 | .. 81 | 82 | “I love that you are exploring new patterns. Go go go!” 83 | 84 | — Danny Greenfield, author of `Two Scoops of Django`_ 85 | 86 | 87 | User Guides 88 | ----------- 89 | 90 | .. toctree:: 91 | :maxdepth: 2 92 | 93 | quickstart 94 | tour 95 | deployment 96 | testing 97 | api 98 | cli 99 | 100 | 101 | Installing Responder 102 | -------------------- 103 | 104 | Use ``uv`` for fast installation. 105 | 106 | .. code-block:: shell 107 | 108 | uv pip install --upgrade 'responder' 109 | 110 | Or use standard pip where ``uv`` is not available. 111 | 112 | .. code-block:: shell 113 | 114 | pip install --upgrade 'responder' 115 | 116 | Responder supports **Python 3.7+**. 117 | 118 | Development 119 | ----------- 120 | 121 | If you are looking at installing Responder 122 | for hacking on it, please refer to the :ref:`sandbox` documentation. 123 | 124 | .. toctree:: 125 | :maxdepth: 1 126 | 127 | changes 128 | Sandbox 129 | backlog 130 | 131 | 132 | The Basic Idea 133 | -------------- 134 | 135 | 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. 136 | 137 | - Setting ``resp.content`` sends back bytes. 138 | - Setting ``resp.text`` sends back unicode, while setting ``resp.html`` sends back HTML. 139 | - Setting ``resp.media`` sends back JSON/YAML (``.text``/``.html``/``.content`` override this). 140 | - Case-insensitive ``req.headers`` dict (from Requests directly). 141 | - ``resp.status_code``, ``req.method``, ``req.url``, and other familiar friends. 142 | 143 | Ideas 144 | ----- 145 | 146 | - Flask-style route expression, with new capabilities -- all while using Python 3.6+'s new f-string syntax. 147 | - 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. 148 | - **A built in testing client that uses the actual Requests you know and love**. 149 | - The ability to mount other WSGI apps easily. 150 | - Automatic gzipped-responses. 151 | - 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. 152 | - A production static files server is built-in. 153 | - `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. 154 | - GraphQL support, via Graphene. The goal here is to have any GraphQL query exposable at any route, magically. 155 | 156 | 157 | Indices and tables 158 | ================== 159 | 160 | * :ref:`genindex` 161 | * :ref:`modindex` 162 | * :ref:`search` 163 | 164 | 165 | .. _@kennethreitz: https://x.com/kennethreitz42 166 | .. _ASGI: https://en.wikipedia.org/wiki/Asynchronous_Server_Gateway_Interface 167 | .. _Django REST Framework: https://www.django-rest-framework.org/ 168 | .. _f-string syntax: https://docs.python.org/3/whatsnew/3.6.html#pep-498-formatted-string-literals 169 | .. _Jinja: https://jinja.palletsprojects.com/en/stable/ 170 | .. _ServeStatic: https://archmonger.github.io/ServeStatic/latest/ 171 | .. _Slowloris: https://en.wikipedia.org/wiki/Slowloris_(computer_security) 172 | .. _Starlette: https://www.starlette.io/ 173 | .. _Responder: https://responder.kennethreitz.org/ 174 | .. _Two Scoops of Django: https://www.feldroy.com/two-scoops-press#two-scoops-of-django 175 | .. _uvicorn: https://www.uvicorn.org/ 176 | .. _uvloop: https://uvloop.readthedocs.io/ 177 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quick Start! 2 | ============ 3 | 4 | This section of the documentation exists to provide an introduction to the Responder interface, 5 | as well as educate the user on basic functionality. 6 | 7 | 8 | Declare a Web Service 9 | --------------------- 10 | 11 | The first thing you need to do is declare a web service:: 12 | 13 | import responder 14 | 15 | api = responder.API() 16 | 17 | Hello World! 18 | ------------ 19 | 20 | Then, you can add a view / route to it. 21 | 22 | Here, we'll make the root URL say "hello world!":: 23 | 24 | @api.route("/") 25 | def hello_world(req, resp): 26 | resp.text = "hello, world!" 27 | 28 | Run the Server 29 | -------------- 30 | 31 | Next, we can run our web service easily, with ``api.run()``:: 32 | 33 | api.run() 34 | 35 | This will spin up a production web server on port ``5042``, ready for incoming HTTP requests. 36 | 37 | Note: you can pass ``port=5000`` if you want to customize the port. The ``PORT`` environment variable for established web service providers (e.g. Heroku) will automatically be honored and will set the listening address to ``0.0.0.0`` automatically (also configurable through the ``address`` keyword argument). 38 | 39 | 40 | Accept Route Arguments 41 | ---------------------- 42 | 43 | If you want dynamic URLs, you can use Python's familiar *f-string syntax* to declare variables in your routes:: 44 | 45 | @api.route("/hello/{who}") 46 | def hello_to(req, resp, *, who): 47 | resp.text = f"hello, {who}!" 48 | 49 | A ``GET`` request to ``/hello/brettcannon`` will result in a response of ``hello, brettcannon!``. 50 | 51 | Type convertors are also available:: 52 | 53 | @api.route("/add/{a:int}/{b:int}") 54 | async def add(req, resp, *, a, b): 55 | resp.text = f"{a} + {b} = {a + b}" 56 | 57 | Supported types: ``str``, ``int`` and ``float``. 58 | 59 | Returning JSON / YAML 60 | --------------------- 61 | 62 | If you want your API to send back JSON, simply set the ``resp.media`` property to a JSON-serializable Python object:: 63 | 64 | 65 | @api.route("/hello/{who}/json") 66 | def hello_to(req, resp, *, who): 67 | resp.media = {"hello": who} 68 | 69 | A ``GET`` request to ``/hello/guido/json`` will result in a response of ``{'hello': 'guido'}``. 70 | 71 | If the client requests YAML instead (with a header of ``Accept: application/x-yaml``), YAML will be sent. 72 | 73 | Rendering a Template 74 | -------------------- 75 | 76 | Responder provides a built-in light `Jinja`_ wrapper ``templates.Templates`` 77 | 78 | Usage:: 79 | 80 | from responder.templates import Templates 81 | 82 | templates = Templates() 83 | 84 | @api.route("/hello/{name}/html") 85 | def hello(req, resp, name): 86 | resp.html = templates.render("hello.html", name=name) 87 | 88 | 89 | Also a ``render_async`` is available:: 90 | 91 | templates = Templates(enable_async=True) 92 | resp.html = await templates.render_async("hello.html", who=who) 93 | 94 | You can also use the existing ``api.template(filename, *args, **kwargs)`` to render templates:: 95 | 96 | @api.route("/hello/{who}/html") 97 | def hello_html(req, resp, *, who): 98 | resp.html = api.template('hello.html', who=who) 99 | 100 | 101 | Setting Response Status Code 102 | ---------------------------- 103 | 104 | If you want to set the response status code, simply set ``resp.status_code``:: 105 | 106 | @api.route("/416") 107 | def teapot(req, resp): 108 | resp.status_code = api.status_codes.HTTP_416 # ...or 416 109 | 110 | 111 | Setting Response Headers 112 | ------------------------ 113 | 114 | If you want to set a response header, like ``X-Pizza: 42``, simply modify the ``resp.headers`` dictionary:: 115 | 116 | @api.route("/pizza") 117 | def pizza_pizza(req, resp): 118 | resp.headers['X-Pizza'] = '42' 119 | 120 | That's it! 121 | 122 | 123 | Receiving Data & Background Tasks 124 | --------------------------------- 125 | 126 | If you're expecting to read any request data, on the server, you need to declare your view as async and await the content. 127 | 128 | Here, we'll process our data in the background, while responding immediately to the client:: 129 | 130 | import time 131 | 132 | @api.route("/incoming") 133 | async def receive_incoming(req, resp): 134 | 135 | @api.background.task 136 | def process_data(data): 137 | """Just sleeps for three seconds, as a demo.""" 138 | time.sleep(3) 139 | 140 | 141 | # Parse the incoming data as form-encoded. 142 | # Note: 'json' and 'yaml' formats are also automatically supported. 143 | data = await req.media() 144 | 145 | # Process the data (in the background). 146 | process_data(data) 147 | 148 | # Immediately respond that upload was successful. 149 | resp.media = {'success': True} 150 | 151 | A ``POST`` request to ``/incoming`` will result in an immediate response of ``{'success': true}``. 152 | 153 | 154 | Here's a sample code to post a file with background:: 155 | 156 | @api.route("/") 157 | async def upload_file(req, resp): 158 | 159 | @api.background.task 160 | def process_data(data): 161 | f = open('./{}'.format(data['file']['filename']), 'w') 162 | f.write(data['file']['content'].decode('utf-8')) 163 | f.close() 164 | 165 | data = await req.media(format='files') 166 | process_data(data) 167 | 168 | resp.media = {'success': 'ok'} 169 | 170 | You can send a file easily with requests:: 171 | 172 | import requests 173 | 174 | data = {'file': ('hello.txt', 'hello, world!', "text/plain")} 175 | r = requests.post('http://127.0.0.1:8210/file', files=data) 176 | 177 | print(r.text) 178 | 179 | 180 | .. _Jinja: https://jinja.palletsprojects.com/en/stable/ 181 | -------------------------------------------------------------------------------- /docs/source/sandbox.md: -------------------------------------------------------------------------------- 1 | (sandbox)= 2 | # Development Sandbox 3 | 4 | ## Setup 5 | Set up a development sandbox. 6 | 7 | Acquire sources and create virtualenv. 8 | ```shell 9 | git clone https://github.com/kennethreitz/responder.git 10 | cd responder 11 | uv venv 12 | ``` 13 | 14 | Install project in editable mode, including 15 | all runtime extensions and development tools. 16 | ```shell 17 | uv pip install --upgrade --editable '.[full,develop,docs,release,test]' 18 | ``` 19 | 20 | ## Operations 21 | Invoke linter and software tests. 22 | ```shell 23 | source .venv/bin/activate 24 | poe check 25 | ``` 26 | 27 | Format code. 28 | ```shell 29 | poe format 30 | ``` 31 | 32 | Documentation authoring. 33 | ```shell 34 | poe docs-autobuild 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/source/testing.rst: -------------------------------------------------------------------------------- 1 | Building and Testing with Responder 2 | =================================== 3 | 4 | Responder comes with a first-class, well supported test client for your ASGI web services: **Requests**. 5 | 6 | Here, we'll go over the basics of setting up a proper Python package and adding testing to it. 7 | 8 | The Basics 9 | ---------- 10 | 11 | Your repository should look like this:: 12 | 13 | Pipfile Pipfile.lock api.py test_api.py 14 | 15 | ``$ cat api.py``:: 16 | 17 | import responder 18 | 19 | api = responder.API() 20 | 21 | @api.route("/") 22 | def hello_world(req, resp): 23 | resp.text = "hello, world!" 24 | 25 | if __name__ == "__main__": 26 | api.run() 27 | 28 | 29 | ``$ cat Pipfile``:: 30 | 31 | [[source]] 32 | url = "https://pypi.org/simple" 33 | verify_ssl = true 34 | name = "pypi" 35 | 36 | [packages] 37 | responder = "*" 38 | 39 | [dev-packages] 40 | pytest = "*" 41 | 42 | [requires] 43 | python_version = "3.7" 44 | 45 | [pipenv] 46 | allow_prereleases = true 47 | 48 | Writing Tests 49 | ------------- 50 | 51 | ``$ cat test_api.py``:: 52 | 53 | import pytest 54 | import api as service 55 | 56 | @pytest.fixture 57 | def api(): 58 | return service.api 59 | 60 | 61 | def test_hello_world(api): 62 | r = api.requests.get("/") 63 | assert r.text == "hello, world!" 64 | 65 | ``$ pytest``:: 66 | 67 | ... 68 | ========================== 1 passed in 0.10 seconds ========================== 69 | 70 | 71 | (Optional) Proper Python Package 72 | -------------------------------- 73 | 74 | Optionally, you can not rely on relative imports, and instead install your api as a proper package. This requires: 75 | 76 | 1. A `proper setup.py `_ file. 77 | 2. ``$ pipenv install -e . --dev`` 78 | 79 | 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``. 80 | 81 | This will ensure that your application gets installed in every developer's environment, using Pipenv. 82 | -------------------------------------------------------------------------------- /examples/helloworld.py: -------------------------------------------------------------------------------- 1 | # Example HTTP service definition, using Responder. 2 | # https://pypi.org/project/responder/ 3 | import responder 4 | 5 | api = responder.API() 6 | 7 | 8 | @api.route("/{greeting}") 9 | async def greet_world(req, resp, *, greeting): 10 | resp.text = f"{greeting}, world!" 11 | 12 | 13 | if __name__ == "__main__": 14 | api.run() 15 | -------------------------------------------------------------------------------- /examples/user.py: -------------------------------------------------------------------------------- 1 | # Example HTTP service definition, using Responder. 2 | # https://pypi.org/project/responder/ 3 | import responder 4 | 5 | api = responder.API() 6 | 7 | 8 | @api.route("/") 9 | async def index(req, resp): 10 | resp.text = "Welcome" 11 | 12 | 13 | @api.route("/user") 14 | async def user_create(req, resp): 15 | data = await req.media() 16 | resp.text = f"Hello, {data['username']}" 17 | 18 | 19 | @api.route("/user/{identifier}") 20 | async def user_get(req, resp, *, identifier): 21 | resp.text = f"Hello, user {identifier}" 22 | 23 | 24 | if __name__ == "__main__": 25 | api.run() 26 | -------------------------------------------------------------------------------- /ext/Artboard 1-100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/ext/Artboard 1-100.jpg -------------------------------------------------------------------------------- /ext/Artboard 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/ext/Artboard 1.png -------------------------------------------------------------------------------- /ext/Artboard 1@2x-100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/ext/Artboard 1@2x-100.jpg -------------------------------------------------------------------------------- /ext/assets/noun_https_1875421_000000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/ext/assets/noun_https_1875421_000000.png -------------------------------------------------------------------------------- /ext/assets/noun_https_1875421_000000.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ext/responder-logo.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/ext/responder-logo.ai -------------------------------------------------------------------------------- /ext/small.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/ext/small.jpg -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = [ 4 | "setuptools>=42", # At least v42 of setuptools required. 5 | ] 6 | 7 | [tool.ruff] 8 | line-length = 90 9 | 10 | extend-exclude = [ 11 | "docs/source/conf.py", 12 | "setup.py", 13 | ] 14 | 15 | lint.select = [ 16 | # Builtins 17 | "A", 18 | # Bugbear 19 | "B", 20 | # comprehensions 21 | "C4", 22 | # Pycodestyle 23 | "E", 24 | # eradicate 25 | "ERA", 26 | # Pyflakes 27 | "F", 28 | # isort 29 | "I", 30 | # pandas-vet 31 | "PD", 32 | # return 33 | "RET", 34 | # Bandit 35 | "S", 36 | # print 37 | "T20", 38 | "W", 39 | # flake8-2020 40 | "YTT", 41 | ] 42 | 43 | lint.extend-ignore = [ 44 | "S101", # Allow use of `assert`. 45 | ] 46 | 47 | lint.per-file-ignores."responder/util/cmd.py" = [ "A005" ] # Module shadows a Python standard-library module 48 | 49 | lint.per-file-ignores."tests/*" = [ 50 | "ERA001", # Found commented-out code. 51 | "S101", # Allow use of `assert`, and `print`. 52 | ] 53 | 54 | [tool.pytest.ini_options] 55 | addopts = """ 56 | -rfEXs -p pytester --strict-markers --verbosity=3 57 | --cov --cov-report=term-missing --cov-report=xml 58 | """ 59 | filterwarnings = [ 60 | "error::UserWarning", 61 | ] 62 | log_level = "DEBUG" 63 | log_cli_level = "DEBUG" 64 | log_format = "%(asctime)-15s [%(name)-36s] %(levelname)-8s: %(message)s" 65 | minversion = "2.0" 66 | testpaths = [ 67 | "responder", 68 | "tests", 69 | ] 70 | markers = [ 71 | ] 72 | xfail_strict = true 73 | 74 | [tool.coverage.run] 75 | branch = false 76 | omit = [ 77 | "*.html", 78 | "tests/*", 79 | ] 80 | 81 | [tool.coverage.report] 82 | fail_under = 0 83 | show_missing = true 84 | exclude_lines = [ 85 | "# pragma: no cover", 86 | "raise NotImplemented", 87 | ] 88 | 89 | [tool.mypy] 90 | packages = [ 91 | "responder", 92 | ] 93 | exclude = [ 94 | ] 95 | check_untyped_defs = true 96 | explicit_package_bases = true 97 | ignore_missing_imports = true 98 | implicit_optional = true 99 | install_types = true 100 | namespace_packages = true 101 | non_interactive = true 102 | 103 | [tool.poe.tasks] 104 | 105 | check = [ 106 | "lint", 107 | "test", 108 | ] 109 | 110 | docs-autobuild = [ 111 | { cmd = "sphinx-autobuild --open-browser --watch docs/source docs/source docs/build" }, 112 | ] 113 | docs-html = [ 114 | { cmd = "sphinx-build -W --keep-going docs/source docs/build" }, 115 | ] 116 | docs-linkcheck = [ 117 | { cmd = "sphinx-build -W --keep-going -b linkcheck docs/source docs/build" }, 118 | ] 119 | 120 | format = [ 121 | { cmd = "ruff format ." }, 122 | # Configure Ruff not to auto-fix (remove!): 123 | # unused imports (F401), unused variables (F841), `print` statements (T201), and commented-out code (ERA001). 124 | { cmd = "ruff check --fix --ignore=ERA --ignore=F401 --ignore=F841 --ignore=T20 --ignore=ERA001 ." }, 125 | { cmd = "pyproject-fmt --keep-full-version pyproject.toml" }, 126 | ] 127 | 128 | lint = [ 129 | { cmd = "ruff format --check ." }, 130 | { cmd = "ruff check ." }, 131 | { cmd = "validate-pyproject pyproject.toml" }, 132 | { cmd = "mypy" }, 133 | ] 134 | 135 | release = [ 136 | { cmd = "python -m build" }, 137 | { cmd = "twine upload --skip-existing dist/*" }, 138 | ] 139 | 140 | [tool.poe.tasks.test] 141 | cmd = "pytest" 142 | help = "Invoke software tests" 143 | 144 | [tool.poe.tasks.test.args.expression] 145 | options = [ "-k" ] 146 | 147 | [tool.poe.tasks.test.args.marker] 148 | options = [ "-m" ] 149 | -------------------------------------------------------------------------------- /responder/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Responder - a familiar HTTP Service Framework. 3 | 4 | This module exports the core functionality of the Responder framework, 5 | including the API, Request, and Response classes. 6 | """ 7 | 8 | from . import ext 9 | from .core import API, Request, Response 10 | 11 | __all__ = [ 12 | "API", 13 | "Request", 14 | "Response", 15 | "ext", 16 | ] 17 | -------------------------------------------------------------------------------- /responder/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.0.7" 2 | -------------------------------------------------------------------------------- /responder/background.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import concurrent.futures 3 | import multiprocessing 4 | import traceback 5 | 6 | from starlette.concurrency import run_in_threadpool 7 | 8 | 9 | class BackgroundQueue: 10 | def __init__(self, n=None): 11 | if n is None: 12 | n = multiprocessing.cpu_count() 13 | 14 | self.n = n 15 | self.pool = concurrent.futures.ThreadPoolExecutor(max_workers=n) 16 | self.results = [] 17 | 18 | def run(self, f, *args, **kwargs): 19 | self.pool._max_workers = self.n 20 | self.pool._adjust_thread_count() 21 | 22 | f = self.pool.submit(f, *args, **kwargs) 23 | self.results.append(f) 24 | return f 25 | 26 | def task(self, f): 27 | def on_future_done(fs): 28 | try: 29 | fs.result() 30 | except Exception: 31 | traceback.print_exc() 32 | 33 | def do_task(*args, **kwargs): 34 | result = self.run(f, *args, **kwargs) 35 | result.add_done_callback(on_future_done) 36 | return result 37 | 38 | return do_task 39 | 40 | async def __call__(self, func, *args, **kwargs) -> None: 41 | if asyncio.iscoroutinefunction(func): 42 | return await asyncio.ensure_future(func(*args, **kwargs)) 43 | return await run_in_threadpool(func, *args, **kwargs) 44 | -------------------------------------------------------------------------------- /responder/core.py: -------------------------------------------------------------------------------- 1 | from .api import API 2 | from .models import Request, Response 3 | 4 | __all__ = [ 5 | "API", 6 | "Request", 7 | "Response", 8 | ] 9 | -------------------------------------------------------------------------------- /responder/ext/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/responder/ext/__init__.py -------------------------------------------------------------------------------- /responder/ext/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Responder CLI. 3 | 4 | A web framework for Python. 5 | 6 | Commands: 7 | run Start the application server 8 | build Build frontend assets using npm 9 | 10 | Usage: 11 | responder 12 | responder run [--debug] [--limit-max-requests=] 13 | responder build [] 14 | responder --version 15 | 16 | Options: 17 | -h --help Show this screen. 18 | -v --version Show version. 19 | --debug Enable debug mode with verbose logging. 20 | --limit-max-requests= Maximum number of requests to handle before shutting down. 21 | 22 | Arguments: 23 | For run: Python module specifier (e.g., "app:api" loads api from app.py) 24 | Format: "module.submodule:variable_name" where variable_name is your API instance 25 | For build: Directory containing package.json (default: current directory) 26 | 27 | Examples: 28 | responder run app:api # Run the 'api' instance from app.py 29 | responder run myapp/core.py:application # Run the 'application' instance from myapp/core.py 30 | responder build # Build frontend assets 31 | """ # noqa: E501 32 | 33 | import logging 34 | import platform 35 | import subprocess 36 | import sys 37 | import typing as t 38 | from pathlib import Path 39 | 40 | import docopt 41 | 42 | from responder.__version__ import __version__ 43 | from responder.util.python import InvalidTarget, load_target 44 | 45 | logger = logging.getLogger(__name__) 46 | 47 | 48 | def cli() -> None: 49 | """ 50 | Main entry point for the Responder CLI. 51 | 52 | Parses command line arguments and executes the appropriate command. 53 | Supports running the application, building assets, and displaying version info. 54 | """ 55 | args = docopt.docopt(__doc__, argv=None, version=__version__, options_first=False) 56 | setup_logging(args["--debug"]) 57 | 58 | target: t.Optional[str] = args[""] 59 | build: bool = args["build"] 60 | debug: bool = args["--debug"] 61 | run: bool = args["run"] 62 | 63 | if build: 64 | target_path = Path(target).resolve() if target else Path.cwd() 65 | if not target_path.is_dir() or not (target_path / "package.json").exists(): 66 | logger.error( 67 | f"Invalid target directory or missing package.json: {target_path}" 68 | ) 69 | sys.exit(1) 70 | npm_cmd = "npm.cmd" if platform.system() == "Windows" else "npm" 71 | try: 72 | logger.info("Starting frontend asset build") 73 | # S603, S607 are addressed by validating the target directory. 74 | subprocess.check_call( # noqa: S603, S607 75 | [npm_cmd, "run", "build"], 76 | cwd=target_path, 77 | timeout=300, 78 | ) 79 | logger.info("Frontend asset build completed successfully") 80 | except FileNotFoundError: 81 | logger.error("npm not found. Please install Node.js and npm.") 82 | sys.exit(1) 83 | except subprocess.CalledProcessError as e: 84 | logger.error(f"Build failed with exit code {e.returncode}") 85 | sys.exit(1) 86 | 87 | if run: 88 | if not target: 89 | logger.error("Target argument is required for run command") 90 | sys.exit(1) 91 | 92 | # Maximum request limit. Terminating afterward. Suitable for software testing. 93 | limit_max_requests = args["--limit-max-requests"] 94 | if limit_max_requests is not None: 95 | try: 96 | limit_max_requests = int(limit_max_requests) 97 | if limit_max_requests <= 0: 98 | logger.error("limit-max-requests must be a positive integer") 99 | sys.exit(1) 100 | except ValueError: 101 | logger.error("limit-max-requests must be a valid integer") 102 | sys.exit(1) 103 | 104 | # Load application from target. 105 | try: 106 | api = load_target(target=target) 107 | except InvalidTarget as ex: 108 | raise ValueError( 109 | f"{ex}. " 110 | "Use either a Python module entrypoint specification, " 111 | "a filesystem path, or a remote URL. " 112 | "See also https://responder.kennethreitz.org/cli.html." 113 | ) from ex 114 | 115 | # Launch Responder API server (uvicorn). 116 | api.run(debug=debug, limit_max_requests=limit_max_requests) 117 | 118 | 119 | def setup_logging(debug: bool) -> None: 120 | """ 121 | Configure logging based on debug mode. 122 | 123 | Args: 124 | debug: When True, sets logging level to DEBUG; otherwise, sets to INFO 125 | """ 126 | log_level = logging.DEBUG if debug else logging.INFO 127 | logging.basicConfig( 128 | level=log_level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 129 | ) 130 | -------------------------------------------------------------------------------- /responder/ext/graphql/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from functools import partial 3 | 4 | from graphql_server import default_format_error, encode_execution_results, json_encode 5 | 6 | from .templates import GRAPHIQL 7 | 8 | 9 | class GraphQLView: 10 | def __init__(self, *, api, schema): 11 | self.api = api 12 | self.schema = schema 13 | 14 | @staticmethod 15 | async def _resolve_graphql_query(req, resp): 16 | # TODO: Get variables and operation_name from form data, params, request text? 17 | 18 | if "json" in req.mimetype: 19 | json_media = await req.media("json") 20 | if "query" not in json_media: 21 | resp.status_code = 400 22 | resp.media = {"errors": ["'query' key is required in the JSON payload"]} 23 | return None, None, None 24 | return ( 25 | json_media["query"], 26 | json_media.get("variables"), 27 | json_media.get("operationName"), 28 | ) 29 | 30 | # Support query/q in form data. 31 | # Form data is awaiting https://github.com/encode/starlette/pull/102 32 | """ 33 | if "query" in req.media("form"): 34 | return req.media("form")["query"], None, None 35 | if "q" in req.media("form"): 36 | return req.media("form")["q"], None, None 37 | """ 38 | 39 | # Support query/q in params. 40 | if "query" in req.params: 41 | return req.params["query"], None, None 42 | if "q" in req.params: 43 | return req.params["q"], None, None 44 | 45 | # Otherwise, the request text is used (typical). 46 | # TODO: Make some assertions about content-type here. 47 | return req.text, None, None 48 | 49 | async def graphql_response(self, req, resp): 50 | show_graphiql = req.method == "get" and req.accepts("text/html") 51 | 52 | if show_graphiql: 53 | resp.content = self.api.templates.render_string( 54 | GRAPHIQL, endpoint=req.url.path 55 | ) 56 | return None 57 | 58 | query, variables, operation_name = await self._resolve_graphql_query(req, resp) 59 | if query is None: 60 | return None 61 | 62 | context = {"request": req, "response": resp} 63 | result = self.schema.execute( 64 | query, variables=variables, operation_name=operation_name, context=context 65 | ) 66 | result, status_code = encode_execution_results( 67 | [result], 68 | is_batch=False, 69 | format_error=default_format_error, 70 | encode=partial(json_encode, pretty=False), 71 | ) 72 | resp.media = json.loads(result) 73 | return (query, result, status_code) 74 | 75 | async def on_request(self, req, resp): 76 | await self.graphql_response(req, resp) 77 | 78 | async def __call__(self, req, resp): 79 | await self.on_request(req, resp) 80 | -------------------------------------------------------------------------------- /responder/ext/graphql/templates.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: E501 2 | GRAPHIQL = """ 3 | {% set GRAPHIQL_VERSION = '0.12.0' %} 4 | 5 | 12 | 13 | 14 | 15 | 26 | 27 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 |
Loading...
42 | 144 | 145 | 146 | """.strip() 147 | -------------------------------------------------------------------------------- /responder/ext/openapi/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from apispec import APISpec, yaml_utils 4 | from apispec.ext.marshmallow import MarshmallowPlugin 5 | 6 | from responder import status_codes 7 | from responder.statics import API_THEMES, DEFAULT_OPENAPI_THEME 8 | from responder.templates import Templates 9 | 10 | 11 | class OpenAPISchema: 12 | def __init__( 13 | self, 14 | app, 15 | title, 16 | version, 17 | plugins=None, 18 | description=None, 19 | terms_of_service=None, 20 | contact=None, 21 | license=None, # noqa: A002 22 | openapi=None, 23 | openapi_route="/schema.yml", 24 | docs_route="/docs/", 25 | static_route="/static", 26 | openapi_theme=DEFAULT_OPENAPI_THEME, 27 | ): 28 | self.app = app 29 | self.schemas = {} 30 | self.title = title 31 | self.version = version 32 | self.description = description 33 | self.terms_of_service = terms_of_service 34 | self.contact = contact 35 | self.license = license 36 | 37 | self.openapi_version = openapi 38 | self.openapi_route = openapi_route 39 | 40 | self.docs_theme = ( 41 | openapi_theme if openapi_theme in API_THEMES else DEFAULT_OPENAPI_THEME 42 | ) 43 | self.docs_route = docs_route 44 | 45 | self.plugins = [MarshmallowPlugin()] if plugins is None else plugins 46 | 47 | if self.openapi_version is not None: 48 | self.app.add_route(self.openapi_route, self.schema_response) 49 | 50 | if self.docs_route is not None: 51 | self.app.add_route(self.docs_route, self.docs_response) 52 | 53 | theme_path = (Path(__file__).parent / "docs").resolve() 54 | self.templates = Templates(directory=theme_path) 55 | 56 | self.static_route = static_route 57 | 58 | @property 59 | def _apispec(self): 60 | info = {} 61 | if self.description is not None: 62 | info["description"] = self.description 63 | if self.terms_of_service is not None: 64 | info["termsOfService"] = self.terms_of_service 65 | if self.contact is not None: 66 | info["contact"] = self.contact 67 | if self.license is not None: 68 | info["license"] = self.license 69 | 70 | spec = APISpec( 71 | title=self.title, 72 | version=self.version, 73 | openapi_version=self.openapi_version, 74 | plugins=self.plugins, 75 | info=info, 76 | ) 77 | 78 | for route in self.app.router.routes: 79 | if route.description: 80 | operations = yaml_utils.load_operations_from_docstring(route.description) 81 | spec.path(path=route.route, operations=operations) 82 | 83 | for name, schema in self.schemas.items(): 84 | spec.components.schema(name, schema=schema) 85 | 86 | return spec 87 | 88 | @property 89 | def openapi(self): 90 | return self._apispec.to_yaml() 91 | 92 | def add_schema(self, name, schema, check_existing=True): 93 | """Adds a marshmallow schema to the API specification.""" 94 | if check_existing: 95 | assert name not in self.schemas 96 | 97 | self.schemas[name] = schema 98 | 99 | def schema(self, name, **options): 100 | """Decorator for creating new routes around function and class definitions. 101 | 102 | Usage:: 103 | 104 | from marshmallow import Schema, fields 105 | 106 | @api.schema("Pet") 107 | class PetSchema(Schema): 108 | name = fields.Str() 109 | 110 | """ 111 | 112 | def decorator(f): 113 | self.add_schema(name=name, schema=f, **options) 114 | return f 115 | 116 | return decorator 117 | 118 | @property 119 | def docs(self): 120 | return self.templates.render( 121 | f"{self.docs_theme}.html", 122 | title=self.title, 123 | version=self.version, 124 | schema_url="/schema.yml", 125 | ) 126 | 127 | def static_url(self, asset): 128 | """Given a static asset, return its URL path.""" 129 | assert self.static_route is not None 130 | return f"{self.static_route}/{str(asset)}" 131 | 132 | def docs_response(self, req, resp): 133 | resp.html = self.docs 134 | 135 | def schema_response(self, req, resp): 136 | resp.status_code = status_codes.HTTP_200 # type: ignore[attr-defined] 137 | resp.headers["Content-Type"] = "application/x-yaml" 138 | resp.content = self.openapi 139 | -------------------------------------------------------------------------------- /responder/ext/openapi/docs/elements.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | {{ title }} {{ version }} 10 | 11 | 12 | 16 | 17 | 18 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /responder/ext/openapi/docs/rapidoc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ title }} {{ version }} 6 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /responder/ext/openapi/docs/redoc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ title }} {{ version }} 5 | 6 | 7 | 11 | 12 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /responder/ext/openapi/docs/swagger_ui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ title }} {{ version }} 6 | 11 | 29 | 30 | 31 | 32 |
33 | 34 | 35 | 36 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /responder/formats.py: -------------------------------------------------------------------------------- 1 | import json 2 | from urllib.parse import urlencode 3 | 4 | import yaml 5 | from requests_toolbelt.multipart import decoder 6 | 7 | from .models import QueryDict 8 | 9 | 10 | async def format_form(r, encode=False): 11 | if encode: 12 | return None 13 | if "multipart/form-data" in r.headers.get("Content-Type"): 14 | decode = decoder.MultipartDecoder(await r.content, r.mimetype) 15 | queries = [] 16 | for part in decode.parts: 17 | header = part.headers.get(b"Content-Disposition").decode("utf-8") 18 | text = part.text 19 | 20 | for section in [h.strip() for h in header.split(";")]: 21 | split = section.split("=") 22 | if len(split) > 1: 23 | key = split[1] 24 | key = key[1:-1] 25 | queries.append((key, text)) 26 | 27 | content = urlencode(queries) 28 | return QueryDict(content) 29 | return QueryDict(await r.text) 30 | 31 | 32 | async def format_yaml(r, encode=False): 33 | if encode: 34 | r.headers.update({"Content-Type": "application/x-yaml"}) 35 | return yaml.safe_dump(r.media) 36 | return yaml.safe_load(await r.content) 37 | 38 | 39 | async def format_json(r, encode=False): 40 | if encode: 41 | r.headers.update({"Content-Type": "application/json"}) 42 | return json.dumps(r.media) 43 | return json.loads(await r.content) 44 | 45 | 46 | async def format_files(r, encode=False): 47 | if encode: 48 | return None 49 | decoded = decoder.MultipartDecoder(await r.content, r.mimetype) 50 | dump = {} 51 | for part in decoded.parts: 52 | header = part.headers[b"Content-Disposition"].decode("utf-8") 53 | mimetype = part.headers.get(b"Content-Type", None) 54 | filename = None 55 | 56 | for section in [h.strip() for h in header.split(";")]: 57 | split = section.split("=") 58 | if len(split) > 1: 59 | key = split[0] 60 | value = split[1] 61 | 62 | value = value[1:-1] 63 | 64 | if key == "filename": 65 | filename = value 66 | elif key == "name": 67 | formname = value 68 | 69 | if mimetype is None: 70 | dump[formname] = part.content 71 | else: 72 | dump[formname] = { 73 | "filename": filename, 74 | "content": part.content, 75 | "content-type": mimetype.decode("utf-8"), 76 | } 77 | return dump 78 | 79 | 80 | def get_formats(): 81 | return { 82 | "json": format_json, 83 | "yaml": format_yaml, 84 | "form": format_form, 85 | "files": format_files, 86 | } 87 | -------------------------------------------------------------------------------- /responder/models.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import inspect 3 | import typing as t 4 | from http.cookies import SimpleCookie 5 | from urllib.parse import parse_qs 6 | 7 | import chardet 8 | import rfc3986 9 | from requests.cookies import RequestsCookieJar 10 | from requests.structures import CaseInsensitiveDict 11 | from starlette.requests import Request as StarletteRequest 12 | from starlette.requests import State 13 | from starlette.responses import ( 14 | Response as StarletteResponse, 15 | ) 16 | from starlette.responses import ( 17 | StreamingResponse as StarletteStreamingResponse, 18 | ) 19 | 20 | from .statics import DEFAULT_ENCODING 21 | from .status_codes import HTTP_301 # type: ignore[attr-defined] 22 | 23 | 24 | class QueryDict(dict): 25 | def __init__(self, query_string): 26 | self.update(parse_qs(query_string)) 27 | 28 | def __getitem__(self, key): 29 | """ 30 | Return the last data value for this key, or [] if it's an empty list; 31 | raise KeyError if not found. 32 | """ 33 | list_ = super().__getitem__(key) 34 | try: 35 | return list_[-1] 36 | except IndexError: 37 | return [] 38 | 39 | def get(self, key, default=None): 40 | """ 41 | Return the last data value for the passed key. If key doesn't exist 42 | or value is an empty list, return `default`. 43 | """ 44 | try: 45 | val = self[key] 46 | except KeyError: 47 | return default 48 | if val == []: 49 | return default 50 | return val 51 | 52 | def _get_list(self, key, default=None, force_list=False): 53 | """ 54 | Return a list of values for the key. 55 | 56 | Used internally to manipulate values list. If force_list is True, 57 | return a new copy of values. 58 | """ 59 | try: 60 | values = super().__getitem__(key) 61 | except KeyError: 62 | if default is None: 63 | return [] 64 | return default 65 | else: 66 | if force_list: 67 | values = list(values) if values is not None else None 68 | return values 69 | 70 | def get_list(self, key, default=None): 71 | """ 72 | Return the list of values for the key. If key doesn't exist, return a 73 | default value. 74 | """ 75 | return self._get_list(key, default, force_list=True) 76 | 77 | def items(self): 78 | """ 79 | Yield (key, value) pairs, where value is the last item in the list 80 | associated with the key. 81 | """ 82 | for key in self: 83 | yield key, self[key] 84 | 85 | def items_list(self): 86 | """ 87 | Yield (key, value) pairs, where value is the the list. 88 | """ 89 | yield from super().items() 90 | 91 | 92 | class Request: 93 | __slots__ = [ 94 | "_starlette", 95 | "formats", 96 | "_headers", 97 | "_encoding", 98 | "api", 99 | "_content", 100 | "_cookies", 101 | ] 102 | 103 | def __init__(self, scope, receive, api=None, formats=None): 104 | self._starlette = StarletteRequest(scope, receive) 105 | self.formats = formats 106 | self._encoding = None 107 | self.api = api 108 | self._content = None 109 | 110 | headers: CaseInsensitiveDict = CaseInsensitiveDict() 111 | for key, value in self._starlette.headers.items(): 112 | headers[key] = value 113 | 114 | self._headers = headers 115 | self._cookies = None 116 | 117 | @property 118 | def session(self): 119 | """The session data, in dict form, from the Request.""" 120 | return self._starlette.session 121 | 122 | @property 123 | def headers(self): 124 | """A case-insensitive dictionary, containing all headers sent in the Request.""" 125 | return self._headers 126 | 127 | @property 128 | def mimetype(self): 129 | return self.headers.get("Content-Type", "") 130 | 131 | @property 132 | def method(self): 133 | """The incoming HTTP method used for the request, lower-cased.""" 134 | return self._starlette.method.lower() 135 | 136 | @property 137 | def full_url(self): 138 | """The full URL of the Request, query parameters and all.""" 139 | return str(self._starlette.url) 140 | 141 | @property 142 | def url(self): 143 | """The parsed URL of the Request.""" 144 | return rfc3986.urlparse(self.full_url) 145 | 146 | @property 147 | def cookies(self): 148 | """The cookies sent in the Request, as a dictionary.""" 149 | if self._cookies is None: 150 | cookies = RequestsCookieJar() 151 | cookie_header = self.headers.get("Cookie", "") 152 | 153 | bc: SimpleCookie = SimpleCookie(cookie_header) 154 | for key, morsel in bc.items(): 155 | cookies[key] = morsel.value 156 | 157 | self._cookies = cookies.get_dict() 158 | 159 | return self._cookies 160 | 161 | @property 162 | def params(self): 163 | """A dictionary of the parsed query parameters used for the Request.""" 164 | try: 165 | return QueryDict(self.url.query) 166 | except AttributeError: 167 | return QueryDict({}) 168 | 169 | @property 170 | def state(self) -> State: 171 | """ 172 | Use the state to store additional information. 173 | 174 | This can be a very helpful feature, if you want to hand over 175 | information from a middelware or a route decorator to the 176 | actual route handler. 177 | 178 | Usage: ``request.state.time_started = time.time()`` 179 | """ 180 | return self._starlette.state 181 | 182 | @property 183 | async def encoding(self): 184 | """The encoding of the Request's body. Can be set, manually. Must be awaited.""" 185 | # Use the user-set encoding first. 186 | if self._encoding: 187 | return self._encoding 188 | 189 | return await self.apparent_encoding 190 | 191 | @encoding.setter 192 | def encoding(self, value): 193 | self._encoding = value 194 | 195 | @property 196 | async def content(self): 197 | """The Request body, as bytes. Must be awaited.""" 198 | if not self._content: 199 | self._content = await self._starlette.body() 200 | return self._content 201 | 202 | @property 203 | async def text(self): 204 | """The Request body, as unicode. Must be awaited.""" 205 | return (await self.content).decode(await self.encoding) 206 | 207 | @property 208 | async def declared_encoding(self): 209 | if "Encoding" in self.headers: 210 | return self.headers["Encoding"] 211 | return None 212 | 213 | @property 214 | async def apparent_encoding(self): 215 | """The apparent encoding, provided by the chardet library. Must be awaited.""" 216 | declared_encoding = await self.declared_encoding 217 | 218 | if declared_encoding: 219 | return declared_encoding 220 | 221 | return chardet.detect(await self.content)["encoding"] or DEFAULT_ENCODING 222 | 223 | @property 224 | def is_secure(self): 225 | return self.url.scheme == "https" 226 | 227 | def accepts(self, content_type): 228 | """Returns ``True`` if the incoming Request accepts the given ``content_type``.""" 229 | return content_type in self.headers.get("Accept", []) 230 | 231 | async def media(self, format: t.Union[str, t.Callable] = None): # noqa: A001, A002 232 | """Renders incoming json/yaml/form data as Python objects. Must be awaited. 233 | 234 | :param format: The name of the format being used. 235 | Alternatively, accepts a custom callable for the format type. 236 | """ 237 | 238 | if format is None: 239 | format = "yaml" if "yaml" in self.mimetype or "" else "json" # noqa: A001 240 | format = "form" if "form" in self.mimetype or "" else format # noqa: A001 241 | 242 | formatter: t.Callable 243 | if isinstance(format, str): 244 | try: 245 | formatter = self.formats[format] 246 | except KeyError as ex: 247 | raise ValueError(f"Unable to process data in '{format}' format") from ex 248 | 249 | elif callable(format): 250 | formatter = format 251 | 252 | else: 253 | raise TypeError(f"Invalid 'format' argument: {format}") 254 | 255 | return await formatter(self) 256 | 257 | 258 | def content_setter(mimetype): 259 | def getter(instance): 260 | return instance.content 261 | 262 | def setter(instance, value): 263 | instance.content = value 264 | instance.mimetype = mimetype 265 | 266 | return property(fget=getter, fset=setter) 267 | 268 | 269 | class Response: 270 | __slots__ = [ 271 | "req", 272 | "status_code", 273 | "content", 274 | "encoding", 275 | "media", 276 | "headers", 277 | "formats", 278 | "cookies", 279 | "session", 280 | "mimetype", 281 | "_stream", 282 | ] 283 | 284 | text = content_setter("text/plain") 285 | html = content_setter("text/html") 286 | 287 | def __init__(self, req, *, formats): 288 | self.req = req 289 | #: The HTTP Status Code to use for the Response. 290 | self.status_code: t.Union[int, None] = None 291 | self.content = None #: A bytes representation of the response body. 292 | self.mimetype = None 293 | self.encoding = DEFAULT_ENCODING 294 | self.media = None #: A Python object that will be content-negotiated and 295 | #: sent back to the client. Typically, in JSON formatting. 296 | self._stream = None 297 | self.headers = {} #: A Python dictionary of ``{key: value}``, 298 | #: representing the headers of the response. 299 | self.formats = formats 300 | self.cookies: SimpleCookie = SimpleCookie() #: The cookies set in the Response 301 | self.session = ( 302 | req.session 303 | ) #: The cookie-based session data, in dict form, to add to the Response. 304 | 305 | # Property or func/dec 306 | def stream(self, func, *args, **kwargs): 307 | assert inspect.isasyncgenfunction(func) 308 | 309 | self._stream = functools.partial(func, *args, **kwargs) 310 | 311 | return func 312 | 313 | def redirect(self, location, *, set_text=True, status_code=HTTP_301): 314 | self.status_code = status_code 315 | if set_text: 316 | self.text = f"Redirecting to: {location}" 317 | self.headers.update({"Location": location}) 318 | 319 | @property 320 | async def body(self): 321 | if self._stream is not None: 322 | return (self._stream(), {}) 323 | 324 | if self.content is not None: 325 | headers = {} 326 | content = self.content 327 | if self.mimetype is not None: 328 | headers["Content-Type"] = self.mimetype 329 | if self.mimetype == "text/plain" and self.encoding is not None: 330 | headers["Encoding"] = self.encoding 331 | content = content.encode(self.encoding) 332 | return (content, headers) 333 | 334 | for format_ in self.formats: 335 | if self.req.accepts(format_): 336 | return (await self.formats[format_](self, encode=True)), {} 337 | 338 | # Default to JSON anyway. 339 | return ( 340 | await self.formats["json"](self, encode=True), 341 | {"Content-Type": "application/json"}, 342 | ) 343 | 344 | def set_cookie( 345 | self, 346 | key, 347 | value="", 348 | expires=None, 349 | path="/", 350 | domain=None, 351 | max_age=None, 352 | secure=False, 353 | httponly=True, 354 | ): 355 | self.cookies[key] = value 356 | morsel = self.cookies[key] 357 | if expires is not None: 358 | morsel["expires"] = expires 359 | if path is not None: 360 | morsel["path"] = path 361 | if domain is not None: 362 | morsel["domain"] = domain 363 | if max_age is not None: 364 | morsel["max-age"] = max_age 365 | morsel["secure"] = secure 366 | morsel["httponly"] = httponly 367 | 368 | def _prepare_cookies(self, starlette_response): 369 | cookie_header = ( 370 | (b"set-cookie", morsel.output(header="").lstrip().encode("latin-1")) 371 | for morsel in self.cookies.values() 372 | ) 373 | starlette_response.raw_headers.extend(cookie_header) 374 | 375 | async def __call__(self, scope, receive, send): 376 | body, headers = await self.body 377 | if self.headers: 378 | headers.update(self.headers) 379 | 380 | response_cls: t.Union[ 381 | t.Type[StarletteResponse], t.Type[StarletteStreamingResponse] 382 | ] 383 | if self._stream is not None: 384 | response_cls = StarletteStreamingResponse 385 | else: 386 | response_cls = StarletteResponse 387 | 388 | response = response_cls(body, status_code=self.status_code_safe, headers=headers) 389 | self._prepare_cookies(response) 390 | 391 | await response(scope, receive, send) 392 | 393 | @property 394 | def ok(self): 395 | return 200 <= self.status_code_safe < 300 396 | 397 | @property 398 | def status_code_safe(self) -> int: 399 | if self.status_code is None: 400 | raise RuntimeError("HTTP status code has not been defined") 401 | return self.status_code 402 | -------------------------------------------------------------------------------- /responder/routes.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import inspect 3 | import re 4 | import traceback 5 | import typing as t 6 | from collections import defaultdict 7 | 8 | from starlette.concurrency import run_in_threadpool 9 | from starlette.exceptions import HTTPException 10 | from starlette.middleware.wsgi import WSGIMiddleware 11 | from starlette.types import ASGIApp 12 | from starlette.websockets import WebSocket, WebSocketClose 13 | 14 | from . import status_codes 15 | from .formats import get_formats 16 | from .models import Request, Response 17 | 18 | _CONVERTORS = { 19 | "int": (int, r"\d+"), 20 | "str": (str, r"[^/]+"), 21 | "float": (float, r"\d+(.\d+)?"), 22 | } 23 | 24 | PARAM_RE = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}") 25 | 26 | 27 | def compile_path(path): 28 | path_re = "^" 29 | param_convertors = {} 30 | idx = 0 31 | 32 | for match in PARAM_RE.finditer(path): 33 | param_name, convertor_type = match.groups(default="str") 34 | convertor_type = convertor_type.lstrip(":") 35 | assert convertor_type in _CONVERTORS.keys(), ( 36 | f"Unknown path convertor '{convertor_type}'" 37 | ) 38 | convertor, convertor_re = _CONVERTORS[convertor_type] 39 | 40 | path_re += path[idx : match.start()] 41 | path_re += rf"(?P<{param_name}>{convertor_re})" 42 | 43 | param_convertors[param_name] = convertor 44 | 45 | idx = match.end() 46 | 47 | path_re += path[idx:] + "$" 48 | 49 | return re.compile(path_re), param_convertors 50 | 51 | 52 | class BaseRoute: 53 | def matches(self, scope): 54 | raise NotImplementedError() 55 | 56 | async def __call__(self, scope, receive, send): 57 | raise NotImplementedError() 58 | 59 | 60 | class Route(BaseRoute): 61 | def __init__(self, route, endpoint, *, before_request=False): 62 | assert route.startswith("/"), "Route path must start with '/'" 63 | self.route = route 64 | self.endpoint = endpoint 65 | self.before_request = before_request 66 | 67 | self.path_re, self.param_convertors = compile_path(route) 68 | 69 | def __repr__(self): 70 | return f"" 71 | 72 | def url(self, **params): 73 | return self.route.format(**params) 74 | 75 | @property 76 | def endpoint_name(self): 77 | return self.endpoint.__name__ 78 | 79 | @property 80 | def description(self): 81 | return self.endpoint.__doc__ 82 | 83 | def matches(self, scope): 84 | if scope["type"] != "http": 85 | return False, {} 86 | 87 | path = scope["path"] 88 | match = self.path_re.match(path) 89 | 90 | if match is None: 91 | return False, {} 92 | 93 | matched_params = match.groupdict() 94 | for key, value in matched_params.items(): 95 | matched_params[key] = self.param_convertors[key](value) 96 | 97 | return True, {"path_params": {**matched_params}} 98 | 99 | async def __call__(self, scope, receive, send): 100 | request = Request(scope, receive, formats=get_formats()) 101 | response = Response(req=request, formats=get_formats()) 102 | 103 | path_params = scope.get("path_params", {}) 104 | before_requests = scope.get("before_requests", []) 105 | 106 | for before_request in before_requests.get("http", []): 107 | if asyncio.iscoroutinefunction(before_request): 108 | await before_request(request, response) 109 | else: 110 | await run_in_threadpool(before_request, request, response) 111 | 112 | views = [] 113 | 114 | if inspect.isclass(self.endpoint): 115 | endpoint = self.endpoint() 116 | on_request = getattr(endpoint, "on_request", None) 117 | if on_request: 118 | views.append(on_request) 119 | 120 | method_name = f"on_{request.method}" 121 | try: 122 | view = getattr(endpoint, method_name) 123 | views.append(view) 124 | except AttributeError as ex: 125 | if on_request is None: 126 | raise HTTPException(status_code=status_codes.HTTP_405) from ex # type: ignore[attr-defined] 127 | else: 128 | views.append(self.endpoint) 129 | 130 | for view in views: 131 | # "Monckey patch" for graphql: explicitly checking __call__ 132 | if asyncio.iscoroutinefunction(view) or asyncio.iscoroutinefunction( 133 | view.__call__ 134 | ): 135 | await view(request, response, **path_params) 136 | else: 137 | await run_in_threadpool(view, request, response, **path_params) 138 | 139 | if response.status_code is None: 140 | response.status_code = status_codes.HTTP_200 # type: ignore[attr-defined] 141 | 142 | await response(scope, receive, send) 143 | 144 | def __eq__(self, other): 145 | # [TODO] compare to str ? 146 | return self.route == other.route and self.endpoint == other.endpoint 147 | 148 | def __hash__(self): 149 | return hash(self.route) ^ hash(self.endpoint) ^ hash(self.before_request) 150 | 151 | 152 | class WebSocketRoute(BaseRoute): 153 | def __init__(self, route, endpoint, *, before_request=False): 154 | assert route.startswith("/"), "Route path must start with '/'" 155 | self.route = route 156 | self.endpoint = endpoint 157 | self.before_request = before_request 158 | 159 | self.path_re, self.param_convertors = compile_path(route) 160 | 161 | def __repr__(self): 162 | return f"" 163 | 164 | def url(self, **params): 165 | return self.route.format(**params) 166 | 167 | @property 168 | def endpoint_name(self): 169 | return self.endpoint.__name__ 170 | 171 | @property 172 | def description(self): 173 | return self.endpoint.__doc__ 174 | 175 | def matches(self, scope): 176 | if scope["type"] != "websocket": 177 | return False, {} 178 | 179 | path = scope["path"] 180 | match = self.path_re.match(path) 181 | 182 | if match is None: 183 | return False, {} 184 | 185 | matched_params = match.groupdict() 186 | for key, value in matched_params.items(): 187 | matched_params[key] = self.param_convertors[key](value) 188 | 189 | return True, {"path_params": {**matched_params}} 190 | 191 | async def __call__(self, scope, receive, send): 192 | ws = WebSocket(scope, receive, send) 193 | 194 | before_requests = scope.get("before_requests", []) 195 | for before_request in before_requests.get("ws", []): 196 | await before_request(ws) 197 | 198 | await self.endpoint(ws) 199 | 200 | def __eq__(self, other): 201 | # [TODO] compare to str ? 202 | return self.route == other.route and self.endpoint == other.endpoint 203 | 204 | def __hash__(self): 205 | return hash(self.route) ^ hash(self.endpoint) ^ hash(self.before_request) 206 | 207 | 208 | class Router: 209 | def __init__(self, routes=None, default_response=None, before_requests=None): 210 | self.routes = [] if routes is None else list(routes) 211 | # [TODO] Make its own router 212 | self.apps: t.Dict[str, ASGIApp] = {} 213 | self.default_endpoint = ( 214 | self.default_response if default_response is None else default_response 215 | ) 216 | self.before_requests = ( 217 | {"http": [], "ws": []} if before_requests is None else before_requests 218 | ) 219 | self.events = defaultdict(list) 220 | 221 | def add_route( 222 | self, 223 | route=None, 224 | endpoint=None, 225 | *, 226 | default=False, 227 | websocket=False, 228 | before_request=False, 229 | check_existing=False, 230 | ): 231 | """Adds a route to the router. 232 | :param route: A string representation of the route 233 | :param endpoint: The endpoint for the route -- can be callable, or class. 234 | :param default: If ``True``, all unknown requests will route to this view. 235 | """ 236 | if before_request: 237 | if websocket: 238 | self.before_requests.setdefault("ws", []).append(endpoint) 239 | else: 240 | self.before_requests.setdefault("http", []).append(endpoint) 241 | return 242 | 243 | if check_existing: 244 | assert not self.routes or route not in (item.route for item in self.routes), ( 245 | f"Route '{route}' already exists" 246 | ) 247 | 248 | if default: 249 | self.default_endpoint = endpoint 250 | 251 | if websocket: 252 | route = WebSocketRoute(route, endpoint) 253 | else: 254 | route = Route(route, endpoint) 255 | 256 | self.routes.append(route) 257 | 258 | def mount(self, route, app): 259 | """Mounts ASGI / WSGI applications at a given route""" 260 | self.apps.update({route: app}) 261 | 262 | def add_event_handler(self, event_type, handler): 263 | assert event_type in ( 264 | "startup", 265 | "shutdown", 266 | ), f"Only 'startup' and 'shutdown' events are supported, not {event_type}." 267 | self.events[event_type].append(handler) 268 | 269 | async def trigger_event(self, event_type): 270 | for handler in self.events.get(event_type, []): 271 | if asyncio.iscoroutinefunction(handler): 272 | await handler() 273 | else: 274 | handler() 275 | 276 | def before_request(self, endpoint, websocket=False): 277 | if websocket: 278 | self.before_requests.setdefault("ws", []).append(endpoint) 279 | else: 280 | self.before_requests.setdefault("http", []).append(endpoint) 281 | 282 | def url_for(self, endpoint, **params): 283 | # TODO: Check for params 284 | for route in self.routes: 285 | if endpoint in (route.endpoint, route.endpoint.__name__): 286 | return route.url(**params) 287 | return None 288 | 289 | async def default_response(self, scope, receive, send): 290 | if scope["type"] == "websocket": 291 | websocket_close = WebSocketClose() 292 | await websocket_close(scope, receive, send) 293 | return 294 | 295 | # FIXME: Please review! 296 | request = Request(scope, receive) 297 | response = Response(request, formats=get_formats()) # noqa: F841 298 | 299 | raise HTTPException(status_code=status_codes.HTTP_404) # type: ignore[attr-defined] 300 | 301 | def _resolve_route(self, scope): 302 | for route in self.routes: 303 | matches, child_scope = route.matches(scope) 304 | if matches: 305 | scope.update(child_scope) 306 | return route 307 | return None 308 | 309 | async def lifespan(self, scope, receive, send): 310 | message = await receive() 311 | assert message["type"] == "lifespan.startup" 312 | 313 | try: 314 | await self.trigger_event("startup") 315 | except BaseException: 316 | msg = traceback.format_exc() 317 | await send({"type": "lifespan.startup.failed", "message": msg}) 318 | raise 319 | 320 | await send({"type": "lifespan.startup.complete"}) 321 | message = await receive() 322 | assert message["type"] == "lifespan.shutdown" 323 | await self.trigger_event("shutdown") 324 | await send({"type": "lifespan.shutdown.complete"}) 325 | 326 | async def __call__(self, scope, receive, send): 327 | assert scope["type"] in ("http", "websocket", "lifespan") 328 | 329 | if scope["type"] == "lifespan": 330 | await self.lifespan(scope, receive, send) 331 | return 332 | 333 | path = scope["path"] 334 | root_path = scope.get("root_path", "") 335 | 336 | # Check "primary" mounted routes first (before submounted apps) 337 | route = self._resolve_route(scope) 338 | 339 | scope["before_requests"] = self.before_requests 340 | 341 | if route is not None: 342 | await route(scope, receive, send) 343 | return 344 | 345 | # Call into a submounted app, if one exists. 346 | for path_prefix, app in self.apps.items(): 347 | if path.startswith(path_prefix): 348 | scope["path"] = path[len(path_prefix) :] 349 | scope["root_path"] = root_path + path_prefix 350 | try: 351 | await app(scope, receive, send) 352 | return 353 | except TypeError: 354 | app = WSGIMiddleware(app) 355 | await app(scope, receive, send) 356 | return 357 | 358 | await self.default_endpoint(scope, receive, send) 359 | -------------------------------------------------------------------------------- /responder/staticfiles.py: -------------------------------------------------------------------------------- 1 | from starlette.staticfiles import StaticFiles as StarletteStaticFiles 2 | 3 | 4 | class StaticFiles(StarletteStaticFiles): 5 | """ 6 | Extension to Starlette's `StaticFiles`. 7 | 8 | I've created an issue to discuss allowing multiple directories in 9 | Starlette's `StaticFiles`. 10 | 11 | https://github.com/encode/starlette/issues/625 12 | 13 | I've also made a PR to add this method to Starlette StaticFiles 14 | Once accepted we will remove this. 15 | 16 | https://github.com/encode/starlette/pull/626 17 | """ 18 | 19 | def add_directory(self, directory: str) -> None: 20 | self.all_directories = [*self.all_directories, *self.get_directories(directory)] 21 | -------------------------------------------------------------------------------- /responder/statics.py: -------------------------------------------------------------------------------- 1 | API_THEMES = ["elements", "rapidoc", "redoc", "swagger_ui"] 2 | DEFAULT_ENCODING = "utf-8" 3 | DEFAULT_OPENAPI_THEME = "swagger_ui" 4 | DEFAULT_SESSION_COOKIE = "Responder-Session" 5 | DEFAULT_SECRET_KEY = "NOTASECRET" # noqa: S105 6 | 7 | DEFAULT_CORS_PARAMS = { 8 | "allow_origins": (), 9 | "allow_methods": ("GET",), 10 | "allow_headers": (), 11 | "allow_credentials": False, 12 | "allow_origin_regex": None, 13 | "expose_headers": (), 14 | "max_age": 600, 15 | } 16 | -------------------------------------------------------------------------------- /responder/status_codes.py: -------------------------------------------------------------------------------- 1 | # from: https://github.com/requests/requests/blob/master/requests/status_codes.py 2 | 3 | codes = { 4 | # Informational. 5 | 100: ("continue",), 6 | 101: ("switching_protocols",), 7 | 102: ("processing",), 8 | 103: ("checkpoint",), 9 | 122: ("uri_too_long", "request_uri_too_long"), 10 | 200: ("ok", "okay", "all_ok", "all_okay", "all_good", "\\o/", "✓"), 11 | 201: ("created",), 12 | 202: ("accepted",), 13 | 203: ("non_authoritative_info", "non_authoritative_information"), 14 | 204: ("no_content",), 15 | 205: ("reset_content", "reset"), 16 | 206: ("partial_content", "partial"), 17 | 207: ("multi_status", "multiple_status", "multi_stati", "multiple_stati"), 18 | 208: ("already_reported",), 19 | 226: ("im_used",), 20 | # Redirection. 21 | 300: ("multiple_choices",), 22 | 301: ("moved_permanently", "moved", "\\o-"), 23 | 302: ("found",), 24 | 303: ("see_other", "other"), 25 | 304: ("not_modified",), 26 | 305: ("use_proxy",), 27 | 306: ("switch_proxy",), 28 | 307: ("temporary_redirect", "temporary_moved", "temporary"), 29 | 308: ( 30 | "permanent_redirect", 31 | "resume_incomplete", 32 | "resume", 33 | ), # These 2 to be removed in 3.0 34 | # Client Error. 35 | 400: ("bad_request", "bad"), 36 | 401: ("unauthorized",), 37 | 402: ("payment_required", "payment"), 38 | 403: ("forbidden",), 39 | 404: ("not_found", "-o-"), 40 | 405: ("method_not_allowed", "not_allowed"), 41 | 406: ("not_acceptable",), 42 | 407: ("proxy_authentication_required", "proxy_auth", "proxy_authentication"), 43 | 408: ("request_timeout", "timeout"), 44 | 409: ("conflict",), 45 | 410: ("gone",), 46 | 411: ("length_required",), 47 | 412: ("precondition_failed", "precondition"), 48 | 413: ("request_entity_too_large",), 49 | 414: ("request_uri_too_large",), 50 | 415: ("unsupported_media_type", "unsupported_media", "media_type"), 51 | 416: ( 52 | "requested_range_not_satisfiable", 53 | "requested_range", 54 | "range_not_satisfiable", 55 | ), 56 | 417: ("expectation_failed",), 57 | 418: ("im_a_teapot", "teapot", "i_am_a_teapot"), 58 | 421: ("misdirected_request",), 59 | 422: ("unprocessable_entity", "unprocessable"), 60 | 423: ("locked",), 61 | 424: ("failed_dependency", "dependency"), 62 | 425: ("unordered_collection", "unordered"), 63 | 426: ("upgrade_required", "upgrade"), 64 | 428: ("precondition_required", "precondition"), 65 | 429: ("too_many_requests", "too_many"), 66 | 431: ("header_fields_too_large", "fields_too_large"), 67 | 444: ("no_response", "none"), 68 | 449: ("retry_with", "retry"), 69 | 450: ("blocked_by_windows_parental_controls", "parental_controls"), 70 | 451: ("unavailable_for_legal_reasons", "legal_reasons"), 71 | 499: ("client_closed_request",), 72 | # Server Error. 73 | 500: ("internal_server_error", "server_error", "/o\\", "✗"), 74 | 501: ("not_implemented",), 75 | 502: ("bad_gateway",), 76 | 503: ("service_unavailable", "unavailable"), 77 | 504: ("gateway_timeout",), 78 | 505: ("http_version_not_supported", "http_version"), 79 | 506: ("variant_also_negotiates",), 80 | 507: ("insufficient_storage",), 81 | 509: ("bandwidth_limit_exceeded", "bandwidth"), 82 | 510: ("not_extended",), 83 | 511: ("network_authentication_required", "network_auth", "network_authentication"), 84 | } 85 | 86 | for number in codes: 87 | locals()[f"HTTP_{number}"] = number 88 | 89 | for label in codes[number]: 90 | locals()[label] = number 91 | 92 | 93 | def _is_category(category, status_code): 94 | return all([(status_code >= category), (status_code < category + 100)]) 95 | 96 | 97 | def is_100(status_code): 98 | return _is_category(100, status_code) 99 | 100 | 101 | def is_200(status_code): 102 | return _is_category(200, status_code) 103 | 104 | 105 | def is_300(status_code): 106 | return _is_category(300, status_code) 107 | 108 | 109 | def is_400(status_code): 110 | return _is_category(400, status_code) 111 | 112 | 113 | def is_500(status_code): 114 | return _is_category(500, status_code) 115 | -------------------------------------------------------------------------------- /responder/templates.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | import jinja2 4 | 5 | 6 | class Templates: 7 | def __init__( 8 | self, directory="templates", autoescape=True, context=None, enable_async=False 9 | ): 10 | self.directory = directory 11 | self._env = jinja2.Environment( 12 | loader=jinja2.FileSystemLoader([str(self.directory)]), 13 | autoescape=autoescape, # noqa: S701 14 | enable_async=enable_async, 15 | ) 16 | self.default_context = {} if context is None else {**context} 17 | self._env.globals.update(self.default_context) 18 | 19 | @property 20 | def context(self): 21 | return self._env.globals 22 | 23 | @context.setter 24 | def context(self, context): 25 | self._env.globals = {**self.default_context, **context} 26 | 27 | def get_template(self, name): 28 | return self._env.get_template(name) 29 | 30 | def render(self, template, *args, **kwargs): 31 | """Renders the given `jinja2 `_ template, with provided values supplied. 32 | 33 | :param template: The filename of the jinja2 template. 34 | :param **kwargs: Data to pass into the template. 35 | :param **kwargs: Data to pass into the template. 36 | """ # noqa: E501 37 | return self.get_template(template).render(*args, **kwargs) 38 | 39 | @contextmanager 40 | def _async(self): 41 | self._env.is_async = True 42 | try: 43 | yield 44 | finally: 45 | self._env.is_async = False 46 | 47 | async def render_async(self, template, *args, **kwargs): 48 | with self._async(): 49 | return await self.get_template(template).render_async(*args, **kwargs) 50 | 51 | def render_string(self, source, *args, **kwargs): 52 | """Renders the given `jinja2 `_ template string, with provided values supplied. 53 | 54 | :param source: The template to use. 55 | :param *args, **kwargs: Data to pass into the template. 56 | :param **kwargs: Data to pass into the template. 57 | """ # noqa: E501 58 | template = self._env.from_string(source) 59 | return template.render(*args, **kwargs) 60 | -------------------------------------------------------------------------------- /responder/util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/responder/util/__init__.py -------------------------------------------------------------------------------- /responder/util/cmd.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: S603 # Subprocess call - output not captured 2 | # ruff: noqa: S607 # Starting a process with a partial executable path 3 | # Security considerations for subprocess usage: 4 | # 1. Only execute the 'responder' binary from PATH 5 | # 2. Validate all user inputs before passing to subprocess 6 | # 3. Use Path.resolve() to prevent path traversal 7 | import functools 8 | import logging 9 | import os 10 | import shutil 11 | import signal 12 | import socket 13 | import subprocess 14 | import sys 15 | import threading 16 | import time 17 | from pathlib import Path 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class ResponderProgram: 23 | """ 24 | Utility class for managing Responder program execution. 25 | 26 | This class provides methods for: 27 | - Locating the responder executable in PATH 28 | - Building frontend assets using npm 29 | 30 | Example: 31 | >>> program_path = ResponderProgram.path() 32 | >>> build_status = ResponderProgram.build(Path("app_dir")) 33 | """ 34 | 35 | @staticmethod 36 | @functools.lru_cache(maxsize=None) 37 | def path(): 38 | name = "responder" 39 | if sys.platform == "win32": 40 | name = "responder.exe" 41 | program = shutil.which(name) 42 | if program is None: 43 | paths = os.environ.get("PATH", "").split(os.pathsep) 44 | raise RuntimeError( 45 | f"Could not find '{name}' executable in PATH. " 46 | f"Please install Responder with 'pip install --upgrade responder[cli]'. " 47 | f"Searched in: {', '.join(paths)}" 48 | ) 49 | logger.debug(f"Found responder program: {program}") 50 | return program 51 | 52 | @classmethod 53 | def build(cls, path: Path) -> int: 54 | """ 55 | Invoke `responder build` command. 56 | 57 | Args: 58 | path: Path to the application to build 59 | 60 | Returns: 61 | int: The return code from the build process 62 | 63 | Raises: 64 | ValueError: If the path is invalid 65 | RuntimeError: If the responder executable is not found 66 | subprocess.SubprocessError: If the build process fails 67 | """ 68 | 69 | if not isinstance(path, Path): 70 | raise ValueError(f"Expected a Path object, got {type(path).__name__}") 71 | if not path.exists(): 72 | raise ValueError(f"Path does not exist: {path}") 73 | if not path.is_dir(): 74 | raise FileNotFoundError(f"Path is not a directory: {path}") 75 | 76 | command = [ 77 | cls.path(), 78 | "build", 79 | str(path), 80 | ] 81 | return subprocess.call(command) 82 | 83 | 84 | class ResponderServer(threading.Thread): 85 | """ 86 | A threaded wrapper around the `responder run` command for testing purposes. 87 | 88 | This class allows running a Responder application in a separate thread, 89 | making it suitable for integration testing scenarios. 90 | 91 | Args: 92 | target (str): The path to the Responder application to run 93 | port (int, optional): The port to run the server on. Defaults to 5042. 94 | limit_max_requests (int, optional): Maximum number of requests to handle 95 | before shutting down. Useful for testing scenarios. 96 | 97 | Example: 98 | >>> server = ResponderServer("app.py", port=8000) 99 | >>> server.start() 100 | >>> # Run tests 101 | >>> server.stop() 102 | """ 103 | 104 | def __init__(self, target: str, port: int = 5042, limit_max_requests: int = None): 105 | super().__init__() 106 | self._stopping = False 107 | 108 | # Validate input variables. 109 | if not target or not isinstance(target, str): 110 | raise ValueError("Target must be a non-empty string") 111 | if not isinstance(port, int) or port < 1: 112 | raise ValueError("Port must be a positive integer") 113 | if limit_max_requests is not None and ( 114 | not isinstance(limit_max_requests, int) or limit_max_requests < 1 115 | ): 116 | raise ValueError("limit_max_requests must be a positive integer if specified") 117 | 118 | # Check if port is available. 119 | try: 120 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 121 | s.bind(("localhost", port)) 122 | except OSError as ex: 123 | raise ValueError(f"Port {port} is already in use") from ex 124 | 125 | # Instance variables after validation. 126 | self.target = target 127 | self.port = port 128 | self.limit_max_requests = limit_max_requests 129 | self.shutdown_timeout = 5 # seconds 130 | 131 | # Allow the thread to be terminated when the main program exits. 132 | self.process: subprocess.Popen 133 | self.daemon = True 134 | self._process_lock = threading.Lock() 135 | 136 | # Setup signal handlers. 137 | signal.signal(signal.SIGTERM, self._signal_handler) 138 | signal.signal(signal.SIGINT, self._signal_handler) 139 | 140 | def run(self): 141 | command = [ 142 | ResponderProgram.path(), 143 | "run", 144 | self.target, 145 | ] 146 | if self.limit_max_requests is not None: 147 | command += [f"--limit-max-requests={self.limit_max_requests}"] 148 | 149 | # Preserve existing environment 150 | env = os.environ.copy() 151 | 152 | if self.port is not None: 153 | env["PORT"] = str(self.port) 154 | 155 | with self._process_lock: 156 | self.process = subprocess.Popen( 157 | command, 158 | env=env, 159 | universal_newlines=True, 160 | ) 161 | self.process.wait() 162 | 163 | def stop(self): 164 | """ 165 | Gracefully stop the process (API). 166 | """ 167 | if self._stopping: 168 | return 169 | with self._process_lock: 170 | self._stop() 171 | 172 | def _stop(self): 173 | """ 174 | Gracefully stop the process (impl). 175 | """ 176 | self._stopping = True 177 | if self.process and self.process.poll() is None: 178 | logger.info("Attempting to terminate server process...") 179 | self.process.terminate() 180 | try: 181 | # Wait for graceful shutdown. 182 | self.process.wait(timeout=self.shutdown_timeout) 183 | logger.info("Server process terminated gracefully") 184 | except subprocess.TimeoutExpired: 185 | logger.warning( 186 | "Server process did not terminate gracefully, forcing kill" 187 | ) 188 | self.process.kill() # Force kill if not terminated 189 | 190 | def _signal_handler(self, signum, frame): 191 | """ 192 | Handle termination signals gracefully. 193 | """ 194 | logger.info("Received signal %d, shutting down...", signum) 195 | self.stop() 196 | 197 | def wait_until_ready(self, timeout=30, request_timeout=1, delay=0.1) -> bool: 198 | """ 199 | Wait until the server is ready to accept connections. 200 | 201 | Args: 202 | timeout (int, optional): Maximum time to wait in seconds. Defaults to 30. 203 | 204 | Returns: 205 | bool: True if server is ready and accepting connections, False otherwise. 206 | """ 207 | start_time = time.time() 208 | last_error = None 209 | while time.time() - start_time < timeout: 210 | if not self.is_running(): 211 | if self.process is None: 212 | logger.error("Server process was never started") 213 | else: 214 | returncode = self.process.poll() 215 | logger.error("Server process exited with code: %d", returncode) 216 | return False 217 | try: 218 | with socket.create_connection( 219 | ("localhost", self.port), timeout=request_timeout 220 | ): 221 | return True 222 | except ( 223 | socket.timeout, 224 | ConnectionRefusedError, 225 | socket.gaierror, 226 | OSError, 227 | ) as ex: 228 | last_error = ex 229 | logger.debug(f"Server not ready yet: {ex}") 230 | time.sleep(delay) 231 | logger.error( 232 | "Server failed to start within %d seconds. Last error: %s", 233 | timeout, 234 | last_error, 235 | ) 236 | return False 237 | 238 | def is_running(self): 239 | """ 240 | Check if the server process is still running. 241 | """ 242 | return self.process is not None and self.process.poll() is None 243 | -------------------------------------------------------------------------------- /responder/util/python.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import typing as t 3 | 4 | from pueblo.sfa.core import InvalidTarget, SingleFileApplication 5 | 6 | __all__ = [ 7 | "InvalidTarget", 8 | "SingleFileApplication", 9 | "load_target", 10 | ] 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def load_target(target: str, default_property: str = "api", method: str = "run") -> t.Any: 16 | """ 17 | Load Python code from a file path or module name. 18 | 19 | Warning: 20 | This function executes arbitrary Python code. Ensure the target is from a trusted 21 | source to prevent security vulnerabilities. 22 | 23 | Args: 24 | target: Module address (e.g., 'acme.app:foo'), file path (e.g., '/path/to/acme/app.py'), 25 | or URL. 26 | default_property: Name of the property to load if not specified in target (default: "api") 27 | method: Name of the method to invoke on the API instance (default: "run") 28 | 29 | Returns: 30 | The API instance, loaded from the given property. 31 | 32 | Raises: 33 | ValueError: If target format is invalid 34 | ImportError: If module cannot be imported 35 | AttributeError: If property or method is not found 36 | 37 | Example: 38 | >>> api = load_target("myapp.api:server") 39 | >>> api.run() 40 | """ # noqa: E501 41 | 42 | app = SingleFileApplication.from_spec(spec=target, default_property=default_property) 43 | app.load() 44 | return app.entrypoint 45 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import codecs 4 | import os 5 | import sys 6 | from shutil import rmtree 7 | 8 | from setuptools import Command, find_packages, setup 9 | 10 | here = os.path.abspath(os.path.dirname(__file__)) 11 | 12 | with codecs.open(os.path.join(here, "README.md"), encoding="utf-8") as f: 13 | long_description = "\n" + f.read() 14 | 15 | about = {} 16 | 17 | with open(os.path.join(here, "responder", "__version__.py")) as f: 18 | exec(f.read(), about) 19 | 20 | if sys.argv[-1] == "publish": 21 | os.system("python setup.py sdist bdist_wheel upload") 22 | sys.exit() 23 | 24 | required = [ 25 | "apispec>=1.0.0b1", 26 | "chardet", 27 | "marshmallow", 28 | "requests", 29 | "requests-toolbelt", 30 | "rfc3986", 31 | # ServeStatic is the successor to WhiteNoise. 32 | # WhiteNoise is used for backward compatibility with Python <3.8. 33 | "servestatic; python_version>='3.8'", 34 | "starlette[full]", 35 | "uvicorn[standard]", 36 | "whitenoise; python_version<'3.8'", 37 | ] 38 | 39 | 40 | # https://pypi.python.org/pypi/stdeb/0.8.5#quickstart-2-just-tell-me-the-fastest-way-to-make-a-deb 41 | class DebCommand(Command): 42 | """Support for setup.py deb""" 43 | 44 | description = "Build and publish the .deb package." 45 | user_options = [] 46 | 47 | @staticmethod 48 | def status(s): 49 | """Prints things in bold.""" 50 | print("\033[1m{0}\033[0m".format(s)) 51 | 52 | def initialize_options(self): 53 | pass 54 | 55 | def finalize_options(self): 56 | pass 57 | 58 | def run(self): 59 | try: 60 | self.status("Removing previous builds…") 61 | rmtree(os.path.join(here, "deb_dist")) 62 | except FileNotFoundError: 63 | pass 64 | self.status("Creating debian manifest…") 65 | os.system( 66 | "python setup.py --command-packages=stdeb.command sdist_dsc -z artful --package3=pipenv --depends3=python3-virtualenv-clone" 67 | ) 68 | self.status("Building .deb…") 69 | os.chdir("deb_dist/pipenv-{0}".format(about["__version__"])) 70 | os.system("dpkg-buildpackage -rfakeroot -uc -us") 71 | 72 | 73 | class UploadCommand(Command): 74 | """Support setup.py publish.""" 75 | 76 | description = "Build and publish the package." 77 | user_options = [] 78 | 79 | @staticmethod 80 | def status(s): 81 | """Prints things in bold.""" 82 | print("\033[1m{0}\033[0m".format(s)) 83 | 84 | def initialize_options(self): 85 | pass 86 | 87 | def finalize_options(self): 88 | pass 89 | 90 | def run(self): 91 | try: 92 | self.status("Removing previous builds…") 93 | rmtree(os.path.join(here, "dist")) 94 | except FileNotFoundError: 95 | pass 96 | self.status("Building Source distribution…") 97 | os.system("{0} setup.py sdist bdist_wheel".format(sys.executable)) 98 | self.status("Uploading the package to PyPI via Twine…") 99 | os.system("twine upload dist/*") 100 | self.status("Pushing git tags…") 101 | os.system("git tag v{0}".format(about["__version__"])) 102 | os.system("git push --tags") 103 | sys.exit() 104 | 105 | 106 | setup( 107 | name="responder", 108 | version=about["__version__"], 109 | description="A familiar HTTP Service Framework for Python.", 110 | long_description=long_description, 111 | long_description_content_type="text/markdown", 112 | author="Kenneth Reitz", 113 | author_email="me@kennethreitz.org", 114 | url="https://github.com/kennethreitz/responder", 115 | packages=find_packages(exclude=["tests"]), 116 | package_data={}, 117 | entry_points={"console_scripts": ["responder=responder.ext.cli:cli"]}, 118 | python_requires=">=3.7", 119 | setup_requires=[], 120 | install_requires=required, 121 | extras_require={ 122 | "cli": [ 123 | "docopt-ng", 124 | "pueblo[sfa]>=0.0.11", 125 | ], 126 | "cli-full": [ 127 | "pueblo[sfa-full]>=0.0.11", 128 | "responder[cli]", 129 | ], 130 | "develop": [ 131 | "poethepoet", 132 | "pyproject-fmt; python_version>='3.7'", 133 | "ruff; python_version>='3.7'", 134 | "validate-pyproject", 135 | ], 136 | "docs": [ 137 | "alabaster<1.1", 138 | "myst-parser[linkify]", 139 | "sphinx>=5,<9", 140 | "sphinx-autobuild", 141 | "sphinx-copybutton", 142 | "sphinx-design-elements", 143 | "sphinxext.opengraph", 144 | ], 145 | "full": ["responder[cli-full,graphql,openapi]"], 146 | "graphql": ["graphene<3", "graphql-server-core>=1.2,<2"], 147 | "openapi": ["apispec>=1.0.0"], 148 | "release": ["build", "twine"], 149 | "test": [ 150 | "flask", 151 | "mypy", 152 | "pytest", 153 | "pytest-cov", 154 | "pytest-mock", 155 | "pytest-rerunfailures", 156 | ], 157 | }, 158 | include_package_data=True, 159 | license="Apache 2.0", 160 | classifiers=[ 161 | "Development Status :: 5 - Production/Stable", 162 | "Environment :: Web Environment", 163 | "Intended Audience :: Developers", 164 | "License :: OSI Approved :: Apache Software License", 165 | "Operating System :: OS Independent", 166 | "Programming Language :: Python", 167 | "Programming Language :: Python :: 3", 168 | "Programming Language :: Python :: 3.7", 169 | "Programming Language :: Python :: 3.8", 170 | "Programming Language :: Python :: 3.9", 171 | "Programming Language :: Python :: 3.10", 172 | "Programming Language :: Python :: 3.11", 173 | "Programming Language :: Python :: 3.12", 174 | "Programming Language :: Python :: 3.13", 175 | "Programming Language :: Python :: Implementation :: CPython", 176 | "Programming Language :: Python :: Implementation :: PyPy", 177 | "Topic :: Internet :: WWW/HTTP", 178 | ], 179 | cmdclass={"upload": UploadCommand, "deb": DebCommand}, 180 | ) 181 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kennethreitz/responder/944d47da45db16b904ced22fb731b3c3959406d6/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | import responder 6 | 7 | 8 | @pytest.fixture 9 | def data_dir(current_dir): 10 | yield current_dir / "data" 11 | 12 | 13 | @pytest.fixture() 14 | def current_dir(): 15 | yield Path(__file__).parent 16 | 17 | 18 | @pytest.fixture 19 | def api(): 20 | return responder.API(debug=False, allowed_hosts=[";"]) 21 | 22 | 23 | @pytest.fixture 24 | def session(api): 25 | return api.requests 26 | 27 | 28 | @pytest.fixture 29 | def url(): 30 | def url_for(s): 31 | return f"http://;{s}" 32 | 33 | return url_for 34 | 35 | 36 | @pytest.fixture 37 | def flask(): 38 | from flask import Flask 39 | 40 | app = Flask(__name__) 41 | 42 | @app.route("/") 43 | def hello(): 44 | return "Hello World!" 45 | 46 | return app 47 | 48 | 49 | @pytest.fixture 50 | def template_path(tmpdir): 51 | # create a Jinja template file on the filesystem 52 | template_name = "test.html" 53 | template_file = tmpdir.mkdir("static").join(template_name) 54 | template_file.write("{{ var }}") 55 | return template_file 56 | 57 | 58 | @pytest.fixture 59 | def needs_openapi() -> None: 60 | try: 61 | import apispec 62 | 63 | _ = apispec.APISpec 64 | except ImportError as ex: 65 | raise pytest.skip("apispec package not installed") from ex 66 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test module for Responder CLI functionality. 3 | 4 | This module tests the following CLI commands: 5 | - responder --version: Version display 6 | - responder build: Build command execution 7 | - responder run: Server execution 8 | 9 | Requirements: 10 | - The `docopt-ng` package must be installed 11 | - Example application must be present at `examples/helloworld.py` 12 | - This file should implement a basic HTTP server with a "/hello" endpoint 13 | that returns "hello, world!" as response 14 | """ 15 | 16 | import json 17 | import os 18 | import subprocess 19 | import time 20 | import typing as t 21 | from pathlib import Path 22 | 23 | import pytest 24 | import requests 25 | from _pytest.capture import CaptureFixture 26 | from requests_toolbelt.multipart.encoder import to_list 27 | 28 | from responder.__version__ import __version__ 29 | from responder.util.cmd import ResponderProgram, ResponderServer 30 | from tests.util import random_port, wait_server_tcp 31 | 32 | # Skip test if optional CLI dependency is not installed. 33 | pytest.importorskip("docopt", reason="docopt-ng package not installed") 34 | 35 | 36 | # Pseudo-wait for server idleness 37 | SERVER_IDLE_WAIT = float(os.getenv("RESPONDER_SERVER_IDLE_WAIT", "0.25")) 38 | 39 | # Maximum time to wait for server startup or teardown (adjust for slower systems) 40 | SERVER_TIMEOUT = float(os.getenv("RESPONDER_SERVER_TIMEOUT", "5")) 41 | 42 | # Maximum time to wait for HTTP requests (adjust for slower networks) 43 | REQUEST_TIMEOUT = float(os.getenv("RESPONDER_REQUEST_TIMEOUT", "5")) 44 | 45 | # Endpoint to use for `responder run`. 46 | HELLO_ENDPOINT = "/hello" 47 | 48 | 49 | def test_cli_version(capfd): 50 | """ 51 | Verify that `responder --version` works as expected. 52 | """ 53 | try: 54 | # Suppress security checks for subprocess calls in tests. 55 | # S603: subprocess call - safe as we use fixed command 56 | # S607: start process with partial path - safe as we use installed package 57 | subprocess.check_call(["responder", "--version"]) # noqa: S603, S607 58 | except subprocess.CalledProcessError as ex: 59 | pytest.fail( 60 | f"responder --version failed with exit code {ex.returncode}. Error: {ex}" 61 | ) 62 | 63 | stdout = capfd.readouterr().out.strip() 64 | assert stdout == __version__ 65 | 66 | 67 | def responder_build(path: Path, capfd: CaptureFixture) -> t.Tuple[str, str]: 68 | """ 69 | Execute responder build command and capture its output. 70 | 71 | Args: 72 | path: Directory containing package.json 73 | capfd: Pytest fixture for capturing output 74 | 75 | Returns: 76 | tuple: (stdout, stderr) containing the captured output 77 | """ 78 | 79 | ResponderProgram.build(path=path) 80 | output = capfd.readouterr() 81 | 82 | stdout = output.out.strip() 83 | stderr = output.err.strip() 84 | 85 | return stdout, stderr 86 | 87 | 88 | def test_cli_build_success(capfd, tmp_path): 89 | """ 90 | Verify that `responder build` works as expected. 91 | """ 92 | 93 | # Temporary surrogate `package.json` file. 94 | package_json = {"scripts": {"build": "echo Hotzenplotz"}} 95 | package_json_file = tmp_path / "package.json" 96 | package_json_file.write_text(json.dumps(package_json)) 97 | 98 | # Invoke `responder build`. 99 | stdout, stderr = responder_build(tmp_path, capfd) 100 | assert "Hotzenplotz" in stdout 101 | 102 | 103 | def test_cli_build_missing_package_json(capfd, tmp_path): 104 | """ 105 | Verify `responder build`, while `package.json` file is missing. 106 | """ 107 | 108 | # Invoke `responder build`. 109 | stdout, stderr = responder_build(tmp_path, capfd) 110 | assert "Invalid target directory or missing package.json" in stderr 111 | 112 | 113 | @pytest.mark.parametrize( 114 | "invalid_content,npm_error,expected_error", 115 | [ 116 | ("foobar", "code EJSONPARSE", ["is not valid JSON", "Failed to parse JSON data"]), 117 | ("{", "code EJSONPARSE", "Unexpected end of JSON input"), 118 | ('{"scripts": }', "code EJSONPARSE", "Unexpected token"), 119 | ( 120 | '{"scripts": null}', 121 | "Cannot convert undefined or null to object", 122 | "scripts.build script not found", 123 | ), 124 | ('{"scripts": {"build": null}}', "Missing script", '"build"'), 125 | ('{"scripts": {"build": 123}}', "Missing script", '"build"'), 126 | ], 127 | ids=[ 128 | "invalid_json_content", 129 | "incomplete_json", 130 | "syntax_error", 131 | "null_scripts", 132 | "missing_script_null", 133 | "missing_script_number", 134 | ], 135 | ) 136 | def test_cli_build_invalid_package_json( 137 | capfd, tmp_path, invalid_content, npm_error, expected_error 138 | ): 139 | """ 140 | Verify `responder build` using an invalid `package.json` file. 141 | """ 142 | 143 | # Temporary surrogate `package.json` file. 144 | package_json_file = tmp_path / "package.json" 145 | package_json_file.write_text(invalid_content) 146 | 147 | # Invoke `responder build`. 148 | stdout, stderr = responder_build(tmp_path, capfd) 149 | assert f"npm error {npm_error}" in stderr 150 | assert any(item in stderr for item in to_list(expected_error)) 151 | 152 | 153 | sfa_services_valid = [ 154 | str(Path("examples") / "helloworld.py"), 155 | "https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py", 156 | ] 157 | 158 | 159 | # The test is marked as flaky due to potential race conditions in server startup 160 | # and port availability. Known error codes by platform: 161 | # - macOS: [Errno 61] Connection refused (Failed to establish a new connection) 162 | # - Linux: [Errno 111] Connection refused (Failed to establish a new connection) 163 | # - Windows: [WinError 10061] No connection could be made because target machine 164 | # actively refused it 165 | @pytest.mark.flaky(reruns=3, reruns_delay=2, only_rerun=["TimeoutError"]) 166 | @pytest.mark.parametrize("target", sfa_services_valid, ids=sfa_services_valid) 167 | def test_cli_run(capfd, target): 168 | """ 169 | Verify that `responder run` works as expected. 170 | """ 171 | 172 | # Start a Responder service instance in the background, using its CLI. 173 | # Make it terminate itself after serving one HTTP request. 174 | server = ResponderServer(target=str(target), port=random_port(), limit_max_requests=1) 175 | try: 176 | # Start server and wait until it responds on TCP. 177 | server.start() 178 | wait_server_tcp(server.port) 179 | 180 | # Submit a single probing HTTP request that also will terminate the server. 181 | response = requests.get( 182 | f"http://127.0.0.1:{server.port}{HELLO_ENDPOINT}", timeout=REQUEST_TIMEOUT 183 | ) 184 | assert "hello, world!" == response.text 185 | finally: 186 | server.join(timeout=SERVER_TIMEOUT) 187 | 188 | # Capture process output. 189 | time.sleep(SERVER_IDLE_WAIT) 190 | output = capfd.readouterr() 191 | 192 | stdout = output.out.strip() 193 | assert f'"GET {HELLO_ENDPOINT} HTTP/1.1" 200 OK' in stdout 194 | 195 | stderr = output.err.strip() 196 | 197 | # Define expected lifecycle messages in order. 198 | lifecycle_messages = [ 199 | # Startup phase 200 | "Started server process", 201 | "Waiting for application startup", 202 | "Application startup complete", 203 | "Uvicorn running", 204 | # Shutdown phase 205 | "Shutting down", 206 | "Waiting for application shutdown", 207 | "Application shutdown complete", 208 | "Finished server process", 209 | ] 210 | 211 | # Verify messages appear in expected order. 212 | last_pos = -1 213 | for msg in lifecycle_messages: 214 | pos = stderr.find(msg) 215 | assert pos > last_pos, f"Expected '{msg}' to appear after previous message" 216 | last_pos = pos 217 | -------------------------------------------------------------------------------- /tests/test_encodings.py: -------------------------------------------------------------------------------- 1 | def test_custom_encoding(api, session): 2 | data = "hi alex!" 3 | 4 | @api.route("/") 5 | async def route(req, resp): 6 | req.encoding = "ascii" 7 | resp.text = await req.text 8 | 9 | r = session.post(api.url_for(route), data=data) 10 | assert r.text == data 11 | 12 | 13 | def test_bytes_encoding(api, session): 14 | data = b"hi lenny!" 15 | 16 | @api.route("/") 17 | async def route(req, resp): 18 | resp.text = (await req.content).decode("utf-8") 19 | 20 | r = session.post(api.url_for(route), data=data) 21 | assert r.content == data 22 | -------------------------------------------------------------------------------- /tests/test_graphql.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: E402 2 | import pytest 3 | 4 | graphene = pytest.importorskip("graphene") 5 | 6 | from responder.ext.graphql import GraphQLView 7 | 8 | 9 | @pytest.fixture 10 | def schema(): 11 | class Query(graphene.ObjectType): 12 | hello = graphene.String(name=graphene.String(default_value="stranger")) 13 | 14 | def resolve_hello(self, info, name): 15 | return f"Hello {name}" 16 | 17 | return graphene.Schema(query=Query) 18 | 19 | 20 | def test_graphql_schema_query_querying(api, schema): 21 | api.add_route("/", GraphQLView(schema=schema, api=api)) 22 | r = api.requests.get("http://;/?q={ hello }", headers={"Accept": "json"}) 23 | assert r.status_code == 200 24 | assert r.json() == {"data": {"hello": "Hello stranger"}} 25 | 26 | 27 | def test_graphql_schema_json_query(api, schema): 28 | api.add_route("/", GraphQLView(schema=schema, api=api)) 29 | r = api.requests.post("http://;/", json={"query": "{ hello }"}) 30 | assert r.status_code < 300 31 | assert r.json() == {"data": {"hello": "Hello stranger"}} 32 | 33 | 34 | def test_graphiql(api, schema): 35 | api.add_route("/", GraphQLView(schema=schema, api=api)) 36 | r = api.requests.get("http://;/", headers={"Accept": "text/html"}) 37 | assert r.status_code < 300 38 | assert "GraphiQL" in r.text 39 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | import pytest 4 | 5 | from responder import models 6 | 7 | _default_query = "q=%7b%20hello%20%7d&name=myname&user_name=test_user" 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "query, expected", 12 | [ 13 | pytest.param( 14 | _default_query, 15 | {"q": ["{ hello }"], "name": ["myname"], "user_name": ["test_user"]}, 16 | id="parse query with unique keys", 17 | ), 18 | pytest.param( 19 | "q=1&q=2&q=3", {"q": ["1", "2", "3"]}, id="parse query with the same key" 20 | ), 21 | ], 22 | ) 23 | def test_query_dict(query, expected): 24 | d = models.QueryDict(query) 25 | assert d == expected 26 | 27 | 28 | def test_query_dict_get(): 29 | d = models.QueryDict(_default_query) 30 | 31 | assert d["user_name"] == "test_user" 32 | assert d.get("key_none_exist") is None 33 | 34 | 35 | def test_query_dict_get_list(): 36 | d = models.QueryDict(_default_query) 37 | 38 | assert d.get_list("user_name") == ["test_user"] 39 | assert d.get_list("key_none_exist") == [] 40 | assert d.get_list("key_none_exist", ["foo"]) == ["foo"] 41 | 42 | 43 | def test_query_dict_items_list(): 44 | d = models.QueryDict(_default_query) 45 | 46 | items_list = d.items_list() 47 | assert inspect.isgenerator(items_list) 48 | assert dict(items_list) == { 49 | "q": ["{ hello }"], 50 | "name": ["myname"], 51 | "user_name": ["test_user"], 52 | } 53 | 54 | 55 | def test_query_dict_items(): 56 | d = models.QueryDict(_default_query) 57 | 58 | items = d.items() 59 | assert inspect.isgenerator(items) 60 | assert dict(items) == {"q": "{ hello }", "name": "myname", "user_name": "test_user"} 61 | -------------------------------------------------------------------------------- /tests/test_status_codes.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from responder import status_codes 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "status_code, expected", 8 | [ 9 | pytest.param(101, True, id="Normal 101"), 10 | pytest.param(199, True, id="Not actual status code but within 100"), 11 | pytest.param(0, False, id="Zero case (below 100)"), 12 | pytest.param(200, False, id="Above 100"), 13 | ], 14 | ) 15 | def test_is_100(status_code, expected): 16 | assert status_codes.is_100(status_code) is expected 17 | 18 | 19 | @pytest.mark.parametrize( 20 | "status_code, expected", 21 | [ 22 | pytest.param(201, True, id="Normal 201"), 23 | pytest.param(299, True, id="Not actual status code but within 200"), 24 | pytest.param(0, False, id="Zero case (below 200)"), 25 | pytest.param(300, False, id="Above 200"), 26 | ], 27 | ) 28 | def test_is_200(status_code, expected): 29 | assert status_codes.is_200(status_code) is expected 30 | 31 | 32 | @pytest.mark.parametrize( 33 | "status_code, expected", 34 | [ 35 | pytest.param(301, True, id="Normal 301"), 36 | pytest.param(399, True, id="Not actual status code but within 300"), 37 | pytest.param(0, False, id="Zero case (below 300)"), 38 | pytest.param(400, False, id="Above 300"), 39 | ], 40 | ) 41 | def test_is_300(status_code, expected): 42 | assert status_codes.is_300(status_code) is expected 43 | 44 | 45 | @pytest.mark.parametrize( 46 | "status_code, expected", 47 | [ 48 | pytest.param(401, True, id="Normal 401"), 49 | pytest.param(499, True, id="Not actual status code but within 400"), 50 | pytest.param(0, False, id="Zero case (below 400)"), 51 | pytest.param(500, False, id="Above 400"), 52 | ], 53 | ) 54 | def test_is_400(status_code, expected): 55 | assert status_codes.is_400(status_code) is expected 56 | 57 | 58 | @pytest.mark.parametrize( 59 | "status_code, expected", 60 | [ 61 | pytest.param(501, True, id="Normal 501"), 62 | pytest.param(599, True, id="Not actual status code but within 500"), 63 | pytest.param(0, False, id="Zero case (below 500)"), 64 | pytest.param(600, False, id="Above 500"), 65 | ], 66 | ) 67 | def test_is_500(status_code, expected): 68 | assert status_codes.is_500(status_code) is expected 69 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility functions for testing server components. 3 | 4 | This module provides functions for managing test server instances, 5 | including port allocation and server readiness checking. 6 | """ 7 | 8 | import errno 9 | import logging 10 | import socket 11 | import time 12 | import typing as t 13 | from copy import copy 14 | from functools import lru_cache 15 | 16 | import requests 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | def random_port() -> int: 22 | """ 23 | Return a random available port by binding to port 0. 24 | 25 | Returns: 26 | int: An available port number that can be used for testing. 27 | """ 28 | sock = socket.socket() 29 | try: 30 | sock.bind(("", 0)) 31 | return sock.getsockname()[1] 32 | finally: 33 | sock.close() 34 | 35 | 36 | @lru_cache(maxsize=None) 37 | def transient_socket_error_numbers() -> t.List[int]: 38 | """ 39 | A list of TCP socket error numbers to ignore in `wait_server_tcp`. 40 | 41 | On Windows, Winsock error codes are the Unix error code + 10000. 42 | 43 | Returns: 44 | List[int]: A list containing both Unix and Windows-specific error codes. 45 | For each Unix error code 'x', includes both 'x' and 'x + 10000'. 46 | """ 47 | error_numbers = [ 48 | errno.EAGAIN, 49 | errno.ECONNABORTED, 50 | errno.ECONNREFUSED, 51 | errno.ETIMEDOUT, 52 | errno.EWOULDBLOCK, 53 | ] 54 | error_numbers_effective = copy(error_numbers) 55 | error_numbers_effective.extend(error_number + 10000 for error_number in error_numbers) 56 | return error_numbers_effective 57 | 58 | 59 | def wait_server_tcp( 60 | port: int, 61 | host: str = "127.0.0.1", 62 | timeout: int = 10, 63 | delay: float = 0.1, 64 | ) -> None: 65 | """ 66 | Wait for server to be ready by attempting TCP connections. 67 | 68 | Args: 69 | port: The port number to connect to 70 | host: The host to connect to (default: "127.0.0.1") 71 | timeout: Maximum time to wait in seconds (default: 10) 72 | delay: Delay between attempts in seconds (default: 0.1) 73 | 74 | Raises: 75 | RuntimeError: If server is not ready within timeout period 76 | """ 77 | endpoint = f"tcp://{host}:{port}/" 78 | logger.debug(f"Waiting for endpoint: {endpoint}") 79 | start_time = time.time() 80 | while time.time() - start_time < timeout: 81 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: 82 | sock.settimeout(delay / 2) # Set socket timeout 83 | error_number = sock.connect_ex((host, port)) 84 | if error_number == 0: 85 | break 86 | 87 | # Expected errors when server is not ready. 88 | if error_number in transient_socket_error_numbers(): 89 | pass 90 | 91 | # Unexpected error. 92 | else: 93 | raise RuntimeError( 94 | f"Unexpected error while connecting to {endpoint}: {error_number}" 95 | ) 96 | time.sleep(delay) 97 | else: 98 | raise RuntimeError( 99 | f"Server at {endpoint} failed to start within {timeout} seconds" 100 | ) 101 | 102 | 103 | def wait_server_http( 104 | port: int, 105 | host: str = "127.0.0.1", 106 | protocol: str = "http", 107 | attempts: int = 20, 108 | delay: float = 0.1, 109 | ) -> None: 110 | """ 111 | Wait for server to be ready by attempting to connect to it. 112 | 113 | Args: 114 | port: The port number to connect to 115 | host: The host to connect to (default: "127.0.0.1") 116 | protocol: The protocol to use (default: "http") 117 | attempts: Number of connection attempts (default: 20) 118 | delay: Delay per attempt in seconds (default: 0.1) 119 | 120 | Raises: 121 | RuntimeError: If server is not ready after all attempts 122 | """ 123 | url = f"{protocol}://{host}:{port}/" 124 | for attempt in range(1, attempts + 1): 125 | try: 126 | requests.get(url, timeout=delay / 2) # Shorter timeout for connection 127 | break 128 | except requests.exceptions.RequestException: 129 | if attempt < attempts: # Don't sleep on last attempt 130 | time.sleep(delay) 131 | else: 132 | raise RuntimeError( 133 | f"Server at {url} failed to respond after {attempts} attempts " 134 | f"(total wait time: {attempts * delay:.1f}s)" 135 | ) 136 | --------------------------------------------------------------------------------