Compare commits

..

1 Commits

Author SHA1 Message Date
Andreas Motl cc0fe78382 Packaging: Use versioningit for maintaining the package version
The Debian package builder has been saved to `bin/mkdeb.py` for now. It
needs to be refurbished into a `poethepoet` task subsequently.
2024-10-25 14:21:22 +02:00
60 changed files with 689 additions and 3216 deletions
-3
View File
@@ -1,3 +0,0 @@
github: kennethreitz
thanks_dev: kennethreitz
custom: https://cash.app/$KennethReitz
-42
View File
@@ -1,42 +0,0 @@
name: "Documentation"
on:
push:
branches: [ main ]
pull_request: ~
workflow_dispatch:
# Cancel redundant in-progress jobs.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
documentation:
name: "Documentation"
runs-on: ubuntu-latest
env:
UV_SYSTEM_PYTHON: true
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
version: "latest"
enable-cache: true
cache-dependency-glob: |
pyproject.toml
- name: Install package and documentation dependencies
run: uv pip install '.[docs]'
- name: Build static HTML documentation
run: sphinx-build -W --keep-going docs/source docs/build
+13 -28
View File
@@ -12,45 +12,30 @@ concurrency:
cancel-in-progress: true
jobs:
test:
name: "Python ${{ matrix.python-version }}"
runs-on: ubuntu-latest
name: "Python ${{ matrix.python-version }} on ${{ matrix.os }}"
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [
"ubuntu-latest",
"macos-12",
"macos-latest",
]
python-version: [
"3.9",
"3.10",
"3.11",
"3.12",
"3.13",
"pypy3.10",
]
env:
UV_SYSTEM_PYTHON: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Set up Python
uses: actions/setup-python@v5
- uses: actions/checkout@v3
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
version: "latest"
enable-cache: true
cache-suffix: ${{ matrix.python-version }}
cache-dependency-glob: |
pyproject.toml
- name: Install package
run: uv pip install '.[develop,test]'
- name: Run tests
run: pytest
- uses: yezz123/setup-uv@v4
- run: uv pip install --editable '.[graphql,develop,test]' --system
- run: poe check
-1
View File
@@ -7,7 +7,6 @@
.pytest_cache
.DS_Store
coverage.xml
.coverage*
__pycache__
tests/__pycache__
-33
View File
@@ -1,33 +0,0 @@
# .readthedocs.yml
# Read the Docs configuration file
# Details
# - https://docs.readthedocs.io/en/stable/config-file/v2.html
# Required
version: 2
build:
os: "ubuntu-24.04"
tools:
python: "3.12"
python:
install:
- method: pip
path: .
extra_requirements:
- docs
sphinx:
configuration: docs/source/conf.py
# Use standard HTML builder.
builder: html
# Fail on all warnings to avoid broken references.
fail_on_warning: true
# Optionally build your docs in additional formats such as PDF
#formats:
# - pdf
+46 -87
View File
@@ -7,46 +7,6 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
## [Unreleased]
## [v3.0.0] - 2026-03-22
### Added
- Platform: Added support for Python 3.10 - Python 3.13
- CLI: `responder run` now also accepts a filesystem path on its `<target>`
argument, enabling usage on single-file applications.
- CLI: `responder run` now also accepts URLs.
### Changed
- Platform: Minimum Python version is now 3.9 (dropped 3.6, 3.7, 3.8)
- Dependencies: Dramatically reduced core dependency count (10 → 5)
- Removed `requests`, `requests-toolbelt`, `rfc3986`, `whitenoise`
- Moved `apispec` and `marshmallow` to `openapi` optional extra
- Replaced `rfc3986` with stdlib `urllib.parse`
- Replaced `requests-toolbelt` multipart decoder with `python-multipart`
- Replaced deprecated `starlette.middleware.wsgi` with `a2wsgi`
- Switched from WhiteNoise to ServeStatic
- Dependencies: Pinned `starlette[full]>=0.40` (was unpinned)
- GraphQL: Upgraded to `graphene>=3` and `graphql-core>=3.1`
(from `graphene<3` and `graphql-server-core`, which is unmaintained)
- GraphQL: Updated GraphiQL UI from 0.12.0 (2018) to 3.0.6 with React 18
- Extensions: All of CLI-, GraphQL-, and OpenAPI-Support modules are
extensions now, found within the `responder.ext` module namespace.
- Packaging: Migrated from `setup.py` to declarative `pyproject.toml`
### Removed
- Platform: Removed support for EOL Python 3.6, 3.7, 3.8
- Status codes: Removed deprecated `resume_incomplete` and `resume`
aliases for HTTP 308 (marked for removal in 3.0)
- CLI: `responder run --build` ceased to exist
### Fixed
- Routing: Fixed dispatching `static_route=None` on Windows
- uvicorn: `--debug` now maps to uvicorn's `log_level = "debug"`
- Tests: Fixed deprecated httpx TestClient usage
## [v2.0.5] - 2019-12-15
### Added
@@ -373,50 +333,49 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- Conception!
[unreleased]: https://github.com/kennethreitz/responder/compare/v3.0.0..HEAD
[v3.0.0]: https://github.com/kennethreitz/responder/compare/v2.0.5..v3.0.0
[v2.0.5]: https://github.com/kennethreitz/responder/compare/v2.0.4..v2.0.5
[v2.0.4]: https://github.com/kennethreitz/responder/compare/v2.0.3..v2.0.4
[v2.0.3]: https://github.com/kennethreitz/responder/compare/v2.0.2..v2.0.3
[v2.0.2]: https://github.com/kennethreitz/responder/compare/v2.0.1..v2.0.2
[v2.0.1]: https://github.com/kennethreitz/responder/compare/v2.0.0..v2.0.1
[v2.0.0]: https://github.com/kennethreitz/responder/compare/v1.3.2..v2.0.0
[v1.3.2]: https://github.com/kennethreitz/responder/compare/v1.3.1..v1.3.2
[v1.3.1]: https://github.com/kennethreitz/responder/compare/v1.3.0..v1.3.1
[v1.3.0]: https://github.com/kennethreitz/responder/compare/v1.2.0..v1.3.0
[v1.2.0]: https://github.com/kennethreitz/responder/compare/v1.1.3..v1.2.0
[v1.1.3]: https://github.com/kennethreitz/responder/compare/v1.1.2..v1.1.3
[v1.1.2]: https://github.com/kennethreitz/responder/compare/v1.1.1..v1.1.2
[v1.1.1]: https://github.com/kennethreitz/responder/compare/v1.1.0..v1.1.1
[v1.1.0]: https://github.com/kennethreitz/responder/compare/v1.0.5..v1.1.0
[v1.0.5]: https://github.com/kennethreitz/responder/compare/v1.0.4..v1.0.5
[v1.0.4]: https://github.com/kennethreitz/responder/compare/v1.0.3..v1.0.4
[v1.0.3]: https://github.com/kennethreitz/responder/compare/v1.0.2..v1.0.3
[v1.0.2]: https://github.com/kennethreitz/responder/compare/v1.0.1..v1.0.2
[v1.0.1]: https://github.com/kennethreitz/responder/compare/v1.0.0..v1.0.1
[v1.0.0]: https://github.com/kennethreitz/responder/compare/v0.3.3..v1.0.0
[v0.3.3]: https://github.com/kennethreitz/responder/compare/v0.3.2..v0.3.3
[v0.3.2]: https://github.com/kennethreitz/responder/compare/v0.3.1..v0.3.2
[v0.3.1]: https://github.com/kennethreitz/responder/compare/v0.3.0..v0.3.1
[v0.3.0]: https://github.com/kennethreitz/responder/compare/v0.2.3..v0.3.0
[v0.2.3]: https://github.com/kennethreitz/responder/compare/v0.2.2..v0.2.3
[v0.2.2]: https://github.com/kennethreitz/responder/compare/v0.2.1..v0.2.2
[v0.2.1]: https://github.com/kennethreitz/responder/compare/v0.2.0..v0.2.1
[v0.2.0]: https://github.com/kennethreitz/responder/compare/v0.1.6..v0.2.0
[v0.1.6]: https://github.com/kennethreitz/responder/compare/v0.1.5..v0.1.6
[v0.1.5]: https://github.com/kennethreitz/responder/compare/v0.1.4..v0.1.5
[v0.1.4]: https://github.com/kennethreitz/responder/compare/v0.1.3..v0.1.4
[v0.1.3]: https://github.com/kennethreitz/responder/compare/v0.1.2..v0.1.3
[v0.1.2]: https://github.com/kennethreitz/responder/compare/v0.1.1..v0.1.2
[v0.1.1]: https://github.com/kennethreitz/responder/compare/v0.1.0..v0.1.1
[v0.1.0]: https://github.com/kennethreitz/responder/compare/v0.0.10..v0.1.0
[v0.0.10]: https://github.com/kennethreitz/responder/compare/v0.0.9..v0.0.10
[v0.0.9]: https://github.com/kennethreitz/responder/compare/v0.0.8..v0.0.9
[v0.0.8]: https://github.com/kennethreitz/responder/compare/v0.0.7..v0.0.8
[v0.0.7]: https://github.com/kennethreitz/responder/compare/v0.0.6..v0.0.7
[v0.0.6]: https://github.com/kennethreitz/responder/compare/v0.0.5..v0.0.6
[v0.0.5]: https://github.com/kennethreitz/responder/compare/v0.0.4..v0.0.5
[v0.0.4]: https://github.com/kennethreitz/responder/compare/v0.0.3..v0.0.4
[v0.0.3]: https://github.com/kennethreitz/responder/compare/v0.0.2..v0.0.3
[v0.0.2]: https://github.com/kennethreitz/responder/compare/v0.0.1..v0.0.2
[v0.0.1]: https://github.com/kennethreitz/responder/compare/v0.0.0..v0.0.1
[unreleased]: https://github.com/taoufik07/responder/compare/v2.0.5..HEAD
[v2.0.5]: https://github.com/taoufik07/responder/compare/v2.0.4..v2.0.5
[v2.0.4]: https://github.com/taoufik07/responder/compare/v2.0.3..v2.0.4
[v2.0.3]: https://github.com/taoufik07/responder/compare/v2.0.2..v2.0.3
[v2.0.2]: https://github.com/taoufik07/responder/compare/v2.0.1..v2.0.2
[v2.0.1]: https://github.com/taoufik07/responder/compare/v2.0.0..v2.0.1
[v2.0.0]: https://github.com/taoufik07/responder/compare/v1.3.2..v2.0.0
[v1.3.2]: https://github.com/taoufik07/responder/compare/v1.3.1..v1.3.2
[v1.3.1]: https://github.com/taoufik07/responder/compare/v1.3.0..v1.3.1
[v1.3.0]: https://github.com/taoufik07/responder/compare/v1.2.0..v1.3.0
[v1.2.0]: https://github.com/taoufik07/responder/compare/v1.1.3..v1.2.0
[v1.1.3]: https://github.com/taoufik07/responder/compare/v1.1.2..v1.1.3
[v1.1.2]: https://github.com/taoufik07/responder/compare/v1.1.1..v1.1.2
[v1.1.1]: https://github.com/taoufik07/responder/compare/v1.1.0..v1.1.1
[v1.1.0]: https://github.com/taoufik07/responder/compare/v1.0.5..v1.1.0
[v1.0.5]: https://github.com/taoufik07/responder/compare/v1.0.4..v1.0.5
[v1.0.4]: https://github.com/taoufik07/responder/compare/v1.0.3..v1.0.4
[v1.0.3]: https://github.com/taoufik07/responder/compare/v1.0.2..v1.0.3
[v1.0.2]: https://github.com/taoufik07/responder/compare/v1.0.1..v1.0.2
[v1.0.1]: https://github.com/taoufik07/responder/compare/v1.0.0..v1.0.1
[v1.0.0]: https://github.com/taoufik07/responder/compare/v0.3.3..v1.0.0
[v0.3.3]: https://github.com/taoufik07/responder/compare/v0.3.2..v0.3.3
[v0.3.2]: https://github.com/taoufik07/responder/compare/v0.3.1..v0.3.2
[v0.3.1]: https://github.com/taoufik07/responder/compare/v0.3.0..v0.3.1
[v0.3.0]: https://github.com/taoufik07/responder/compare/v0.2.3..v0.3.0
[v0.2.3]: https://github.com/taoufik07/responder/compare/v0.2.2..v0.2.3
[v0.2.2]: https://github.com/taoufik07/responder/compare/v0.2.1..v0.2.2
[v0.2.1]: https://github.com/taoufik07/responder/compare/v0.2.0..v0.2.1
[v0.2.0]: https://github.com/taoufik07/responder/compare/v0.1.6..v0.2.0
[v0.1.6]: https://github.com/taoufik07/responder/compare/v0.1.5..v0.1.6
[v0.1.5]: https://github.com/taoufik07/responder/compare/v0.1.4..v0.1.5
[v0.1.4]: https://github.com/taoufik07/responder/compare/v0.1.3..v0.1.4
[v0.1.3]: https://github.com/taoufik07/responder/compare/v0.1.2..v0.1.3
[v0.1.2]: https://github.com/taoufik07/responder/compare/v0.1.1..v0.1.2
[v0.1.1]: https://github.com/taoufik07/responder/compare/v0.1.0..v0.1.1
[v0.1.0]: https://github.com/taoufik07/responder/compare/v0.0.10..v0.1.0
[v0.0.10]: https://github.com/taoufik07/responder/compare/v0.0.9..v0.0.10
[v0.0.9]: https://github.com/taoufik07/responder/compare/v0.0.8..v0.0.9
[v0.0.8]: https://github.com/taoufik07/responder/compare/v0.0.7..v0.0.8
[v0.0.7]: https://github.com/taoufik07/responder/compare/v0.0.6..v0.0.7
[v0.0.6]: https://github.com/taoufik07/responder/compare/v0.0.5..v0.0.6
[v0.0.5]: https://github.com/taoufik07/responder/compare/v0.0.4..v0.0.5
[v0.0.4]: https://github.com/taoufik07/responder/compare/v0.0.3..v0.0.4
[v0.0.3]: https://github.com/taoufik07/responder/compare/v0.0.2..v0.0.3
[v0.0.2]: https://github.com/taoufik07/responder/compare/v0.0.1..v0.0.2
[v0.0.1]: https://github.com/taoufik07/responder/compare/v0.0.0..v0.0.1
+33
View File
@@ -0,0 +1,33 @@
# Development Sandbox
## Setup
Acquire sources and install project in editable mode.
```shell
git clone https://github.com/kennethreitz/responder
cd responder
python3 -m venv .venv
source .venv/bin/activate
pip install --editable '.[graphql,develop,release,test]'
```
## Operations
Invoke linter and software tests.
```shell
poe check
```
Format code.
```shell
poe format
```
## Release
```shell
git tag v2.1.0
git push --tags
poe release
```
+1
View File
@@ -0,0 +1 @@
include LICENSE
+68 -86
View File
@@ -1,109 +1,91 @@
# Responder
# Responder: a familiar HTTP Service Framework for Python
A familiar HTTP Service Framework for Python, powered by [Starlette](https://www.starlette.io/).
[![Build Status](https://github.com/kennethreitz/responder/actions/workflows/test.yaml/badge.svg)](https://github.com/kennethreitz/responder/actions/workflows/test.yaml)
[![Documentation Status](https://github.com/kennethreitz/responder/actions/workflows/pages/pages-build-deployment/badge.svg)](https://responder.kennethreitz.org/)
[![image](https://img.shields.io/pypi/v/responder.svg)](https://pypi.org/project/responder/)
[![image](https://img.shields.io/pypi/l/responder.svg)](https://pypi.org/project/responder/)
[![image](https://img.shields.io/pypi/pyversions/responder.svg)](https://pypi.org/project/responder/)
[![image](https://img.shields.io/github/contributors/kennethreitz/responder.svg)](https://github.com/kennethreitz/responder/graphs/contributors)
[![PyPI Downloads](https://pepy.tech/badge/responder/month)](https://pepy.tech/project/responder/)
[![Status](https://img.shields.io/pypi/status/responder.svg)](https://pypi.org/project/responder/)
[![License](https://img.shields.io/pypi/l/responder.svg)](https://pypi.org/project/responder/)
```python
import responder
[![](https://farm2.staticflickr.com/1959/43750081370_a4e20752de_o_d.png)](https://responder.readthedocs.io)
api = responder.API()
Powered by [Starlette](https://www.starlette.io/). That `async` declaration is optional.
[View documentation](https://responder.readthedocs.io).
@api.route("/{greeting}")
async def greet_world(req, resp, *, greeting):
resp.text = f"{greeting}, world!"
This gets you a ASGI app, with a production static files server pre-installed, jinja2
templating (without additional imports), and a production webserver based on uvloop,
serving up requests with gzip compression automatically.
if __name__ == "__main__":
api.run()
```
## Testimonials
$ pip install responder
> "Pleasantly very taken with python-responder.
> [@kennethreitz](https://twitter.com/kennethreitz) at his absolute best." —Rudraksh
> M.K.
That's it. Supports Python 3.9+.
> "ASGI is going to enable all sorts of new high-performance web services. It's awesome
> to see Responder starting to take advantage of that." — Tom Christie author of
> [Django REST Framework](https://www.django-rest-framework.org/)
## The Basics
> "I love that you are exploring new patterns. Go go go!" — Danny Greenfield, author of
> [Two Scoops of Django]()
- `resp.text` sends back text. `resp.html` sends back HTML. `resp.content` sends back bytes.
- `resp.media` sends back JSON (or YAML, with content negotiation).
- `resp.file("path.pdf")` serves a file with automatic content-type detection.
- `req.headers` is case-insensitive. `req.params` gives you query parameters.
- Both sync and async views work — the `async` is optional.
## More Examples
## Highlights
See
[the documentation's feature tour](https://responder.readthedocs.io/en/latest/tour.html)
for more details on features available in Responder.
```python
# Type-safe route parameters
@api.route("/users/{user_id:int}")
async def get_user(req, resp, *, user_id):
resp.media = {"id": user_id}
# Installing Responder
# HTTP method filtering
@api.route("/items", methods=["POST"])
async def create_item(req, resp):
data = await req.media()
resp.media = {"created": data}
Install the most recent stable release:
# Class-based views
@api.route("/things/{id}")
class ThingResource:
def on_get(self, req, resp, *, id):
resp.media = {"id": id}
def on_post(self, req, resp, *, id):
resp.text = "created"
pip install --upgrade responder
# Before-request hooks (auth, rate limiting, etc.)
@api.route(before_request=True)
def check_auth(req, resp):
if not req.headers.get("Authorization"):
resp.status_code = 401
resp.media = {"error": "unauthorized"}
Or, install directly from the repository:
# Custom error handling
@api.exception_handler(ValueError)
async def handle_error(req, resp, exc):
resp.status_code = 400
resp.media = {"error": str(exc)}
pip install 'responder @ git+https://github.com/kennethreitz/responder.git'
# Lifespan events
from contextlib import asynccontextmanager
Only **Python 3.6+** is supported.
@asynccontextmanager
async def lifespan(app):
print("starting up")
yield
print("shutting down")
# The Basic Idea
api = responder.API(lifespan=lifespan)
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.
# GraphQL
import graphene
api.graphql("/graphql", schema=graphene.Schema(query=Query))
- Setting `resp.content` sends back bytes.
- Setting `resp.text` sends back unicode, while setting `resp.html` sends back HTML.
- Setting `resp.media` sends back JSON/YAML (`.text`/`.html`/`.content` override this).
- Case-insensitive `req.headers` dict (from Requests directly).
- `resp.status_code`, `req.method`, `req.url`, and other familiar friends.
# WebSockets
@api.route("/ws", websocket=True)
async def websocket(ws):
await ws.accept()
while True:
name = await ws.receive_text()
await ws.send_text(f"Hello {name}!")
## Ideas
# Mount WSGI/ASGI apps
from flask import Flask
flask_app = Flask(__name__)
api.mount("/flask", flask_app)
- Flask-style route expression, with new capabilities -- all while using Python 3.6+'s
new f-string syntax.
- I love Falcon's "every request and response is passed into to each view and mutated"
methodology, especially `response.media`, and have used it here. In addition to
supporting JSON, I have decided to support YAML as well, as Kubernetes is slowly
taking over the world, and it uses YAML for all the things. Content-negotiation and
all that.
- **A built in testing client that uses the actual Requests you know and love**.
- The ability to mount other WSGI apps easily.
- Automatic gzipped-responses.
- In addition to Falcon's `on_get`, `on_post`, etc methods, Responder features an
`on_request` method, which gets called on every type of request, much like Requests.
- A production static file server is built-in.
- Uvicorn built-in as a production web server. I would have chosen Gunicorn, but it
doesn't run on Windows. Plus, Uvicorn serves well to protect against slowloris
attacks, making nginx unnecessary in production.
- GraphQL support, via Graphene. The goal here is to have any GraphQL query exposable at
any route, magically.
- Provide an official way to run webpack.
# Background tasks
@api.route("/work")
def do_work(req, resp):
@api.background.task
def process():
import time; time.sleep(10)
process()
resp.media = {"status": "processing"}
```
## Development
Built-in OpenAPI docs, cookie-based sessions, gzip compression, static file serving, Jinja2 templates, and a production uvicorn server.
Route convertors: `str`, `int`, `float`, `uuid`, `path`.
## Documentation
https://responder.kennethreitz.org
See [Development Sandbox](DEVELOP.md).
+37
View File
@@ -0,0 +1,37 @@
# ruff: noqa: S605, S607
"""
Build and publish a .deb package.
https://pypi.python.org/pypi/stdeb/0.8.5#quickstart-2-just-tell-me-the-fastest-way-to-make-a-deb
"""
import os
from shutil import rmtree
here = os.path.abspath(os.path.dirname(__file__))
def get_version():
import responder
return responder.__version__
def run():
version = get_version()
try:
print("Removing previous builds")
rmtree(os.path.join(here, "deb_dist"))
except FileNotFoundError:
pass
print("Creating Debian package manifest")
os.system(
"python setup.py --command-packages=stdeb.command sdist_dsc "
"-z artful --package3=pipenv --depends3=python3-virtualenv-clone"
)
print("Building .deb")
os.chdir(f"deb_dist/pipenv-{version}")
os.system("dpkg-buildpackage -rfakeroot -uc -us")
if __name__ == "__main__":
run()
+6
View File
@@ -0,0 +1,6 @@
alabaster<0.8
jinja2<3.2
markupsafe<4
readme-renderer<45
sphinx>=5,<9
sphinxcontrib-websupport<2.1
-2
View File
@@ -3,8 +3,6 @@
type="text/css"
href="https://cloud.typography.com/7584432/7586812/css/fonts.css"
/>
<script async defer src="https://buttons.github.io/buttons.js"></script>
<script type="text/javascript">
$("#searchbox").hide(0);
</script>
+18 -13
View File
@@ -7,13 +7,16 @@
/>
</a>
</p>
<p>
<a class="github-button"
href="https://github.com/kennethreitz/responder"
data-color-scheme="no-preference: light; light: light; dark: light;"
data-size="large"
data-show-count="true"
aria-label="Star kennethreitz/responder on GitHub">Star</a>
<iframe
src="https://ghbtns.com/github-btn.html?user=kennethreitz&repo=responder&type=watch&count=true&size=large"
allowtransparency="true"
frameborder="0"
scrolling="0"
width="200px"
height="35px"
></iframe>
</p>
<link
rel="stylesheet"
@@ -51,17 +54,19 @@
<p>Receive updates on new releases and upcoming projects.</p>
<p>
<a class="github-button"
href="https://github.com/kennethreitz"
data-color-scheme="no-preference: light; light: light; dark: light;"
data-size="medium"
data-show-count="true"
aria-label="Follow @kennethreitz on GitHub">Follow @kennethreitz</a>
<iframe
src="https://ghbtns.com/github-btn.html?user=kennethreitz&type=follow&count=true"
allowtransparency="true"
frameborder="0"
scrolling="0"
width="200"
height="20"
></iframe>
</p>
<p>
<a
href="https://x.com/kennethreitz42"
href="https://twitter.com/kennethreitz"
class="twitter-follow-button"
data-show-count="false"
>Follow @kennethreitz</a
+1 -1
View File
@@ -66,7 +66,7 @@
<p>
<a
href="https://x.com/kennethreitz42"
href="https://twitter.com/kennethreitz"
class="twitter-follow-button"
data-show-count="false"
>Follow @kennethreitz</a
-7
View File
@@ -1,7 +0,0 @@
# Backlog
## Future Ideas
- Consider adding `after_request` hooks (complement to `before_request`)
- Explore WebSocket before_request short-circuit support
- Add rate limiting middleware
- Consider async template rendering by default
-1
View File
@@ -1 +0,0 @@
../../CHANGELOG.md
-174
View File
@@ -1,174 +0,0 @@
Responder CLI
=============
Responder installs a command line program ``responder``. Use it to launch
a Responder application from a file or module, either located on a local
or remote filesystem, or object store.
Launch Module Entrypoint
------------------------
For loading a Responder application from a Python module, you will refer to
its ``API()`` instance using a `Python entry point object reference`_ that
points to a Python object. It is either in the form ``importable.module``,
or ``importable.module:object.attr``.
A basic invocation command to launch a Responder application:
.. code-block:: shell
responder run acme.app
The command above assumes a Python package ``acme`` including an ``app``
module ``acme/app.py`` that includes an attribute ``api`` that refers
to a ``responder.API`` instance, reflecting the typical layout of
a standard Responder application.
Loading a Responder application using an entrypoint specification will
inherit the capacities of `Python's import system`_, as implemented by
`importlib`_.
Launch Local File
-----------------
Acquire a minimal example single-file application, ``helloworld.py`` [1]_,
to your local filesystem, giving you the chance to edit it, and launch the
Responder HTTP service.
.. code-block:: shell
wget https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py
responder run helloworld.py
.. note::
To validate the example application, invoke a HTTP request, for example using
`curl`_, `HTTPie`_, or your favourite browser at hand.
.. code-block:: shell
http http://127.0.0.1:5042/Hello
The response is no surprise.
::
HTTP/1.1 200 OK
content-length: 13
content-type: text/plain
date: Sat, 26 Oct 2024 13:16:55 GMT
encoding: utf-8
server: uvicorn
Hello, world!
.. [1] The Responder application `helloworld.py`_ implements a basic echo handler.
Launch Remote File
------------------
You can also launch a single-file application where its Python file is stored
on a remote location.
Responder supports all filesystem adapters compatible with `fsspec`_, and
installs the adapters for Azure Blob Storage (az), Google Cloud Storage (gs),
GitHub, HTTP, and AWS S3 by default.
.. code-block:: shell
# Works 1:1.
responder run https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py
responder run github://kennethreitz:responder@/examples/helloworld.py
If you need access other kinds of remote targets, see the `list of
fsspec-supported filesystems and protocols`_. The next section enumerates
a few synthetic examples. The corresponding storage buckets do not even
exist, so don't expect those commands to work.
.. code-block:: shell
# Azure Blob Storage, Google Cloud Storage, and AWS S3.
responder run az://kennethreitz-assets/responder/examples/helloworld.py
responder run gs://kennethreitz-assets/responder/examples/helloworld.py
responder run s3://kennethreitz-assets/responder/examples/helloworld.py
# Hadoop Distributed File System (hdfs), SSH File Transfer Protocol (sftp),
# Common Internet File System (smb), Web-based Distributed Authoring and
# Versioning (webdav).
responder run hdfs://kennethreitz-assets/responder/examples/helloworld.py
responder run sftp://user@host/kennethreitz/responder/examples/helloworld.py
responder run smb://workgroup;user:password@server:port/responder/examples/helloworld.py
responder run webdav+https://user:password@server:port/responder/examples/helloworld.py
.. tip::
In order to install support for all filesystem types supported by fsspec, run:
.. code-block:: shell
uv pip install 'fsspec[full]'
When using ``uv``, this concludes within an acceptable time of approx.
25 seconds. If you need to be more selectively instead of using ``full``,
choose from one or multiple of the available `fsspec extras`_, which are:
abfs, arrow, dask, dropbox, fuse, gcs, git, github, hdfs, http, oci, s3,
sftp, smb, ssh.
Launch with Non-Standard Instance Name
--------------------------------------
By default, Responder will acquire an ``responder.API`` instance using the
symbol name ``api`` from the specified Python module.
If your main application file uses a different name than ``api``, please
append the designated symbol name to the launch target address.
It works like this for module entrypoints and local files:
.. code-block:: shell
responder run acme.app:service
responder run /path/to/acme/app.py:service
It works like this for URLs:
.. code-block:: shell
responder run http://app.server.local/path/to/acme/app.py#service
Within your ``app.py``, the instance would have been defined to use
the ``service`` symbol name instead of ``api``, like this:
.. code-block:: python
service = responder.API()
Build JavaScript Application
----------------------------
The ``build`` subcommand invokes ``npm run build``, optionally accepting
a target directory. By default, it uses the current working directory,
where it expects a regular NPM ``package.json`` file.
.. code-block:: shell
responder build
When specifying a target directory, Responder will change to that
directory beforehand.
.. code-block:: shell
responder build /path/to/project
.. _curl: https://curl.se/
.. _fsspec: https://filesystem-spec.readthedocs.io/en/latest/
.. _fsspec extras: https://github.com/fsspec/filesystem_spec/blob/2024.12.0/pyproject.toml#L27-L69
.. _helloworld.py: https://github.com/kennethreitz/responder/blob/main/examples/helloworld.py
.. _HTTPie: https://httpie.io/docs/cli
.. _importlib: https://docs.python.org/3/library/importlib.html
.. _list of fsspec-supported filesystems and protocols: https://github.com/fsspec/universal_pathlib#currently-supported-filesystems-and-protocols
.. _Python entry point object reference: https://packaging.python.org/en/latest/specifications/entry-points/
.. _Python's import system: https://docs.python.org/3/reference/import.html
+4 -86
View File
@@ -20,23 +20,11 @@
# -- Project information -----------------------------------------------------
project = "responder"
copyright = "2018-2026, A Kenneth Reitz project"
copyright = "2024, A Kenneth Reitz project"
author = "Kenneth Reitz"
# The short X.Y version
import os
# Path hackery to get current version number.
here = os.path.abspath(os.path.dirname(__file__))
about = {}
with open(os.path.join(here, "..", "..", "responder", "__version__.py")) as f:
exec(f.read(), about)
version = about["__version__"]
# The full version, including alpha/beta/rc tags
release = about["__version__"]
version = ""
# -- General configuration ---------------------------------------------------
@@ -57,11 +45,6 @@ extensions = [
"sphinx.ext.ifconfig",
"sphinx.ext.viewcode",
"sphinx.ext.githubpages",
"myst_parser",
"sphinx_copybutton",
"sphinx_design",
"sphinx_design_elements",
"sphinxext.opengraph",
]
# Add any paths that contain templates here, relative to this directory.
@@ -71,7 +54,7 @@ templates_path = ["_templates"]
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = {".rst": "restructuredtext"}
source_suffix = ".rst"
# The master toctree document.
master_doc = "index"
@@ -216,77 +199,12 @@ epub_exclude_files = ["search.html"]
# -- Extension configuration -------------------------------------------------
# -- Options for link checker ----------------------------------------------
linkcheck_ignore = [
# Feldroy.com links are ignored because it blocks GHA.
r"https://www.feldroy.com/.*",
]
linkcheck_anchors_ignore_for_url = [
# Requires JavaScript.
# After opting-in to new GitHub issues, Sphinx can no longer grok the HTML anchor references.
r"https://github.com",
]
# -- Options for intersphinx extension ---------------------------------------
# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
intersphinx_mapping = {"https://docs.python.org/": None}
# -- Options for todo extension ----------------------------------------------
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = True
# -- Options for MyST --------------------------------------------------------
myst_heading_anchors = 3
myst_enable_extensions = [
"attrs_block",
"attrs_inline",
"colon_fence",
"deflist",
"fieldlist",
"html_admonition",
"html_image",
"linkify",
"replacements",
"strikethrough",
"substitution",
"tasklist",
]
myst_substitutions = {}
# -- Options for OpenGraph ---------------------------------------------------
#
# When making changes, check them using the RTD PR preview URL on https://www.opengraph.xyz/.
#
# About text lengths
#
# Original documentation says:
# - ogp_description_length
# Configure the amount of characters taken from a page. The default of 200 is probably good
# for most people. If something other than a number is used, it defaults back to 200.
# -- https://sphinxext-opengraph.readthedocs.io/en/latest/#options
#
# Other people say:
# - og:title 40 chars
# - og:description has 2 max lengths:
# When the link is used in a Post, it's 300 chars. When a link is used in a Comment, it's 110 chars.
# So you can either treat it as 110, or, write your Descriptions to 300 but make sure the first 110
# is the critical part and still makes sense when it gets cut off.
# -- https://stackoverflow.com/questions/8914476/facebook-open-graph-meta-tags-maximum-content-length
ogp_site_url = "https://responder.kennethreitz.org/"
ogp_description_length = 300
ogp_site_name = "Responder Documentation"
ogp_image = "https://responder.kennethreitz.org/_static/responder.png"
ogp_image_alt = False
ogp_use_first_image = False
ogp_type = "website"
ogp_enable_meta_description = True
# -- Options for sphinx-copybutton -------------------------------------------
copybutton_remove_prompts = True
copybutton_line_continuation_character = "\\"
copybutton_prompt_text = r">>> |\.\.\. |\$ |sh\$ |PS> |cr> |mysql> |In \[\d*\]: | {2,5}\.\.\.: | {5,8}: "
copybutton_prompt_is_regexp = True
+21 -18
View File
@@ -6,30 +6,33 @@ You can deploy Responder anywhere you can deploy a basic Python application.
Docker Deployment
-----------------
Assuming an existing ``api.py`` containing your Responder application.
Assuming existing ``api.py`` and ``Pipfile.lock`` containing ``responder``.
``Dockerfile``::
FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install responder
ENV PORT=80
FROM kennethreitz/pipenv
ENV PORT '80'
COPY . /app
CMD python3 api.py
EXPOSE 80
CMD ["python", "api.py"]
That's it!
Cloud Deployment
----------------
Responder automatically honors the ``PORT`` environment variable, which is
set by most cloud platforms (Fly.io, Railway, Render, Google Cloud Run, etc.).
Heroku Deployment
-----------------
The basics::
$ mkdir my-api
$ cd my-api
$ git init
$ heroku create
...
Install Responder::
$ pipenv install responder
...
Write out an ``api.py``::
@@ -44,12 +47,12 @@ Write out an ``api.py``::
if __name__ == "__main__":
api.run()
Deploy with your platform of choice. Responder will bind to ``0.0.0.0``
on the port specified by ``PORT`` automatically.
Write out a ``Procfile``::
Running with Uvicorn Directly
-----------------------------
web: python api.py
For production deployments, you can also run your app directly with uvicorn::
That's it! Next, we commit and push to Heroku::
uvicorn api:api --host 0.0.0.0 --port 8000 --workers 4
$ git add -A
$ git commit -m 'initial commit'
$ git push heroku master
+32 -65
View File
@@ -6,23 +6,19 @@
A familiar HTTP Service Framework
=================================
|ci-tests| |version| |license| |python-versions| |downloads| |contributors| |say-thanks|
|Build Status| |image1| |image2| |image3| |image4| |image5|
.. |ci-tests| image:: https://github.com/kennethreitz/responder/actions/workflows/test.yaml/badge.svg
.. |Build Status| image:: https://github.com/kennethreitz/responder/actions/workflows/test.yaml/badge.svg
:target: https://github.com/kennethreitz/responder/actions/workflows/test.yaml
.. |ci-docs| image:: https://github.com/kennethreitz/responder/actions/workflows/docs.yaml/badge.svg
:target: https://github.com/kennethreitz/responder/actions/workflows/docs.yaml
.. |version| image:: https://img.shields.io/pypi/v/responder.svg
.. |image1| image:: https://img.shields.io/pypi/v/responder.svg
:target: https://pypi.org/project/responder/
.. |license| image:: https://img.shields.io/pypi/l/responder.svg
.. |image2| image:: https://img.shields.io/pypi/l/responder.svg
:target: https://pypi.org/project/responder/
.. |python-versions| image:: https://img.shields.io/pypi/pyversions/responder.svg
.. |image3| image:: https://img.shields.io/pypi/pyversions/responder.svg
:target: https://pypi.org/project/responder/
.. |downloads| image:: https://static.pepy.tech/badge/responder/month
:target: https://www.pepy.tech/projects/responder
.. |contributors| image:: https://img.shields.io/github/contributors/kennethreitz/responder.svg
.. |image4| image:: https://img.shields.io/github/contributors/kennethreitz/responder.svg
:target: https://github.com/kennethreitz/responder/graphs/contributors
.. |say-thanks| image:: https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg
.. |image5| image:: https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg
:target: https://saythanks.io/to/kennethreitz
.. code:: python
@@ -38,24 +34,23 @@ A familiar HTTP Service Framework
if __name__ == '__main__':
api.run()
Responder is powered by `Starlette`_.
Powered by `Starlette <https://www.starlette.io/>`_. That ``async`` declaration is optional.
The example program demonstrates an `ASGI`_ application using `Responder`_,
including production-ready components like the `uvicorn`_ webserver, based
on `uvloop`_, and the `Jinja`_ templating library pre-installed.
The ``async`` declaration within the example program is optional.
This gets you a ASGI app, with a production static files server
(`WhiteNoise <http://whitenoise.evans.io/en/stable/>`_)
pre-installed, jinja2 templating (without additional imports), and a
production webserver based on uvloop, serving up requests with
automatic gzip compression.
Features
--------
- A pleasant API, with a single import statement.
- Class-based views without inheritance.
- `ASGI`_, the future of Python web services.
- Asynchronous Python frameworks and applications.
- Automatic gzip compression.
- `ASGI <https://asgi.readthedocs.io>`_ framework, the future of Python web services.
- WebSocket support!
- The ability to mount any ASGI / WSGI app at a subroute.
- `f-string syntax`_ route declaration.
- `f-string syntax <https://docs.python.org/3/whatsnew/3.6.html#pep-498-formatted-string-literals>`_ route declaration.
- Mutable response object, passed into each view. No need to return anything.
- Background tasks, spawned off in a ``ThreadPoolExecutor``.
- GraphQL (with *GraphiQL*) support!
@@ -66,23 +61,30 @@ Testimonials
------------
“Pleasantly very taken with python-responder.
`@kennethreitz`_ at his absolute best.”
`@kennethreitz <https://twitter.com/kennethreitz>`_ at his absolute
best.”
—Rudraksh M.K.
— Rudraksh M.K.
..
"ASGI is going to enable all sorts of new high-performance web services. It's awesome to see Responder starting to take advantage of that."
Tom Christie, author of `Django REST Framework`_
Tom Christie, author of `Django REST Framework`_
..
“I love that you are exploring new patterns. Go go go!”
— Danny Greenfield, author of `Two Scoops of Django`_
— Danny Greenfield, author of `Two Scoops of Django`_
.. _Django REST Framework: https://www.django-rest-framework.org/
.. _Two Scoops of Django: https://www.feldroy.com/two-scoops-press#two-scoops-of-django
User Guides
-----------
@@ -94,38 +96,17 @@ User Guides
deployment
testing
api
cli
Installing Responder
--------------------
Use ``uv`` for fast installation.
.. code-block:: shell
uv pip install --upgrade 'responder'
$ pipenv install responder
✨🍰✨
Or use standard pip where ``uv`` is not available.
.. code-block:: shell
pip install --upgrade 'responder'
Responder supports **Python 3.9+**.
Development
-----------
If you are looking at installing Responder
for hacking on it, please refer to the :ref:`sandbox` documentation.
.. toctree::
:maxdepth: 1
changes
Sandbox <sandbox>
backlog
Only **Python 3.6+** is supported.
The Basic Idea
@@ -142,14 +123,14 @@ The primary concept here is to bring the niceties that are brought forth from bo
Ideas
-----
- Flask-style route expression, with new capabilities -- using Python's f-string syntax.
- Flask-style route expression, with new capabilities -- all while using Python 3.6+'s new f-string syntax.
- I love Falcon's "every request and response is passed into each view and mutated" methodology, especially ``response.media``, and have used it here. In addition to supporting JSON, I have decided to support YAML as well, as Kubernetes is slowly taking over the world, and it uses YAML for all the things. Content-negotiation and all that.
- **A built in testing client** powered by Starlette's TestClient.
- **A built in testing client that uses the actual Requests you know and love**.
- The ability to mount other WSGI apps easily.
- Automatic gzipped-responses.
- In addition to Falcon's ``on_get``, ``on_post``, etc methods, Responder features an ``on_request`` method, which gets called on every type of request, much like Requests.
- A production static files server is built-in.
- `uvicorn`_ 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.
- `Uvicorn <https://www.uvicorn.org/>`_ is built-in as a production web server. I would have chosen Gunicorn, but it doesn't run on Windows. Plus, Uvicorn serves well to protect against `slowloris <https://en.wikipedia.org/wiki/Slowloris_(computer_security)>`_ attacks, making nginx unnecessary in production.
- GraphQL support, via Graphene. The goal here is to have any GraphQL query exposable at any route, magically.
@@ -159,17 +140,3 @@ Indices and tables
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
.. _@kennethreitz: https://x.com/kennethreitz42
.. _ASGI: https://en.wikipedia.org/wiki/Asynchronous_Server_Gateway_Interface
.. _Django REST Framework: https://www.django-rest-framework.org/
.. _f-string syntax: https://docs.python.org/3/whatsnew/3.6.html#pep-498-formatted-string-literals
.. _Jinja: https://jinja.palletsprojects.com/en/stable/
.. _ServeStatic: https://archmonger.github.io/ServeStatic/latest/
.. _Slowloris: https://en.wikipedia.org/wiki/Slowloris_(computer_security)
.. _Starlette: https://www.starlette.io/
.. _Responder: https://responder.kennethreitz.org/
.. _Two Scoops of Django: https://www.feldroy.com/two-scoops-press#two-scoops-of-django
.. _uvicorn: https://www.uvicorn.org/
.. _uvloop: https://uvloop.readthedocs.io/
+10 -9
View File
@@ -54,7 +54,7 @@ Type convertors are also available::
async def add(req, resp, *, a, b):
resp.text = f"{a} + {b} = {a + b}"
Supported types: ``str``, ``int``, ``float``, ``uuid``, and ``path``.
Supported types: ``str``, ``int`` and ``float``.
Returning JSON / YAML
---------------------
@@ -73,7 +73,7 @@ If the client requests YAML instead (with a header of ``Accept: application/x-ya
Rendering a Template
--------------------
Responder provides a built-in light `Jinja`_ wrapper ``templates.Templates``
Responder provides a built-in light `jinja2 <http://jinja.pocoo.org/docs/>`_ wrapper ``templates.Templates``
Usage::
@@ -158,19 +158,20 @@ Here's a sample code to post a file with background::
@api.background.task
def process_data(data):
with open(f"./{data['file']['filename']}", 'wb') as f:
f.write(data['file']['content'])
f = open('./{}'.format(data['file']['filename']), 'w')
f.write(data['file']['content'].decode('utf-8'))
f.close()
data = await req.media(format='files')
process_data(data)
resp.media = {'success': 'ok'}
You can test file uploads using the built-in test client::
You can send a file easily with requests::
files = {'file': ('hello.txt', b'hello, world!', 'text/plain')}
r = api.requests.post(api.url_for(upload_file), files=files)
print(r.json())
import requests
data = {'file': ('hello.txt', 'hello, world!', "text/plain")}
r = requests.post('http://127.0.0.1:8210/file', files=data)
.. _Jinja: https://jinja.palletsprojects.com/en/stable/
print(r.text)
-36
View File
@@ -1,36 +0,0 @@
(sandbox)=
# Development Sandbox
## Setup
Set up a development sandbox.
Acquire sources and create virtualenv.
```shell
git clone https://github.com/kennethreitz/responder.git
cd responder
uv venv
```
Install project in editable mode, including
all development tools.
```shell
uv pip install --upgrade --editable '.[develop,docs,release,test]'
```
## Operations
Run tests.
```shell
source .venv/bin/activate
pytest
```
Format code.
```shell
ruff format .
ruff check --fix .
```
Documentation authoring.
```shell
sphinx-autobuild --open-browser --watch docs/source docs/source docs/build
```
+37 -4
View File
@@ -1,16 +1,16 @@
Building and Testing with Responder
===================================
Responder comes with a first-class, well supported test client for your ASGI web services (powered by Starlette's TestClient).
Responder comes with a first-class, well supported test client for your ASGI web services: **Requests**.
Here, we'll go over the basics of setting up and testing a Responder application.
Here, we'll go over the basics of setting up a proper Python package and adding testing to it.
The Basics
----------
Your project should look like this::
Your repository should look like this::
api.py test_api.py
Pipfile Pipfile.lock api.py test_api.py
``$ cat api.py``::
@@ -25,6 +25,26 @@ Your project should look like this::
if __name__ == "__main__":
api.run()
``$ cat Pipfile``::
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
responder = "*"
[dev-packages]
pytest = "*"
[requires]
python_version = "3.7"
[pipenv]
allow_prereleases = true
Writing Tests
-------------
@@ -46,3 +66,16 @@ Writing Tests
...
========================== 1 passed in 0.10 seconds ==========================
(Optional) Proper Python Package
--------------------------------
Optionally, you can not rely on relative imports, and instead install your api as a proper package. This requires:
1. A `proper setup.py <https://github.com/kennethreitz/setup.py>`_ file.
2. ``$ pipenv install -e . --dev``
This will allow you to only specify your dependencies once: in ``setup.py``. ``$ pipenv lock`` will automatically lock your transitive dependencies (e.g. Responder), even if it's not specified in the ``Pipfile``.
This will ensure that your application gets installed in every developer's environment, using Pipenv.
+10 -90
View File
@@ -2,21 +2,6 @@ Feature Tour
============
Route Method Filtering
----------------------
You can restrict routes to specific HTTP methods::
@api.route("/items", methods=["GET"])
def list_items(req, resp):
resp.media = {"items": [...]}
@api.route("/items", methods=["POST"], check_existing=False)
async def create_item(req, resp):
data = await req.media()
resp.media = {"created": data}
Class-Based Views
-----------------
@@ -30,61 +15,6 @@ Class-based views (and setting some headers and stuff)::
resp.status_code = api.status_codes.HTTP_416
Lifespan Events
---------------
Use the lifespan context manager for startup and shutdown logic::
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app):
# Startup: connect to database, etc.
print("Starting up...")
yield
# Shutdown: clean up resources
print("Shutting down...")
api = responder.API(lifespan=lifespan)
You can also use the traditional event decorators::
@api.on_event('startup')
async def startup():
print("Starting up...")
@api.on_event('shutdown')
async def shutdown():
print("Shutting down...")
Serving Files
-------------
Serve files from disk with automatic content-type detection::
@api.route("/download")
def download(req, resp):
resp.file("reports/annual.pdf")
You can also specify the content type explicitly::
@api.route("/image")
def image(req, resp):
resp.file("photos/cat.jpg", content_type="image/jpeg")
Custom Error Handling
---------------------
Register handlers for specific exception types::
@api.exception_handler(ValueError)
async def handle_value_error(req, resp, exc):
resp.status_code = 400
resp.media = {"error": str(exc)}
Background Tasks
----------------
@@ -104,15 +34,10 @@ Here, you can spawn off a background thread to run any function, out-of-request:
GraphQL
-------
Responder supports GraphQL, a query language for APIs that enables clients to
request exactly the data they need.
For more information about GraphQL, visit https://graphql.org/.
Serve a GraphQL API::
import graphene
from responder.ext.graphql import GraphQLView
class Query(graphene.ObjectType):
hello = graphene.String(name=graphene.String(default_value="stranger"))
@@ -121,7 +46,7 @@ Serve a GraphQL API::
return f"Hello {name}"
schema = graphene.Schema(query=Query)
view = GraphQLView(api=api, schema=schema)
view = responder.ext.GraphQLView(api=api, schema=schema)
api.add_route("/graph", view)
@@ -133,18 +58,12 @@ You can make use of Responder's Request and Response objects in your GraphQL res
OpenAPI Schema Support
----------------------
Responder comes with built-in support for OpenAPI / marshmallow.
Responder comes with built-in support for OpenAPI / marshmallow
.. note::
If you're upgrading from a previous version, note that the OpenAPI module
has been renamed from ``responder.ext.schema`` to ``responder.ext.openapi``.
Update your imports accordingly.
New in Responder 1.4.0::
New in Responder `1.4.0`::
import responder
from responder.ext.openapi import OpenAPISchema
from responder.ext.schema import Schema as OpenAPISchema
from marshmallow import Schema, fields
contact = {
@@ -275,11 +194,12 @@ Responder can automatically supply API Documentation for you. Using the example
The new and recommended way::
from responder.ext.openapi import OpenAPISchema
...
from responder.ext.schema import Schema
...
api = responder.API()
schema = OpenAPISchema(
schema = Schema(
app=api,
title="Web Service",
version="1.0",
@@ -294,7 +214,7 @@ The new and recommended way::
)
The old way::
The old way ::
api = responder.API(
title="Web Service",
@@ -433,7 +353,7 @@ Closing the connection::
Using Requests Test Client
--------------------------
Responder comes with a first-class, well supported test client for your ASGI web services (powered by Starlette's TestClient).
Responder comes with a first-class, well supported test client for your ASGI web services: **Requests**.
Here's an example of a test (written with pytest)::
-7
View File
@@ -1,15 +1,8 @@
# Example HTTP service definition, using Responder.
# https://pypi.org/project/responder/
import responder
api = responder.API()
@api.route("/")
async def index(req, resp):
resp.text = "hello, world!"
@api.route("/{greeting}")
async def greet_world(req, resp, *, greeting):
resp.text = f"{greeting}, world!"
-26
View File
@@ -1,26 +0,0 @@
# Example showing the lifespan context manager pattern.
# https://pypi.org/project/responder/
from contextlib import asynccontextmanager
import responder
@asynccontextmanager
async def lifespan(app):
# Startup: initialize resources
print("Starting up...")
yield
# Shutdown: clean up resources
print("Shutting down...")
api = responder.API(lifespan=lifespan)
@api.route("/{greeting}")
async def greet_world(req, resp, *, greeting):
resp.text = f"{greeting}, world!"
if __name__ == "__main__":
api.run()
-25
View File
@@ -1,25 +0,0 @@
# Example HTTP service definition, using Responder.
# https://pypi.org/project/responder/
import responder
api = responder.API()
@api.route("/")
async def index(req, resp):
resp.text = "Welcome"
@api.route("/user")
async def user_create(req, resp):
data = await req.media()
resp.text = f"Hello, {data['username']}"
@api.route("/user/{identifier}")
async def user_get(req, resp, *, identifier):
resp.text = f"Hello, user {identifier}"
if __name__ == "__main__":
api.run()
+46 -108
View File
@@ -1,97 +1,17 @@
[build-system]
build-backend = "setuptools.build_meta"
requires = [
"setuptools>=42",
"setuptools>=42", # At least v42 of setuptools required.
"versioningit",
]
[project]
name = "responder"
description = "A familiar HTTP Service Framework for Python."
readme = "README.md"
license = {text = "Apache 2.0"}
authors = [
{ name = "Kenneth Reitz", email = "me@kennethreitz.org" },
]
requires-python = ">=3.9"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Web Environment",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython",
"Topic :: Internet :: WWW/HTTP",
]
dynamic = ["version"]
dependencies = [
"a2wsgi",
"apispec>=1.0.0",
"chardet",
"docopt-ng",
"graphene>=3",
"graphql-core>=3.1",
"marshmallow",
"pueblo[sfa-full]>=0.0.11",
"python-multipart",
"starlette[full]>=0.40",
"uvicorn[standard]",
]
[project.optional-dependencies]
develop = [
"pyproject-fmt",
"ruff",
"validate-pyproject",
]
docs = [
"alabaster<1.1",
"myst-parser[linkify]",
"sphinx>=5,<9",
"sphinx-autobuild",
"sphinx-copybutton",
"sphinx-design-elements",
"sphinxext.opengraph",
]
release = ["build", "twine"]
test = [
"flask",
"mypy",
"pytest",
"pytest-cov",
"pytest-mock",
"pytest-rerunfailures",
]
[project.scripts]
responder = "responder.ext.cli:cli"
[project.urls]
Homepage = "https://github.com/kennethreitz/responder"
Documentation = "https://responder.kennethreitz.org"
Repository = "https://github.com/kennethreitz/responder"
Issues = "https://github.com/kennethreitz/responder/issues"
[tool.setuptools.dynamic]
version = {attr = "responder.__version__.__version__"}
[tool.setuptools.package-data]
responder = ["py.typed"]
[tool.setuptools.packages.find]
exclude = ["tests"]
[tool.ruff]
line-length = 90
extend-exclude = [
"bin/mkdeb.py",
"docs/source/conf.py",
"setup.py",
]
lint.select = [
@@ -126,8 +46,6 @@ lint.extend-ignore = [
"S101", # Allow use of `assert`.
]
lint.per-file-ignores."responder/util/cmd.py" = [ "A005" ] # Module shadows a Python standard-library module
lint.per-file-ignores."tests/*" = [
"ERA001", # Found commented-out code.
"S101", # Allow use of `assert`, and `print`.
@@ -153,31 +71,51 @@ markers = [
]
xfail_strict = true
[tool.coverage.run]
branch = false
omit = [
"*.html",
"tests/*",
[tool.versioningit]
[tool.poe.tasks]
check = [
"lint",
"test",
]
[tool.coverage.report]
fail_under = 0
show_missing = true
exclude_lines = [
"# pragma: no cover",
"raise NotImplemented",
docs-autobuild = [
{ cmd = "sphinx-autobuild --open-browser --watch docs/source docs/build" },
]
docs-html = [
{ cmd = "sphinx-build -W --keep-going docs/source docs/build" },
]
docs-linkcheck = [
{ cmd = "sphinx-build -W --keep-going -b linkcheck docs/source docs/build" },
]
[tool.mypy]
packages = [
"responder",
format = [
{ cmd = "ruff format ." },
# Configure Ruff not to auto-fix (remove!):
# unused imports (F401), unused variables (F841), `print` statements (T201), and commented-out code (ERA001).
{ cmd = "ruff check --fix --ignore=ERA --ignore=F401 --ignore=F841 --ignore=T20 --ignore=ERA001 ." },
{ cmd = "pyproject-fmt --keep-full-version pyproject.toml" },
]
exclude = [
lint = [
{ cmd = "ruff format --check ." },
{ cmd = "ruff check ." },
{ cmd = "validate-pyproject pyproject.toml" },
# { cmd = "mypy" },
]
check_untyped_defs = true
explicit_package_bases = true
ignore_missing_imports = true
implicit_optional = true
install_types = true
namespace_packages = true
non_interactive = true
release = [
{ cmd = "python -m build" },
{ cmd = "twine upload --skip-existing dist/*" },
]
[tool.poe.tasks.test]
cmd = "pytest"
help = "Invoke software tests"
[tool.poe.tasks.test.args.expression]
options = [ "-k" ]
[tool.poe.tasks.test.args.marker]
options = [ "-m" ]
+5
View File
@@ -0,0 +1,5 @@
build:
image: latest
python:
version: 3.6
+7 -8
View File
@@ -1,18 +1,17 @@
"""
Responder - a familiar HTTP Service Framework.
This module exports the core functionality of the Responder framework,
including the API, Request, and Response classes.
"""
from importlib.metadata import PackageNotFoundError, version
from . import ext
from .__version__ import __version__
from .core import API, Request, Response
try:
__version__ = version("responder")
except PackageNotFoundError: # pragma: no cover
__version__ = "unknown"
__all__ = [
"API",
"Request",
"Response",
"__version__",
"ext",
"__version__",
]
-1
View File
@@ -1 +0,0 @@
__version__ = "3.1.0"
+48 -132
View File
@@ -1,22 +1,20 @@
import asyncio
import os
from pathlib import Path
__all__ = ["API"]
import uvicorn
from starlette.exceptions import ExceptionMiddleware
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.errors import ServerErrorMiddleware
from starlette.middleware.exceptions import ExceptionMiddleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
from starlette.middleware.sessions import SessionMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from starlette.testclient import TestClient
from . import status_codes
from .background import BackgroundQueue
from .ext.schema import OpenAPISchema as OpenAPISchema
from .formats import get_formats
from .models import Request, Response
from .routes import Router
from .staticfiles import StaticFiles
from .statics import DEFAULT_CORS_PARAMS, DEFAULT_OPENAPI_THEME, DEFAULT_SECRET_KEY
@@ -58,18 +56,17 @@ class API:
cors_params=DEFAULT_CORS_PARAMS,
allowed_hosts=None,
openapi_theme=DEFAULT_OPENAPI_THEME,
lifespan=None,
):
self.background = BackgroundQueue()
self.secret_key = secret_key
self.router = Router(lifespan=lifespan)
self.router = Router()
if static_dir is not None:
if static_route is None:
static_route = ""
static_dir = Path(static_dir).resolve()
static_route = static_dir
static_dir = Path(os.path.abspath(static_dir))
self.static_dir = static_dir
self.static_route = static_route
@@ -80,15 +77,22 @@ class API:
self.debug = debug
if not allowed_hosts:
# if not debug:
# raise RuntimeError(
# "You need to specify `allowed_hosts` when debug is set to False"
# ) # noqa: ERA001
allowed_hosts = ["*"]
self.allowed_hosts = allowed_hosts
if self.static_dir is not None:
self.static_dir.mkdir(parents=True, exist_ok=True)
os.makedirs(self.static_dir, exist_ok=True)
if self.static_dir is not None:
self.mount(self.static_route, self.static_app)
self.formats = get_formats()
# Cached requests session.
self._session = None
self.default_endpoint = None
@@ -106,14 +110,6 @@ class API:
self.add_middleware(SessionMiddleware, secret_key=self.secret_key)
if openapi or docs_route:
try:
from .ext.openapi import OpenAPISchema
except ImportError as ex:
raise ImportError(
"The dependencies for the OpenAPI extension are not installed. "
"Install them using: pip install responder"
) from ex
self.openapi = OpenAPISchema(
app=self,
title=title,
@@ -129,12 +125,11 @@ class API:
openapi_theme=openapi_theme,
)
# TODO: Update docs for templates
self.templates = Templates(directory=templates_dir)
@property
def requests(self):
"""A test client connected to the ASGI app. Lazily initialized."""
return self.session()
self.requests = (
self.session()
) #: A Requests session that is connected to the ASGI app.
@property
def static_app(self):
@@ -153,59 +148,9 @@ class API:
def add_middleware(self, middleware_cls, **middleware_config):
self.app = middleware_cls(self.app, **middleware_config)
def exception_handler(self, exception_cls):
"""Register a handler for a specific exception type.
Usage::
@api.exception_handler(ValueError)
async def handle_value_error(req, resp, exc):
resp.status_code = 400
resp.media = {"error": str(exc)}
"""
def decorator(func):
async def _handler(request, exc):
from starlette.responses import Response as StarletteResp
req = Request(request.scope, request.receive, formats=get_formats())
resp = Response(req=req, formats=get_formats())
if asyncio.iscoroutinefunction(func):
await func(req, resp, exc)
else:
func(req, resp, exc)
if resp.status_code is None:
resp.status_code = 500
body, headers = await resp.body
return StarletteResp(
content=body, status_code=resp.status_code, headers=headers
)
# Register with the ExceptionMiddleware
self.router._exception_handlers = getattr(
self.router, "_exception_handlers", {}
)
self.router._exception_handlers[exception_cls] = _handler
# Also register on the ASGI app chain
from starlette.middleware.exceptions import ExceptionMiddleware as EM
app = self.app
while app is not None:
if isinstance(app, EM):
app.add_exception_handler(exception_cls, _handler)
break
app = getattr(app, "app", None)
return func
return decorator
def schema(self, name, **options):
"""
Decorator for creating new routes around function and class definitions.
"""Decorator for creating new routes around function and class definitions.
Usage::
from marshmallow import Schema, fields
@api.schema("Pet")
class PetSchema(Schema):
@@ -239,7 +184,6 @@ class API:
check_existing=True,
websocket=False,
before_request=False,
methods=None,
):
"""Adds a route to the API.
@@ -248,9 +192,9 @@ class API:
:param default: If ``True``, all unknown requests will route to this view.
:param static: If ``True``, and no endpoint was passed, render "static/index.html".
Also, it will become a default route.
:param methods: Optional list of HTTP methods (e.g. ``["GET", "POST"]``).
""" # noqa: E501
# Path
if static:
assert self.static_dir is not None
if not endpoint:
@@ -264,30 +208,23 @@ class API:
websocket=websocket,
before_request=before_request,
check_existing=check_existing,
methods=methods,
)
async def _static_response(self, req, resp):
assert self.static_dir is not None
index = (self.static_dir / "index.html").resolve()
if index.exists():
resp.html = index.read_text()
if os.path.exists(index):
with open(index, "r") as f:
resp.html = f.read()
else:
resp.status_code = status_codes.HTTP_404 # type: ignore[attr-defined]
resp.status_code = status_codes.HTTP_404
resp.text = "Not found."
def redirect(
self,
resp,
location,
*,
set_text=True,
status_code=status_codes.HTTP_301, # type: ignore[attr-defined]
self, resp, location, *, set_text=True, status_code=status_codes.HTTP_301
):
"""
Redirects a given response to a given location.
"""Redirects a given response to a given location.
:param resp: The Response to mutate.
:param location: The location of the redirect.
:param set_text: If ``True``, sets the Redirect body content automatically.
@@ -344,27 +281,6 @@ class API:
return decorator
def graphql(self, route="/graphql", *, schema):
"""Mount a GraphQL API at the given route.
Usage::
import graphene
class Query(graphene.ObjectType):
hello = graphene.String(name=graphene.String(default_value="stranger"))
def resolve_hello(self, info, name):
return f"Hello {name}"
api.graphql("/graphql", schema=graphene.Schema(query=Query))
:param route: The URL path for the GraphQL endpoint.
:param schema: A Graphene schema instance.
"""
from .ext.graphql import GraphQLView
self.add_route(route, GraphQLView(api=self, schema=schema))
def mount(self, route, app):
"""Mounts an WSGI / ASGI application at a given route.
@@ -375,19 +291,18 @@ class API:
self.router.apps.update({route: app})
def session(self, base_url="http://;"):
"""Testing HTTP client. Returns a Starlette TestClient instance,
"""Testing HTTP client. Returns a Requests session object,
able to send HTTP requests to the Responder application.
:param base_url: The base URL for the test client.
:param base_url: The URL to mount the connection adaptor to.
"""
if self._session is None:
from starlette.testclient import TestClient
self._session = TestClient(self, base_url=base_url)
return self._session
def url_for(self, endpoint, **params):
# TODO: Absolute_url
"""Given an endpoint, returns a rendered URL for its route.
:param endpoint: The route endpoint you're searching for.
@@ -396,29 +311,29 @@ class API:
return self.router.url_for(endpoint, **params)
def template(self, filename, *args, **kwargs):
r"""Render a Jinja2 template file with the provided values.
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template, with provided values supplied.
Note: The current ``api`` instance is by default passed into the view. This is set in the dict ``api.jinja_values_base``.
:param filename: The filename of the jinja2 template, in ``templates_dir``.
:param \*args: Data to pass into the template.
:param \*\*kwargs: Data to pass into the template.
"""
:param *args: Data to pass into the template.
:param *kwargs: Date to pass into the template.
""" # noqa: E501
return self.templates.render(filename, *args, **kwargs)
def template_string(self, source, *args, **kwargs):
r"""Render a Jinja2 template string with the provided values.
:param source: The template to use, a Jinja2 template string.
:param \*args: Data to pass into the template.
:param \*\*kwargs: Data to pass into the template.
"""
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template string, with provided values supplied.
Note: The current ``api`` instance is by default passed into the view. This is set in the dict ``api.jinja_values_base``.
:param source: The template to use.
:param *args: Data to pass into the template.
:param **kwargs: Data to pass into the template.
""" # noqa: E501
return self.templates.render_string(source, *args, **kwargs)
def serve(self, *, address=None, port=None, debug=False, **options):
"""
Run the application with uvicorn.
If the ``PORT`` environment variable is set, requests will be served on that port
automatically to all known hosts.
"""Runs the application with uvicorn. If the ``PORT`` environment
variable is set, requests will be served on that port automatically to all
known hosts.
:param address: The address to bind to.
:param port: The port to bind to. If none is provided, one will be selected at random.
@@ -435,10 +350,11 @@ class API:
address = "127.0.0.1"
if port is None:
port = 5042
if debug:
options["log_level"] = "debug"
uvicorn.run(self, host=address, port=port, **options)
def spawn():
uvicorn.run(self, host=address, port=port, debug=debug, **options)
spawn()
def run(self, **kwargs):
if "debug" not in kwargs:
+4 -3
View File
@@ -5,8 +5,6 @@ import traceback
from starlette.concurrency import run_in_threadpool
__all__ = ["BackgroundQueue"]
class BackgroundQueue:
def __init__(self, n=None):
@@ -18,6 +16,9 @@ class BackgroundQueue:
self.results = []
def run(self, f, *args, **kwargs):
self.pool._max_workers = self.n
self.pool._adjust_thread_count()
f = self.pool.submit(f, *args, **kwargs)
self.results.append(f)
return f
@@ -38,5 +39,5 @@ class BackgroundQueue:
async def __call__(self, func, *args, **kwargs) -> None:
if asyncio.iscoroutinefunction(func):
return await asyncio.create_task(func(*args, **kwargs))
return await asyncio.ensure_future(func(*args, **kwargs))
return await run_in_threadpool(func, *args, **kwargs)
-129
View File
@@ -1,129 +0,0 @@
"""
Responder CLI.
A web framework for Python.
Commands:
run Start the application server
build Build frontend assets using npm
Usage:
responder
responder run [--debug] [--limit-max-requests=] <target>
responder build [<target>]
responder --version
Options:
-h --help Show this screen.
-v --version Show version.
--debug Enable debug mode with verbose logging.
--limit-max-requests=<n> Maximum number of requests to handle before shutting down.
Arguments:
<target> For run: Python module specifier (e.g., "app:api" loads api from app.py)
Format: "module.submodule:variable_name" where variable_name is your API instance
For build: Directory containing package.json (default: current directory)
Examples:
responder run app:api # Run the 'api' instance from app.py
responder run myapp/core.py:application # Run the 'application' instance from myapp/core.py
responder build # Build frontend assets
""" # noqa: E501
import logging
import platform
import subprocess
import sys
import typing as t
from pathlib import Path
import docopt
from responder.__version__ import __version__
from responder.util.python import InvalidTarget, load_target
logger = logging.getLogger(__name__)
def cli() -> None:
"""
Main entry point for the Responder CLI.
Parses command line arguments and executes the appropriate command.
Supports running the application, building assets, and displaying version info.
"""
args = docopt.docopt(__doc__, argv=None, version=__version__, options_first=False)
setup_logging(args["--debug"])
target: t.Optional[str] = args["<target>"]
build: bool = args["build"]
debug: bool = args["--debug"]
run: bool = args["run"]
if build:
target_path = Path(target).resolve() if target else Path.cwd()
if not target_path.is_dir() or not (target_path / "package.json").exists():
logger.error(
f"Invalid target directory or missing package.json: {target_path}"
)
sys.exit(1)
npm_cmd = "npm.cmd" if platform.system() == "Windows" else "npm"
try:
logger.info("Starting frontend asset build")
# S603, S607 are addressed by validating the target directory.
subprocess.check_call( # noqa: S603, S607
[npm_cmd, "run", "build"],
cwd=target_path,
timeout=300,
)
logger.info("Frontend asset build completed successfully")
except FileNotFoundError:
logger.error("npm not found. Please install Node.js and npm.")
sys.exit(1)
except subprocess.CalledProcessError as e:
logger.error(f"Build failed with exit code {e.returncode}")
sys.exit(1)
if run:
if not target:
logger.error("Target argument is required for run command")
sys.exit(1)
# Maximum request limit. Terminating afterward. Suitable for software testing.
limit_max_requests = args["--limit-max-requests"]
if limit_max_requests is not None:
try:
limit_max_requests = int(limit_max_requests)
if limit_max_requests <= 0:
logger.error("limit-max-requests must be a positive integer")
sys.exit(1)
except ValueError:
logger.error("limit-max-requests must be a valid integer")
sys.exit(1)
# Load application from target.
try:
api = load_target(target=target)
except InvalidTarget as ex:
raise ValueError(
f"{ex}. "
"Use either a Python module entrypoint specification, "
"a filesystem path, or a remote URL. "
"See also https://responder.kennethreitz.org/cli.html."
) from ex
# Launch Responder API server (uvicorn).
api.run(debug=debug, limit_max_requests=limit_max_requests)
def setup_logging(debug: bool) -> None:
"""
Configure logging based on debug mode.
Args:
debug: When True, sets logging level to DEBUG; otherwise, sets to INFO
"""
log_level = logging.DEBUG if debug else logging.INFO
logging.basicConfig(
level=log_level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
-66
View File
@@ -1,66 +0,0 @@
import json
from .templates import GRAPHIQL
class GraphQLView:
def __init__(self, *, api, schema):
self.api = api
self.schema = schema
@staticmethod
async def _resolve_graphql_query(req, resp):
if "json" in req.mimetype:
json_media = await req.media("json")
if "query" not in json_media:
resp.status_code = 400
resp.media = {"errors": ["'query' key is required in the JSON payload"]}
return None, None, None
return (
json_media["query"],
json_media.get("variables"),
json_media.get("operationName"),
)
# Support query/q in params.
if "query" in req.params:
return req.params["query"], None, None
if "q" in req.params:
return req.params["q"], None, None
# Otherwise, the request text is used (typical).
return await req.text, None, None
async def graphql_response(self, req, resp):
show_graphiql = req.method == "get" and req.accepts("text/html")
if show_graphiql:
resp.content = self.api.templates.render_string(
GRAPHIQL, endpoint=req.url.path
)
return None
query, variables, operation_name = await self._resolve_graphql_query(req, resp)
if query is None:
return None
context = {"request": req, "response": resp}
result = self.schema.execute(
query, variables=variables, operation_name=operation_name, context=context
)
response_data = {}
if result.errors:
response_data["errors"] = [{"message": str(e)} for e in result.errors]
if result.data is not None:
response_data["data"] = result.data
resp.media = response_data
status_code = 200 if not result.errors else 400
return (query, json.dumps(response_data), status_code)
async def on_request(self, req, resp):
await self.graphql_response(req, resp)
async def __call__(self, req, resp):
await self.on_request(req, resp)
-34
View File
@@ -1,34 +0,0 @@
# ruff: noqa: E501
GRAPHIQL = """
{% set GRAPHIQL_VERSION = '3.0.6' %}
{% set REACT_VERSION = '18.2.0' %}
<!DOCTYPE html>
<html>
<head>
<style>
body {
height: 100%;
margin: 0;
width: 100%;
overflow: hidden;
}
#graphiql {
height: 100vh;
}
</style>
<link href="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.css" rel="stylesheet"/>
</head>
<body>
<div id="graphiql">Loading...</div>
<script crossorigin src="//cdn.jsdelivr.net/npm/react@{{ REACT_VERSION }}/umd/react.production.min.js"></script>
<script crossorigin src="//cdn.jsdelivr.net/npm/react-dom@{{ REACT_VERSION }}/umd/react-dom.production.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.js"></script>
<script>
const fetcher = GraphiQL.createFetcher({ url: '{{ endpoint }}' });
const root = ReactDOM.createRoot(document.getElementById('graphiql'));
root.render(React.createElement(GraphiQL, { fetcher: fetcher }));
</script>
</body>
</html>
""".strip()
@@ -133,6 +133,6 @@ class OpenAPISchema:
resp.html = self.docs
def schema_response(self, req, resp):
resp.status_code = status_codes.HTTP_200 # type: ignore[attr-defined]
resp.status_code = status_codes.HTTP_200
resp.headers["Content-Type"] = "application/x-yaml"
resp.content = self.openapi
@@ -18,6 +18,6 @@
</head>
<body>
<redoc spec-url="{{ schema_url }}"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@latest/bundles/redoc.standalone.js"></script>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"></script>
</body>
</html>
+13 -75
View File
@@ -1,80 +1,21 @@
from __future__ import annotations
import json
from urllib.parse import urlencode
import yaml
from python_multipart import MultipartParser
from requests_toolbelt.multipart import decoder
from .models import QueryDict
class _PartData:
__slots__ = ("headers", "body", "header_field")
def __init__(self):
self.headers: dict[str, str] = {}
self.body = b""
self.header_field = ""
def _parse_multipart(content: bytes, content_type: str) -> list[_PartData]:
"""Parse multipart form data into a list of parts with headers and body."""
boundary = None
for segment in content_type.split(";"):
segment = segment.strip()
if segment.startswith("boundary="):
boundary = segment.split("=", 1)[1].strip('"')
break
if boundary is None:
return []
parts: list[_PartData] = []
current: list[_PartData | None] = [None]
def on_part_begin():
current[0] = _PartData()
def on_part_data(data, start, end):
current[0].body += data[start:end] # type: ignore[union-attr]
def on_header_field(data, start, end):
current[0].header_field = data[start:end].decode("utf-8") # type: ignore[union-attr]
def on_header_value(data, start, end):
part = current[0]
assert part is not None
part.headers[part.header_field] = data[start:end].decode("utf-8")
def on_part_end():
parts.append(current[0]) # type: ignore[arg-type]
parser = MultipartParser(
boundary.encode(),
{ # type: ignore[arg-type]
"on_part_begin": on_part_begin,
"on_part_data": on_part_data,
"on_header_field": on_header_field,
"on_header_value": on_header_value,
"on_part_end": on_part_end,
},
)
parser.write(content)
parser.finalize()
return parts
async def format_form(r, encode=False):
if encode:
return None
if "multipart/form-data" in r.headers.get("Content-Type"):
parts = _parse_multipart(await r.content, r.mimetype)
decode = decoder.MultipartDecoder(await r.content, r.mimetype)
queries = []
for part in parts:
header = part.headers.get("Content-Disposition", "")
text = part.body.decode("utf-8")
for part in decode.parts:
header = part.headers.get(b"Content-Disposition").decode("utf-8")
text = part.text
for section in [h.strip() for h in header.split(";")]:
split = section.split("=")
@@ -105,19 +46,19 @@ async def format_json(r, encode=False):
async def format_files(r, encode=False):
if encode:
return None
parts = _parse_multipart(await r.content, r.mimetype)
decoded = decoder.MultipartDecoder(await r.content, r.mimetype)
dump = {}
for part in parts:
header = part.headers.get("Content-Disposition", "")
mimetype = part.headers.get("Content-Type", None)
for part in decoded.parts:
header = part.headers[b"Content-Disposition"].decode("utf-8")
mimetype = part.headers.get(b"Content-Type", None)
filename = None
formname = None
for section in [h.strip() for h in header.split(";")]:
split = section.split("=")
if len(split) > 1:
key = split[0]
value = split[1]
value = value[1:-1]
if key == "filename":
@@ -125,16 +66,13 @@ async def format_files(r, encode=False):
elif key == "name":
formname = value
if formname is None:
continue
if mimetype is None:
dump[formname] = part.body
dump[formname] = part.content
else:
dump[formname] = {
"filename": filename,
"content": part.body,
"content-type": mimetype,
"content": part.content,
"content-type": mimetype.decode("utf-8"),
}
return dump
+24 -110
View File
@@ -1,17 +1,13 @@
from __future__ import annotations
import functools
import inspect
from collections.abc import Callable
import typing as t
from http.cookies import SimpleCookie
from urllib.parse import parse_qs, urlparse
from urllib.parse import parse_qs
__all__ = ["Request", "Response", "QueryDict"]
try:
import chardet
except ImportError:
chardet = None # type: ignore[assignment]
import chardet
import rfc3986
from requests.cookies import RequestsCookieJar
from requests.structures import CaseInsensitiveDict
from starlette.requests import Request as StarletteRequest
from starlette.requests import State
from starlette.responses import (
@@ -22,30 +18,7 @@ from starlette.responses import (
)
from .statics import DEFAULT_ENCODING
from .status_codes import HTTP_301 # type: ignore[attr-defined]
class CaseInsensitiveDict(dict):
"""A case-insensitive dict for HTTP headers."""
def __setitem__(self, key, value):
super().__setitem__(key.lower(), value)
def __getitem__(self, key):
return super().__getitem__(key.lower())
def __contains__(self, key):
return super().__contains__(key.lower())
def get(self, key, default=None):
return super().get(key.lower(), default)
def update(self, other=None, **kwargs):
if other:
for key, value in other.items():
self[key] = value
for key, value in kwargs.items():
self[key] = value
from .status_codes import HTTP_301
class QueryDict(dict):
@@ -134,7 +107,7 @@ class Request:
self.api = api
self._content = None
headers: CaseInsensitiveDict = CaseInsensitiveDict()
headers = CaseInsensitiveDict()
for key, value in self._starlette.headers.items():
headers[key] = value
@@ -155,11 +128,6 @@ class Request:
def mimetype(self):
return self.headers.get("Content-Type", "")
@property
def is_json(self):
"""Returns ``True`` if the request content type is JSON."""
return "json" in self.mimetype
@property
def method(self):
"""The incoming HTTP method used for the request, lower-cased."""
@@ -173,20 +141,20 @@ class Request:
@property
def url(self):
"""The parsed URL of the Request."""
return urlparse(self.full_url)
return rfc3986.urlparse(self.full_url)
@property
def cookies(self):
"""The cookies sent in the Request, as a dictionary."""
if self._cookies is None:
cookies = {}
cookies = RequestsCookieJar()
cookie_header = self.headers.get("Cookie", "")
bc: SimpleCookie = SimpleCookie(cookie_header)
bc = SimpleCookie(cookie_header)
for key, morsel in bc.items():
cookies[key] = morsel.value
self._cookies = cookies
self._cookies = cookies.get_dict()
return self._cookies
@@ -198,16 +166,6 @@ class Request:
except AttributeError:
return QueryDict({})
@property
def path_params(self) -> dict:
"""The path parameters extracted from the URL route."""
return self._starlette.path_params
@property
def client(self):
"""The client's address as a (host, port) named tuple, or None."""
return self._starlette.client
@property
def state(self) -> State:
"""
@@ -254,19 +212,13 @@ class Request:
@property
async def apparent_encoding(self):
"""The apparent encoding, detected automatically. Must be awaited.
Uses chardet for detection if installed, otherwise falls back to UTF-8.
"""
"""The apparent encoding, provided by the chardet library. Must be awaited."""
declared_encoding = await self.declared_encoding
if declared_encoding:
return declared_encoding
if chardet is not None:
return chardet.detect(await self.content)["encoding"] or DEFAULT_ENCODING
return DEFAULT_ENCODING
return chardet.detect(await self.content)["encoding"] or DEFAULT_ENCODING
@property
def is_secure(self):
@@ -276,7 +228,7 @@ class Request:
"""Returns ``True`` if the incoming Request accepts the given ``content_type``."""
return content_type in self.headers.get("Accept", [])
async def media(self, format: str | Callable = None): # noqa: A002
async def media(self, format: t.Union[str, t.Callable] = None): # noqa: A001, A002
"""Renders incoming json/yaml/form data as Python objects. Must be awaited.
:param format: The name of the format being used.
@@ -287,20 +239,9 @@ class Request:
format = "yaml" if "yaml" in self.mimetype or "" else "json" # noqa: A001
format = "form" if "form" in self.mimetype or "" else format # noqa: A001
formatter: Callable
if isinstance(format, str):
try:
formatter = self.formats[format]
except KeyError as ex:
raise ValueError(f"Unable to process data in '{format}' format") from ex
elif callable(format):
formatter = format
else:
raise TypeError(f"Invalid 'format' argument: {format}")
return await formatter(self)
if format in self.formats:
return await self.formats[format](self)
return await format(self)
def content_setter(mimetype):
@@ -334,8 +275,7 @@ class Response:
def __init__(self, req, *, formats):
self.req = req
#: The HTTP Status Code to use for the Response.
self.status_code: int | None = None
self.status_code = None #: The HTTP Status Code to use for the Response.
self.content = None #: A bytes representation of the response body.
self.mimetype = None
self.encoding = DEFAULT_ENCODING
@@ -345,11 +285,12 @@ class Response:
self.headers = {} #: A Python dictionary of ``{key: value}``,
#: representing the headers of the response.
self.formats = formats
self.cookies: SimpleCookie = SimpleCookie() #: The cookies set in the Response
self.cookies = SimpleCookie() #: The cookies set in the Response
self.session = (
req.session
) #: The cookie-based session data, in dict form, to add to the Response.
# Property or func/dec
def stream(self, func, *args, **kwargs):
assert inspect.isasyncgenfunction(func)
@@ -357,25 +298,6 @@ class Response:
return func
def file(self, path, *, content_type=None):
"""Serve a file from disk as the response.
:param path: Path to the file to serve.
:param content_type: Optional MIME type override.
"""
from pathlib import Path
path = Path(path)
self.content = path.read_bytes()
if content_type:
self.mimetype = content_type
else:
import mimetypes
guessed = mimetypes.guess_type(str(path))[0]
self.mimetype = guessed or "application/octet-stream"
def redirect(self, location, *, set_text=True, status_code=HTTP_301):
self.status_code = status_code
if set_text:
@@ -394,8 +316,7 @@ class Response:
headers["Content-Type"] = self.mimetype
if self.mimetype == "text/plain" and self.encoding is not None:
headers["Encoding"] = self.encoding
if isinstance(content, str):
content = content.encode(self.encoding)
content = content.encode(self.encoding)
return (content, headers)
for format_ in self.formats:
@@ -444,23 +365,16 @@ class Response:
if self.headers:
headers.update(self.headers)
response_cls: type[StarletteResponse] | type[StarletteStreamingResponse]
if self._stream is not None:
response_cls = StarletteStreamingResponse
else:
response_cls = StarletteResponse
response = response_cls(body, status_code=self.status_code_safe, headers=headers)
response = response_cls(body, status_code=self.status_code, headers=headers)
self._prepare_cookies(response)
await response(scope, receive, send)
@property
def ok(self):
return 200 <= self.status_code_safe < 300
@property
def status_code_safe(self) -> int:
if self.status_code is None:
raise RuntimeError("HTTP status code has not been defined")
return self.status_code
return 200 <= self.status_code < 300
View File
+34 -72
View File
@@ -4,25 +4,19 @@ import re
import traceback
from collections import defaultdict
__all__ = ["Route", "WebSocketRoute", "Router"]
from starlette.concurrency import run_in_threadpool
from starlette.exceptions import HTTPException
from starlette.types import ASGIApp
from starlette.middleware.wsgi import WSGIMiddleware
from starlette.websockets import WebSocket, WebSocketClose
from . import status_codes
from .formats import get_formats
from .models import Request, Response
_UUID_RE = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
_CONVERTORS = {
"int": (int, r"\d+"),
"str": (str, r"[^/]+"),
"float": (float, r"\d+(.\d+)?"),
"path": (str, r".+"),
"uuid": (str, _UUID_RE),
}
PARAM_RE = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}")
@@ -36,9 +30,9 @@ def compile_path(path):
for match in PARAM_RE.finditer(path):
param_name, convertor_type = match.groups(default="str")
convertor_type = convertor_type.lstrip(":")
assert convertor_type in _CONVERTORS.keys(), (
f"Unknown path convertor '{convertor_type}'"
)
assert (
convertor_type in _CONVERTORS.keys()
), f"Unknown path convertor '{convertor_type}'"
convertor, convertor_re = _CONVERTORS[convertor_type]
path_re += path[idx : match.start()]
@@ -62,22 +56,19 @@ class BaseRoute:
class Route(BaseRoute):
def __init__(self, route, endpoint, *, before_request=False, methods=None):
def __init__(self, route, endpoint, *, before_request=False):
assert route.startswith("/"), "Route path must start with '/'"
self.route = route
self.endpoint = endpoint
self.before_request = before_request
self.methods = {m.upper() for m in methods} if methods else None
self.path_re, self.param_convertors = compile_path(route)
# Strip type annotations for URL generation (e.g. {id:int} -> {id})
self._url_template = PARAM_RE.sub(r"{\1}", route)
def __repr__(self):
return f"<Route {self.route!r}={self.endpoint!r}>"
def url(self, **params):
return self._url_template.format(**params)
return self.route.format(**params)
@property
def endpoint_name(self):
@@ -91,9 +82,6 @@ class Route(BaseRoute):
if scope["type"] != "http":
return False, {}
if self.methods and scope.get("method", "").upper() not in self.methods:
return False, {}
path = scope["path"]
match = self.path_re.match(path)
@@ -118,10 +106,6 @@ class Route(BaseRoute):
await before_request(request, response)
else:
await run_in_threadpool(before_request, request, response)
# If a before_request hook set a status code, short-circuit
if response.status_code is not None:
await response(scope, receive, send)
return
views = []
@@ -137,12 +121,12 @@ class Route(BaseRoute):
views.append(view)
except AttributeError as ex:
if on_request is None:
raise HTTPException(status_code=status_codes.HTTP_405) from ex # type: ignore[attr-defined]
raise HTTPException(status_code=status_codes.HTTP_405) from ex
else:
views.append(self.endpoint)
for view in views:
# Check __call__ for class-based views (e.g. GraphQL)
# "Monckey patch" for graphql: explicitly checking __call__
if asyncio.iscoroutinefunction(view) or asyncio.iscoroutinefunction(
view.__call__
):
@@ -151,11 +135,12 @@ class Route(BaseRoute):
await run_in_threadpool(view, request, response, **path_params)
if response.status_code is None:
response.status_code = status_codes.HTTP_200 # type: ignore[attr-defined]
response.status_code = status_codes.HTTP_200
await response(scope, receive, send)
def __eq__(self, other):
# [TODO] compare to str ?
return self.route == other.route and self.endpoint == other.endpoint
def __hash__(self):
@@ -170,13 +155,12 @@ class WebSocketRoute(BaseRoute):
self.before_request = before_request
self.path_re, self.param_convertors = compile_path(route)
self._url_template = PARAM_RE.sub(r"{\1}", route)
def __repr__(self):
return f"<Route {self.route!r}={self.endpoint!r}>"
def url(self, **params):
return self._url_template.format(**params)
return self.route.format(**params)
@property
def endpoint_name(self):
@@ -212,6 +196,7 @@ class WebSocketRoute(BaseRoute):
await self.endpoint(ws)
def __eq__(self, other):
# [TODO] compare to str ?
return self.route == other.route and self.endpoint == other.endpoint
def __hash__(self):
@@ -219,12 +204,10 @@ class WebSocketRoute(BaseRoute):
class Router:
def __init__(
self, routes=None, default_response=None, before_requests=None, lifespan=None
):
def __init__(self, routes=None, default_response=None, before_requests=None):
self.routes = [] if routes is None else list(routes)
self.apps: dict[str, ASGIApp] = {}
# [TODO] Make its own router
self.apps = {}
self.default_endpoint = (
self.default_response if default_response is None else default_response
)
@@ -232,7 +215,6 @@ class Router:
{"http": [], "ws": []} if before_requests is None else before_requests
)
self.events = defaultdict(list)
self._lifespan_handler = lifespan
def add_route(
self,
@@ -243,13 +225,11 @@ class Router:
websocket=False,
before_request=False,
check_existing=False,
methods=None,
):
"""Adds a route to the router.
:param route: A string representation of the route
:param endpoint: The endpoint for the route -- can be callable, or class.
:param default: If ``True``, all unknown requests will route to this view.
:param methods: Optional list of HTTP methods (e.g. ["GET", "POST"]).
"""
if before_request:
if websocket:
@@ -259,9 +239,9 @@ class Router:
return
if check_existing:
assert not self.routes or route not in (item.route for item in self.routes), (
f"Route '{route}' already exists"
)
assert not self.routes or route not in (
item.route for item in self.routes
), f"Route '{route}' already exists"
if default:
self.default_endpoint = endpoint
@@ -269,13 +249,13 @@ class Router:
if websocket:
route = WebSocketRoute(route, endpoint)
else:
route = Route(route, endpoint, methods=methods)
route = Route(route, endpoint)
self.routes.append(route)
def mount(self, route, app):
"""Mounts ASGI / WSGI applications at a given route"""
self.apps.update({route: app})
self.apps.update(route, app)
def add_event_handler(self, event_type, handler):
assert event_type in (
@@ -298,6 +278,7 @@ class Router:
self.before_requests.setdefault("http", []).append(endpoint)
def url_for(self, endpoint, **params):
# TODO: Check for params
for route in self.routes:
if endpoint in (route.endpoint, route.endpoint.__name__):
return route.url(**params)
@@ -306,13 +287,14 @@ class Router:
async def default_response(self, scope, receive, send):
if scope["type"] == "websocket":
websocket_close = WebSocketClose()
await websocket_close(scope, receive, send)
await websocket_close(receive, send)
return
# FIXME: Please review!
request = Request(scope, receive)
response = Response(request, formats=get_formats()) # noqa: F841
raise HTTPException(status_code=status_codes.HTTP_404) # type: ignore[attr-defined]
raise HTTPException(status_code=status_codes.HTTP_404)
def _resolve_route(self, scope):
for route in self.routes:
@@ -326,35 +308,17 @@ class Router:
message = await receive()
assert message["type"] == "lifespan.startup"
if self._lifespan_handler is not None:
# Modern lifespan context manager pattern
try:
ctx = self._lifespan_handler(scope.get("app"))
await ctx.__aenter__()
except BaseException:
msg = traceback.format_exc()
await send({"type": "lifespan.startup.failed", "message": msg})
raise
await send({"type": "lifespan.startup.complete"})
message = await receive()
assert message["type"] == "lifespan.shutdown"
await ctx.__aexit__(None, None, None)
else:
# Legacy on_event("startup") / on_event("shutdown") pattern
try:
await self.trigger_event("startup")
except BaseException:
msg = traceback.format_exc()
await send({"type": "lifespan.startup.failed", "message": msg})
raise
await send({"type": "lifespan.startup.complete"})
message = await receive()
assert message["type"] == "lifespan.shutdown"
await self.trigger_event("shutdown")
try:
await self.trigger_event("startup")
except BaseException:
msg = traceback.format_exc()
await send({"type": "lifespan.startup.failed", "message": msg})
raise
await send({"type": "lifespan.startup.complete"})
message = await receive()
assert message["type"] == "lifespan.shutdown"
await self.trigger_event("shutdown")
await send({"type": "lifespan.shutdown.complete"})
async def __call__(self, scope, receive, send):
@@ -385,8 +349,6 @@ class Router:
await app(scope, receive, send)
return
except TypeError:
from a2wsgi import WSGIMiddleware
app = WSGIMiddleware(app)
await app(scope, receive, send)
return
+13 -1
View File
@@ -2,7 +2,19 @@ from starlette.staticfiles import StaticFiles as StarletteStaticFiles
class StaticFiles(StarletteStaticFiles):
"""Extension to Starlette's StaticFiles with support for multiple directories."""
"""
Extension to Starlette's `StaticFiles`.
I've created an issue to discuss allowing multiple directories in
Starlette's `StaticFiles`.
https://github.com/encode/starlette/issues/625
I've also made a PR to add this method to Starlette StaticFiles
Once accepted we will remove this.
https://github.com/encode/starlette/pull/626
"""
def add_directory(self, directory: str) -> None:
self.all_directories = [*self.all_directories, *self.get_directories(directory)]
+7 -1
View File
@@ -1,3 +1,5 @@
# from: https://github.com/requests/requests/blob/master/requests/status_codes.py
codes = {
# Informational.
100: ("continue",),
@@ -24,7 +26,11 @@ codes = {
305: ("use_proxy",),
306: ("switch_proxy",),
307: ("temporary_redirect", "temporary_moved", "temporary"),
308: ("permanent_redirect",),
308: (
"permanent_redirect",
"resume_incomplete",
"resume",
), # These 2 to be removed in 3.0
# Client Error.
400: ("bad_request", "bad"),
401: ("unauthorized",),
-2
View File
@@ -2,8 +2,6 @@ from contextlib import contextmanager
import jinja2
__all__ = ["Templates"]
class Templates:
def __init__(
View File
-242
View File
@@ -1,242 +0,0 @@
# ruff: noqa: S603 # Subprocess call - output not captured
# ruff: noqa: S607 # Starting a process with a partial executable path
# Security considerations for subprocess usage:
# 1. Only execute the 'responder' binary from PATH
# 2. Validate all user inputs before passing to subprocess
# 3. Use Path.resolve() to prevent path traversal
import functools
import logging
import os
import shutil
import signal
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path
logger = logging.getLogger(__name__)
class ResponderProgram:
"""
Utility class for managing Responder program execution.
This class provides methods for:
- Locating the responder executable in PATH
- Building frontend assets using npm
Example:
>>> program_path = ResponderProgram.path()
>>> build_status = ResponderProgram.build(Path("app_dir"))
"""
@staticmethod
@functools.lru_cache(maxsize=None)
def path():
name = "responder"
if sys.platform == "win32":
name = "responder.exe"
program = shutil.which(name)
if program is None:
paths = os.environ.get("PATH", "").split(os.pathsep)
raise RuntimeError(
f"Could not find '{name}' executable in PATH. "
f"Please install Responder with 'pip install --upgrade responder'. "
f"Searched in: {', '.join(paths)}"
)
logger.debug(f"Found responder program: {program}")
return program
@classmethod
def build(cls, path: Path) -> int:
"""
Invoke `responder build` command.
Args:
path: Path to the application to build
Returns:
int: The return code from the build process
Raises:
ValueError: If the path is invalid
RuntimeError: If the responder executable is not found
subprocess.SubprocessError: If the build process fails
"""
if not isinstance(path, Path):
raise ValueError(f"Expected a Path object, got {type(path).__name__}")
if not path.exists():
raise ValueError(f"Path does not exist: {path}")
if not path.is_dir():
raise FileNotFoundError(f"Path is not a directory: {path}")
command = [
cls.path(),
"build",
str(path),
]
return subprocess.call(command)
class ResponderServer(threading.Thread):
"""
A threaded wrapper around the `responder run` command for testing purposes.
This class allows running a Responder application in a separate thread,
making it suitable for integration testing scenarios.
Args:
target (str): The path to the Responder application to run
port (int, optional): The port to run the server on. Defaults to 5042.
limit_max_requests (int, optional): Maximum number of requests to handle
before shutting down. Useful for testing scenarios.
Example:
>>> server = ResponderServer("app.py", port=8000)
>>> server.start()
>>> # Run tests
>>> server.stop()
"""
def __init__(self, target: str, port: int = 5042, limit_max_requests: int = None):
super().__init__()
self._stopping = False
# Validate input variables.
if not target or not isinstance(target, str):
raise ValueError("Target must be a non-empty string")
if not isinstance(port, int) or port < 1:
raise ValueError("Port must be a positive integer")
if limit_max_requests is not None and (
not isinstance(limit_max_requests, int) or limit_max_requests < 1
):
raise ValueError("limit_max_requests must be a positive integer if specified")
# Check if port is available.
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("localhost", port))
except OSError as ex:
raise ValueError(f"Port {port} is already in use") from ex
# Instance variables after validation.
self.target = target
self.port = port
self.limit_max_requests = limit_max_requests
self.shutdown_timeout = 5 # seconds
# Allow the thread to be terminated when the main program exits.
self.process: subprocess.Popen
self.daemon = True
self._process_lock = threading.Lock()
# Setup signal handlers.
signal.signal(signal.SIGTERM, self._signal_handler)
signal.signal(signal.SIGINT, self._signal_handler)
def run(self):
command = [
ResponderProgram.path(),
"run",
self.target,
]
if self.limit_max_requests is not None:
command += [f"--limit-max-requests={self.limit_max_requests}"]
# Preserve existing environment
env = os.environ.copy()
if self.port is not None:
env["PORT"] = str(self.port)
with self._process_lock:
self.process = subprocess.Popen(
command,
env=env,
universal_newlines=True,
)
self.process.wait()
def stop(self):
"""
Gracefully stop the process (API).
"""
if self._stopping:
return
with self._process_lock:
self._stop()
def _stop(self):
"""
Gracefully stop the process (impl).
"""
self._stopping = True
if self.process and self.process.poll() is None:
logger.info("Attempting to terminate server process...")
self.process.terminate()
try:
# Wait for graceful shutdown.
self.process.wait(timeout=self.shutdown_timeout)
logger.info("Server process terminated gracefully")
except subprocess.TimeoutExpired:
logger.warning(
"Server process did not terminate gracefully, forcing kill"
)
self.process.kill() # Force kill if not terminated
def _signal_handler(self, signum, frame):
"""
Handle termination signals gracefully.
"""
logger.info("Received signal %d, shutting down...", signum)
self.stop()
def wait_until_ready(self, timeout=30, request_timeout=1, delay=0.1) -> bool:
"""
Wait until the server is ready to accept connections.
Args:
timeout (int, optional): Maximum time to wait in seconds. Defaults to 30.
Returns:
bool: True if server is ready and accepting connections, False otherwise.
"""
start_time = time.time()
last_error = None
while time.time() - start_time < timeout:
if not self.is_running():
if self.process is None:
logger.error("Server process was never started")
else:
returncode = self.process.poll()
logger.error("Server process exited with code: %d", returncode)
return False
try:
with socket.create_connection(
("localhost", self.port), timeout=request_timeout
):
return True
except (
socket.timeout,
ConnectionRefusedError,
socket.gaierror,
OSError,
) as ex:
last_error = ex
logger.debug(f"Server not ready yet: {ex}")
time.sleep(delay)
logger.error(
"Server failed to start within %d seconds. Last error: %s",
timeout,
last_error,
)
return False
def is_running(self):
"""
Check if the server process is still running.
"""
return self.process is not None and self.process.poll() is None
-44
View File
@@ -1,44 +0,0 @@
import logging
import typing as t
from pueblo.sfa.core import InvalidTarget, SingleFileApplication
__all__ = [
"InvalidTarget",
"SingleFileApplication",
"load_target",
]
logger = logging.getLogger(__name__)
def load_target(target: str, default_property: str = "api", method: str = "run") -> t.Any:
"""
Load Python code from a file path or module name.
Warning:
This function executes arbitrary Python code. Ensure the target is from a trusted
source to prevent security vulnerabilities.
Args:
target: Module address (e.g., 'acme.app:foo'), file path (e.g., '/path/to/acme/app.py'),
or URL.
default_property: Name of the property to load if not specified in target (default: "api")
method: Name of the method to invoke on the API instance (default: "run")
Returns:
The API instance, loaded from the given property.
Raises:
ValueError: If target format is invalid
ImportError: If module cannot be imported
AttributeError: If property or method is not found
Example:
>>> api = load_target("myapp.api:server")
>>> api.run()
""" # noqa: E501
app = SingleFileApplication.from_spec(spec=target, default_property=default_property)
app.load()
return app.entrypoint
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import codecs
import os
from setuptools import find_packages, setup
from versioningit import get_cmdclasses
here = os.path.abspath(os.path.dirname(__file__))
with codecs.open(os.path.join(here, "README.md"), encoding="utf-8") as f:
long_description = "\n" + f.read()
required = [
"aiofiles",
"apispec>=1.0.0b1",
"chardet",
"docopt-ng",
"marshmallow",
"requests",
"requests-toolbelt",
"rfc3986",
"starlette[full]",
"uvicorn[standard]",
"whitenoise",
]
setup(
name="responder",
description="A familiar HTTP Service Framework for Python.",
long_description=long_description,
long_description_content_type="text/markdown",
author="Kenneth Reitz",
author_email="me@kennethreitz.org",
url="https://github.com/kennethreitz/responder",
packages=find_packages(exclude=["tests"]),
package_data={},
python_requires=">=3.10",
setup_requires=[],
install_requires=required,
extras_require={
"develop": ["poethepoet", "pyproject-fmt", "ruff", "validate-pyproject"],
"graphql": ["graphene"],
"release": ["build", "twine"],
"test": ["pytest", "pytest-cov", "pytest-mock", "flask"],
},
include_package_data=True,
license="Apache 2.0",
classifiers=[
"Development Status :: 5 - Production/Stable",
"Environment :: Web Environment",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Internet :: WWW/HTTP",
],
cmdclass=get_cmdclasses(),
)
+17 -15
View File
@@ -1,8 +1,20 @@
from pathlib import Path
import pytest
import responder
@pytest.fixture
def data_dir(current_dir):
yield current_dir / "data"
@pytest.fixture()
def current_dir():
yield Path(__file__).parent
@pytest.fixture
def api():
return responder.API(debug=False, allowed_hosts=[";"])
@@ -35,19 +47,9 @@ def flask():
@pytest.fixture
def template_path(tmp_path):
template_dir = tmp_path / "static"
template_dir.mkdir()
template_file = template_dir / "test.html"
template_file.write_text("{{ var }}")
def template_path(tmpdir):
# create a Jinja template file on the filesystem
template_name = "test.html"
template_file = tmpdir.mkdir("static").join(template_name)
template_file.write("{{ var }}")
return template_file
@pytest.fixture
def needs_openapi() -> None:
try:
import apispec
_ = apispec.APISpec
except ImportError as ex:
raise pytest.skip("apispec package not installed") from ex
-235
View File
@@ -1,235 +0,0 @@
"""
Test module for Responder CLI functionality.
This module tests the following CLI commands:
- responder --version: Version display
- responder build: Build command execution
- responder run: Server execution
Requirements:
- The `docopt-ng` package must be installed
- Example application must be present at `examples/helloworld.py`
- This file should implement a basic HTTP server with a "/hello" endpoint
that returns "hello, world!" as response
"""
import json
import os
import subprocess
import time
import typing as t
from pathlib import Path
from urllib.request import urlopen
import pytest
from _pytest.capture import CaptureFixture
from responder.__version__ import __version__
from responder.util.cmd import ResponderProgram, ResponderServer
from tests.util import random_port, wait_server_tcp
# Skip test if optional CLI dependency is not installed.
pytest.importorskip("docopt", reason="docopt-ng package not installed")
# Pseudo-wait for server idleness
SERVER_IDLE_WAIT = float(os.getenv("RESPONDER_SERVER_IDLE_WAIT", "0.25"))
# Maximum time to wait for server startup or teardown (adjust for slower systems)
SERVER_TIMEOUT = float(os.getenv("RESPONDER_SERVER_TIMEOUT", "5"))
# Maximum time to wait for HTTP requests (adjust for slower networks)
REQUEST_TIMEOUT = float(os.getenv("RESPONDER_REQUEST_TIMEOUT", "5"))
# Endpoint to use for `responder run`.
HELLO_ENDPOINT = "/hello"
def test_cli_version(capfd):
"""
Verify that `responder --version` works as expected.
"""
try:
# Suppress security checks for subprocess calls in tests.
# S603: subprocess call - safe as we use fixed command
# S607: start process with partial path - safe as we use installed package
subprocess.check_call(["responder", "--version"]) # noqa: S603, S607
except subprocess.CalledProcessError as ex:
pytest.fail(
f"responder --version failed with exit code {ex.returncode}. Error: {ex}"
)
stdout = capfd.readouterr().out.strip()
assert stdout == __version__
def responder_build(path: Path, capfd: CaptureFixture) -> t.Tuple[str, str]:
"""
Execute responder build command and capture its output.
Args:
path: Directory containing package.json
capfd: Pytest fixture for capturing output
Returns:
tuple: (stdout, stderr) containing the captured output
"""
ResponderProgram.build(path=path)
output = capfd.readouterr()
stdout = output.out.strip()
stderr = output.err.strip()
return stdout, stderr
def test_cli_build_success(capfd, tmp_path):
"""
Verify that `responder build` works as expected.
"""
# Temporary surrogate `package.json` file.
package_json = {"scripts": {"build": "echo Hotzenplotz"}}
package_json_file = tmp_path / "package.json"
package_json_file.write_text(json.dumps(package_json))
# Invoke `responder build`.
stdout, stderr = responder_build(tmp_path, capfd)
assert "Hotzenplotz" in stdout
def test_cli_build_missing_package_json(capfd, tmp_path):
"""
Verify `responder build`, while `package.json` file is missing.
"""
# Invoke `responder build`.
stdout, stderr = responder_build(tmp_path, capfd)
assert "Invalid target directory or missing package.json" in stderr
@pytest.mark.parametrize(
"invalid_content,npm_error,expected_error",
[
(
"foobar",
"code EJSONPARSE",
["is not valid JSON", "Failed to parse JSON data", "EJSONPARSE"],
),
("{", "code EJSONPARSE", ["Unexpected end of JSON", "EJSONPARSE"]),
('{"scripts": }', "code EJSONPARSE", ["Unexpected token", "EJSONPARSE"]),
(
'{"scripts": null}',
"error",
[
"Cannot convert undefined or null",
"scripts.build",
"Missing script",
"null",
],
),
(
'{"scripts": {"build": null}}',
"Missing script",
['"build"', "missing script", "build"],
),
(
'{"scripts": {"build": 123}}',
"Missing script",
['"build"', "missing script", "build"],
),
],
ids=[
"invalid_json_content",
"incomplete_json",
"syntax_error",
"null_scripts",
"missing_script_null",
"missing_script_number",
],
)
def test_cli_build_invalid_package_json(
capfd, tmp_path, invalid_content, npm_error, expected_error
):
"""
Verify `responder build` using an invalid `package.json` file.
"""
# Temporary surrogate `package.json` file.
package_json_file = tmp_path / "package.json"
package_json_file.write_text(invalid_content)
# Invoke `responder build`.
stdout, stderr = responder_build(tmp_path, capfd)
assert npm_error.lower() in stderr.lower()
if isinstance(expected_error, str):
expected_error = [expected_error]
assert any(item.lower() in stderr.lower() for item in expected_error)
sfa_services_valid = [
str(Path("examples") / "helloworld.py"),
"https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py",
]
# The test is marked as flaky due to potential race conditions in server startup
# and port availability. Known error codes by platform:
# - macOS: [Errno 61] Connection refused (Failed to establish a new connection)
# - Linux: [Errno 111] Connection refused (Failed to establish a new connection)
# - Windows: [WinError 10061] No connection could be made because target machine
# actively refused it
@pytest.mark.flaky(reruns=3, reruns_delay=2, only_rerun=["TimeoutError"])
@pytest.mark.parametrize("target", sfa_services_valid, ids=sfa_services_valid)
def test_cli_run(capfd, target):
"""
Verify that `responder run` works as expected.
"""
# Start a Responder service instance in the background, using its CLI.
# Make it terminate itself after serving one HTTP request.
server = ResponderServer(target=str(target), port=random_port(), limit_max_requests=1)
try:
# Start server and wait until it responds on TCP.
server.start()
wait_server_tcp(server.port)
# Submit a single probing HTTP request that also will terminate the server.
with urlopen( # noqa: S310
f"http://127.0.0.1:{server.port}{HELLO_ENDPOINT}",
timeout=REQUEST_TIMEOUT,
) as response:
assert "hello, world!" == response.read().decode()
finally:
server.join(timeout=SERVER_TIMEOUT)
# Capture process output.
time.sleep(SERVER_IDLE_WAIT)
output = capfd.readouterr()
stdout = output.out.strip()
assert f'"GET {HELLO_ENDPOINT} HTTP/1.1" 200 OK' in stdout
stderr = output.err.strip()
# Define expected lifecycle messages in order.
lifecycle_messages = [
# Startup phase
"Started server process",
"Waiting for application startup",
"Application startup complete",
"Uvicorn running",
# Shutdown phase
"Shutting down",
"Waiting for application shutdown",
"Application shutdown complete",
"Finished server process",
]
# Verify messages appear in expected order.
last_pos = -1
for msg in lifecycle_messages:
pos = stderr.find(msg)
assert pos > last_pos, f"Expected '{msg}' to appear after previous message"
last_pos = pos
-607
View File
@@ -1,607 +0,0 @@
"""Tests targeting specific uncovered code paths for coverage."""
import time
import pytest
from starlette.testclient import TestClient as StarletteTestClient
import responder
from responder.background import BackgroundQueue
from responder.models import CaseInsensitiveDict, QueryDict, Response
from responder.routes import Route, WebSocketRoute
from responder.templates import Templates
# --- api.py coverage ---
def test_sync_exception_handler():
"""Line 177: sync (non-async) exception handler."""
api = responder.API(allowed_hosts=[";"])
@api.exception_handler(TypeError)
def handle_type_error(req, resp, exc):
resp.status_code = 422
resp.media = {"error": str(exc)}
@api.route("/")
def view(req, resp):
raise TypeError("bad type")
client = StarletteTestClient(api, base_url="http://;", raise_server_exceptions=False)
r = client.get(api.url_for(view))
assert r.status_code == 422
assert r.json() == {"error": "bad type"}
def test_exception_handler_no_status_code():
"""Line 179: exception handler that doesn't set status_code defaults to 500."""
api = responder.API(allowed_hosts=[";"])
@api.exception_handler(RuntimeError)
async def handle(req, resp, exc):
resp.media = {"error": str(exc)}
# deliberately not setting resp.status_code
@api.route("/")
def view(req, resp):
raise RuntimeError("oops")
client = StarletteTestClient(api, base_url="http://;", raise_server_exceptions=False)
r = client.get(api.url_for(view))
assert r.status_code == 500
def test_static_response_no_index(tmp_path):
"""Lines 277-278: static route with no index.html returns 404."""
static_dir = tmp_path / "static"
static_dir.mkdir()
# No index.html created
api = responder.API(static_dir=str(static_dir), allowed_hosts=[";"])
api.add_route("/", static=True)
r = api.requests.get("http://;/")
assert r.status_code == 404
assert "Not found" in r.text
# --- background.py coverage ---
def test_background_task_exception(capsys):
"""Lines 27-30: background task that raises prints traceback."""
bg = BackgroundQueue(n=1)
@bg.task
def failing_task():
raise ValueError("task failed")
future = failing_task()
future.result # wait for completion
time.sleep(0.2) # let the done callback fire
captured = capsys.readouterr()
assert "ValueError" in captured.err or True # traceback goes to stderr
def test_background_run():
"""Lines 25-28: BackgroundQueue.run submits work."""
bg = BackgroundQueue(n=1)
result = bg.run(lambda: 42)
assert result.result(timeout=5) == 42
assert len(bg.results) == 1
# --- formats.py coverage ---
def test_form_uploads_without_multipart(api):
"""Line 71: form format with non-multipart content type."""
@api.route("/")
async def route(req, resp):
data = await req.media("form")
resp.media = dict(data)
r = api.requests.post(
api.url_for(route),
content="name=hello&value=world",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert r.json() == {"name": "world", "value": "world"} or r.status_code < 500
# --- models.py coverage ---
def test_query_dict_empty_value():
"""Lines 63-64, 75-77: QueryDict with empty value returns default."""
d = QueryDict("key=value&empty=")
assert d["key"] == "value"
assert d.get("missing") is None
assert d.get("missing", "default") == "default"
def test_request_params_no_query(api):
"""Lines 198-199: request.params without query string."""
@api.route("/")
def view(req, resp):
resp.media = {"params": dict(req.params)}
r = api.requests.get(api.url_for(view))
assert r.json() == {"params": {}}
def test_request_state(api):
"""Line 222: request.state for middleware data."""
@api.route("/")
def view(req, resp):
req.state.custom = "hello"
resp.media = {"state": req.state.custom}
r = api.requests.get(api.url_for(view))
assert r.json() == {"state": "hello"}
def test_request_client(api):
"""Line 209: request.client address."""
@api.route("/")
def view(req, resp):
client = req.client
resp.media = {"has_client": client is not None}
r = api.requests.get(api.url_for(view))
assert r.json()["has_client"] is True
def test_request_declared_encoding(api):
"""Lines 252, 264: declared encoding from Encoding header."""
@api.route("/")
async def view(req, resp):
encoding = await req.apparent_encoding
resp.text = encoding
r = api.requests.post(
api.url_for(view),
content=b"hello",
headers={"Encoding": "iso-8859-1"},
)
assert r.text == "iso-8859-1"
def test_response_media_json_default(api):
"""Lines 294-301: resp.media defaults to JSON encoding."""
@api.route("/")
def view(req, resp):
resp.media = {"key": "value"}
# No Accept header — should default to JSON
r = api.requests.get(api.url_for(view))
assert r.json() == {"key": "value"}
assert "application/json" in r.headers.get("content-type", "")
def test_response_stream(api):
"""Line 308: streaming response."""
@api.route("/")
async def view(req, resp):
@resp.stream
async def stream_content():
yield b"chunk1"
yield b"chunk2"
r = api.requests.get(api.url_for(view))
assert "chunk1" in r.text
assert "chunk2" in r.text
# --- routes.py coverage ---
def test_route_no_match_wrong_type():
"""Line 92: HTTP route doesn't match websocket scope."""
def handler(req, resp):
pass
route = Route("/test", handler)
matches, _ = route.matches({"type": "websocket", "path": "/test"})
assert matches is False
def test_websocket_route_no_match_wrong_type():
"""Line 191: WebSocket route doesn't match HTTP scope."""
def handler(ws):
pass
route = WebSocketRoute("/ws", handler)
matches, _ = route.matches({"type": "http", "path": "/ws"})
assert matches is False
def test_route_hash():
"""Line 162: Route.__hash__ works for sets."""
def handler(req, resp):
pass
r1 = Route("/a", handler)
r2 = Route("/b", handler)
s = {r1, r2}
assert len(s) == 2
assert r1 in s
def test_websocket_route_hash():
"""Line 218: WebSocketRoute.__hash__ works for sets."""
def handler(ws):
pass
r1 = WebSocketRoute("/ws1", handler)
r2 = WebSocketRoute("/ws2", handler)
s = {r1, r2}
assert len(s) == 2
def test_url_for_by_name(api):
"""Line 304: url_for matches by endpoint function name."""
@api.route("/hello/{name}")
def greet(req, resp, *, name):
resp.text = f"hello {name}"
# By reference
assert api.url_for(greet, name="world") == "/hello/world"
# By name string
assert api.router.url_for("greet", name="world") == "/hello/world"
def test_sync_startup_event(api):
"""Line 292: synchronous startup event handler."""
started = {"value": False}
@api.on_event("startup")
def on_startup():
started["value"] = True
@api.route("/")
def view(req, resp):
resp.media = {"started": started["value"]}
with api.requests as session:
r = session.get("http://;/")
assert r.json() == {"started": True}
# --- templates.py coverage ---
def test_yaml_content_negotiation(api):
"""Lines 294-301: resp.media with YAML Accept header."""
@api.route("/")
def view(req, resp):
resp.media = {"key": "value"}
r = api.requests.get(
api.url_for(view),
headers={"Accept": "application/x-yaml"},
)
assert "key: value" in r.text
def test_websocket_404(api):
"""Lines 308-310: WebSocket to unknown route gets closed."""
client = StarletteTestClient(api)
with pytest.raises(Exception):
with client.websocket_connect("ws://;/nonexistent"):
pass
def test_route_method_mismatch_404(api):
"""Route with methods filter returns 404 for wrong method."""
@api.route("/only-post", methods=["POST"])
def post_only(req, resp):
resp.text = "posted"
r = api.requests.get("http://;/only-post")
assert r.status_code == 404
def test_websocket_route_params():
"""Lines 197, 201: WebSocketRoute with path params."""
def handler(ws):
pass
route = WebSocketRoute("/ws/{room_id:int}", handler)
matches, scope = route.matches(
{"type": "websocket", "path": "/ws/42"}
)
assert matches is True
assert scope["path_params"] == {"room_id": 42}
def test_websocket_route_url():
"""Line 179: WebSocketRoute.url() generates URLs."""
def handler(ws):
pass
route = WebSocketRoute("/ws/{room}", handler)
assert route.url(room="lobby") == "/ws/lobby"
def test_form_upload_urlencoded(api):
"""Line 71: form data with urlencoded content type."""
@api.route("/")
async def view(req, resp):
data = await req.media("form")
resp.media = dict(data)
r = api.requests.post(
api.url_for(view),
content="name=alice&age=30",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
# QueryDict returns last value for key
assert r.json()["name"] in ("alice", ["alice"])
def test_query_dict_empty_list_get():
"""Lines 75-77: QueryDict.get returns default for empty list."""
d = QueryDict("")
assert d.get("missing") is None
assert d.get("missing", "fallback") == "fallback"
def test_response_ok_property(api):
"""Line 429: Response.ok property."""
@api.route("/")
def view(req, resp):
resp.status_code = 200
resp.media = {"ok": resp.ok}
r = api.requests.get(api.url_for(view))
assert r.json() == {"ok": True}
def test_response_ok_false(api):
"""Line 429: Response.ok is False for non-2xx."""
@api.route("/")
def view(req, resp):
resp.status_code = 404
resp.media = {"ok": resp.ok}
r = api.requests.get(api.url_for(view))
assert r.json() == {"ok": False}
def test_response_status_code_safe(api):
"""Lines 460, 465: status_code_safe returns value when set."""
@api.route("/")
def view(req, resp):
resp.status_code = 201
resp.media = {"safe": resp.status_code_safe}
r = api.requests.get(api.url_for(view))
assert r.json() == {"safe": 201}
def test_router_mount():
"""Line 278: Router.mount stores app."""
from responder.routes import Router
router = Router()
app = lambda scope, receive, send: None # noqa: E731
router.mount("/app", app)
assert "/app" in router.apps
def test_router_before_request_http():
"""Line 298: Router.before_request adds HTTP handler."""
from responder.routes import Router
router = Router()
def handler(req, resp):
pass
router.before_request(handler, websocket=False)
assert handler in router.before_requests["http"]
def test_router_before_request_ws():
"""Line 256: Router.add_route with websocket before_request."""
from responder.routes import Router
router = Router()
def handler(ws):
pass
router.add_route(before_request=True, websocket=True, endpoint=handler)
assert handler in router.before_requests["ws"]
def test_url_for_by_name_string(api):
"""Line 304: url_for by endpoint name string."""
@api.route("/items/{item_id}")
def get_item(req, resp, *, item_id):
resp.text = item_id
url = api.router.url_for("get_item", item_id="abc")
assert url == "/items/abc"
def test_graphql_text_query(api):
"""Line 32: GraphQL query from request text."""
graphene = pytest.importorskip("graphene")
from responder.ext.graphql import GraphQLView
class Query(graphene.ObjectType):
hello = graphene.String(name=graphene.String(default_value="stranger"))
def resolve_hello(self, info, name):
return f"Hello {name}"
schema = graphene.Schema(query=Query)
api.add_route("/gql", GraphQLView(schema=schema, api=api))
r = api.requests.post(
"http://;/gql",
content="{ hello }",
headers={"Content-Type": "text/plain"},
)
assert r.status_code < 500
def test_openapi_info_fields():
"""Lines 62-68: OpenAPI with description, terms, contact, license."""
api = responder.API(
title="Test API",
version="1.0",
openapi="3.0.2",
description="A test API",
terms_of_service="http://example.com/terms",
contact={"name": "Support", "email": "support@example.com"},
license={"name": "MIT"},
allowed_hosts=["testserver", ";"],
)
@api.route("/")
def view(req, resp):
resp.text = "ok"
r = api.requests.get("http://;/schema.yml")
assert r.status_code == 200
assert "Test API" in r.text
assert "A test API" in r.text
def test_startup_failure():
"""Lines 334-337 or 348-351: startup event that raises."""
api = responder.API(allowed_hosts=[";"])
@api.on_event("startup")
async def bad_startup():
raise RuntimeError("startup failed")
@api.route("/")
def view(req, resp):
resp.text = "ok"
# The lifespan should handle the error
with pytest.raises(RuntimeError, match="startup failed"):
with api.requests:
pass
def test_lifespan_failure():
"""Lines 334-337: lifespan context manager that fails on startup."""
from contextlib import asynccontextmanager
@asynccontextmanager
async def bad_lifespan(app):
raise RuntimeError("lifespan boom")
yield # noqa: RET503
api = responder.API(lifespan=bad_lifespan, allowed_hosts=[";"])
@api.route("/")
def view(req, resp):
resp.text = "ok"
with pytest.raises(RuntimeError, match="lifespan boom"):
with api.requests:
pass
def test_format_negotiation_yaml_accept(api):
"""Lines 294-301: format negotiation with yaml Accept."""
@api.route("/")
def view(req, resp):
resp.media = {"format": "negotiated"}
r = api.requests.get(
api.url_for(view),
headers={"Accept": "application/x-yaml"},
)
assert r.status_code == 200
assert "format" in r.text
def test_url_for_nonexistent(api):
"""Line 304: url_for returns None for unknown endpoint."""
@api.route("/")
def view(req, resp):
pass
assert api.url_for(lambda: None) is None
def test_websocket_route_int_param(api):
"""Line 197: WebSocket route with int convertor."""
@api.route("/ws/{room_id:int}", websocket=True)
async def ws_handler(ws):
await ws.accept()
await ws.send_json({"room": ws.path_params["room_id"]})
await ws.close()
client = StarletteTestClient(api)
with client.websocket_connect("ws://;/ws/42") as ws:
data = ws.receive_json()
assert data == {"room": 42}
def test_openapi_static_url():
"""Lines 129-130: OpenAPI static_url method."""
api = responder.API(
title="Test",
version="1.0",
openapi="3.0.2",
docs_route="/docs",
allowed_hosts=["testserver", ";"],
)
url = api.openapi.static_url("swagger-ui.css")
assert url == "/static/swagger-ui.css"
def test_templates_context(tmp_path):
"""Lines 23, 27: Templates.context getter and setter."""
template_dir = tmp_path / "templates"
template_dir.mkdir()
(template_dir / "test.html").write_text("{{ greeting }} {{ name }}")
templates = Templates(directory=str(template_dir), context={"greeting": "hello"})
# Getter
assert templates.context["greeting"] == "hello"
# Setter
templates.context = {"name": "world"}
assert templates.context["greeting"] == "hello" # default preserved
assert templates.context["name"] == "world"
result = templates.render("test.html")
assert "hello" in result
assert "world" in result
+2 -2
View File
@@ -6,7 +6,7 @@ def test_custom_encoding(api, session):
req.encoding = "ascii"
resp.text = await req.text
r = session.post(api.url_for(route), content=data)
r = session.post(api.url_for(route), data=data)
assert r.text == data
@@ -17,5 +17,5 @@ def test_bytes_encoding(api, session):
async def route(req, resp):
resp.text = (await req.content).decode("utf-8")
r = session.post(api.url_for(route), content=data)
r = session.post(api.url_for(route), data=data)
assert r.content == data
-65
View File
@@ -1,65 +0,0 @@
# ruff: noqa: E402
import pytest
graphene = pytest.importorskip("graphene")
from responder.ext.graphql import GraphQLView
@pytest.fixture
def schema():
class Query(graphene.ObjectType):
hello = graphene.String(name=graphene.String(default_value="stranger"))
def resolve_hello(self, info, name):
return f"Hello {name}"
return graphene.Schema(query=Query)
def test_graphql_schema_query_querying(api, schema):
api.add_route("/", GraphQLView(schema=schema, api=api))
r = api.requests.get("http://;/?q={ hello }", headers={"Accept": "json"})
assert r.status_code == 200
assert r.json() == {"data": {"hello": "Hello stranger"}}
def test_graphql_schema_json_query(api, schema):
api.add_route("/", GraphQLView(schema=schema, api=api))
r = api.requests.post("http://;/", json={"query": "{ hello }"})
assert r.status_code < 300
assert r.json() == {"data": {"hello": "Hello stranger"}}
def test_graphiql(api, schema):
api.add_route("/", GraphQLView(schema=schema, api=api))
r = api.requests.get("http://;/", headers={"Accept": "text/html"})
assert r.status_code < 300
assert "GraphiQL" in r.text
def test_graphql_shorthand(api, schema):
"""Test the api.graphql() shorthand method."""
api.graphql("/gql", schema=schema)
r = api.requests.post("http://;/gql", json={"query": "{ hello }"})
assert r.status_code < 300
assert r.json() == {"data": {"hello": "Hello stranger"}}
def test_graphql_missing_query_key(api, schema):
api.add_route("/", GraphQLView(schema=schema, api=api))
r = api.requests.post("http://;/", json={"not_query": "foo"})
assert r.status_code == 400
assert "errors" in r.json()
def test_graphql_query_param(api, schema):
api.add_route("/", GraphQLView(schema=schema, api=api))
r = api.requests.get("http://;/?query={ hello }", headers={"Accept": "json"})
assert r.json() == {"data": {"hello": "Hello stranger"}}
def test_graphql_error_response(api, schema):
api.add_route("/", GraphQLView(schema=schema, api=api))
r = api.requests.post("http://;/", json={"query": "{ nonexistent }"})
assert "errors" in r.json()
-34
View File
@@ -3,7 +3,6 @@ import inspect
import pytest
from responder import models
from responder.models import CaseInsensitiveDict
_default_query = "q=%7b%20hello%20%7d&name=myname&user_name=test_user"
@@ -59,36 +58,3 @@ def test_query_dict_items():
items = d.items()
assert inspect.isgenerator(items)
assert dict(items) == {"q": "{ hello }", "name": "myname", "user_name": "test_user"}
class TestCaseInsensitiveDict:
def test_set_and_get(self):
d = CaseInsensitiveDict()
d["Content-Type"] = "text/html"
assert d["content-type"] == "text/html"
assert d["CONTENT-TYPE"] == "text/html"
def test_contains(self):
d = CaseInsensitiveDict()
d["X-Custom"] = "value"
assert "x-custom" in d
assert "X-CUSTOM" in d
assert "missing" not in d
def test_get_default(self):
d = CaseInsensitiveDict()
assert d.get("missing") is None
assert d.get("missing", "default") == "default"
d["Key"] = "val"
assert d.get("KEY") == "val"
def test_update(self):
d = CaseInsensitiveDict()
d.update({"Content-Type": "text/html", "Accept": "json"})
assert d["content-type"] == "text/html"
assert d["accept"] == "json"
def test_update_kwargs(self):
d = CaseInsensitiveDict()
d.update(key1="val1", key2="val2")
assert d["key1"] == "val1"
+64 -250
View File
@@ -1,6 +1,5 @@
import random
import string
from pathlib import Path
import pytest
import yaml
@@ -56,41 +55,18 @@ def test_route_eq():
assert WebSocketRoute("/", home) == WebSocketRoute("/", home)
def test_route_int_convertor(api):
@api.route("/items/{id:int}")
def item(req, resp, *, id): # noqa: A002
resp.media = {"id": id, "type": type(id).__name__}
"""
def test_api_basic_route_overlap(api):
@api.route("/")
def home(req, resp):
resp.text = "hello world!"
r = api.requests.get(api.url_for(item, id=42))
assert r.json() == {"id": 42, "type": "int"}
with pytest.raises(AssertionError):
def test_route_float_convertor(api):
@api.route("/price/{amount:float}")
def price(req, resp, *, amount):
resp.media = {"amount": amount}
r = api.requests.get(api.url_for(price, amount=9.99))
assert r.json() == {"amount": 9.99}
def test_route_path_convertor(api):
@api.route("/files/{filepath:path}")
def serve_file(req, resp, *, filepath):
resp.text = filepath
r = api.requests.get("http://;/files/docs/api/index.html")
assert r.text == "docs/api/index.html"
def test_route_uuid_convertor(api):
@api.route("/users/{user_id:uuid}")
def user(req, resp, *, user_id):
resp.media = {"user_id": user_id}
test_uuid = "12345678-1234-5678-1234-567812345678"
r = api.requests.get(f"http://;/users/{test_uuid}")
assert r.json() == {"user_id": test_uuid}
@api.route("/")
def home2(req, resp):
resp.text = "hello world!"
"""
def test_class_based_view_registration(api):
@@ -191,32 +167,6 @@ def test_request_and_get(api):
assert "LIFE" in r.headers
def test_req_is_json(api):
@api.route("/")
async def view(req, resp):
resp.media = {"is_json": req.is_json}
r = api.requests.post(
api.url_for(view),
json={"hello": "world"},
)
assert r.json()["is_json"] is True
r = api.requests.get(api.url_for(view))
assert r.json()["is_json"] is False
def test_req_path_params(api):
@api.route("/users/{user_id:int}")
def view(req, resp, *, user_id): # noqa: A002
resp.media = {"from_kwargs": user_id, "from_req": req.path_params}
r = api.requests.get("http://;/users/42")
data = r.json()
assert data["from_kwargs"] == 42
assert data["from_req"] == {"user_id": 42}
def test_class_based_view_status_code(api):
@api.route("/")
class ThingsResource:
@@ -238,6 +188,19 @@ def test_query_params(api, url):
assert r.json()["params"] == {"q": "3"}
# Requires https://github.com/encode/starlette/pull/102
# def test_form_data(api):
# @api.route("/")
# async def route(req, resp):
# resp.media = {"form": await req.media("form")}
# dump = {"q": "q"}
# r = api.requests.get(api.url_for(route), params=dump)
# assert r.json()["form"] == dump
def test_async_function(api):
content = "The Emerald Tablet of Hermes"
@@ -279,22 +242,6 @@ def test_background(api):
assert r.status_code < 300
def test_async_background(api):
result = {"value": None}
@api.route("/")
async def route(req, resp):
async def set_value():
result["value"] = 42
await api.background(set_value)
resp.media = {"dispatched": True}
r = api.requests.get(api.url_for(route))
assert r.json() == {"dispatched": True}
assert result["value"] == 42
def test_multiple_routes(api):
@api.route("/1")
def route1(req, resp):
@@ -329,7 +276,7 @@ def test_yaml_uploads(api):
dump = {"complicated": "times"}
r = api.requests.post(
api.url_for(route),
content=yaml.dump(dump),
data=yaml.dump(dump),
headers={"Content-Type": "application/x-yaml"},
)
assert r.json() == dump
@@ -374,11 +321,11 @@ def test_yaml_downloads(api):
assert yaml.safe_load(r.content) == dump
def test_schema_generation_explicit(needs_openapi):
def test_schema_generation_explicit():
import marshmallow
import responder
from responder.ext.openapi import OpenAPISchema
from responder.ext.schema import OpenAPISchema as OpenAPISchema
api = responder.API()
@@ -409,7 +356,7 @@ def test_schema_generation_explicit(needs_openapi):
assert dump["openapi"] == "3.0.2"
def test_schema_generation(needs_openapi):
def test_schema_generation():
from marshmallow import Schema, fields
import responder
@@ -441,11 +388,11 @@ def test_schema_generation(needs_openapi):
assert dump["openapi"] == "3.0.2"
def test_documentation_explicit(needs_openapi):
def test_documentation_explicit():
import marshmallow
import responder
from responder.ext.openapi import OpenAPISchema
from responder.ext.schema import OpenAPISchema as OpenAPISchema
description = "This is a sample server for a pet store."
terms_of_service = "http://example.com/terms/"
@@ -495,7 +442,7 @@ def test_documentation_explicit(needs_openapi):
assert "html" in r.text
def test_documentation(needs_openapi):
def test_documentation():
from marshmallow import Schema, fields
import responder
@@ -564,7 +511,7 @@ def test_async_class_based_views(api):
resp.text = await req.text
data = "frame"
r = api.requests.post(api.url_for(Resource), content=data)
r = api.requests.post(api.url_for(Resource), data=data)
assert r.text == data
@@ -583,8 +530,7 @@ def test_cookies(api):
httponly=True,
)
api.requests.cookies.set("hello", "universe")
r = api.requests.get(api.url_for(cookies))
r = api.requests.get(api.url_for(cookies), cookies={"hello": "universe"})
assert r.json() == {"cookies": {"hello": "universe"}}
assert "sent" in r.cookies
assert "hello" in r.cookies
@@ -593,6 +539,7 @@ def test_cookies(api):
assert r.json() == {"cookies": {"hello": "world", "sent": "true"}}
@pytest.mark.xfail
def test_sessions(api):
@api.route("/")
def view(req, resp):
@@ -600,9 +547,12 @@ def test_sessions(api):
resp.media = resp.session
r = api.requests.get(api.url_for(view))
assert "session" in r.cookies
assert api.session_cookie in r.cookies
r = api.requests.get(api.url_for(view))
assert (
r.cookies[api.session_cookie] == '{"hello": "world"}.r3EB04hEEyLYIJaAXCEq3d4YEbs'
)
assert r.json() == {"hello": "world"}
@@ -616,33 +566,33 @@ def test_template_string_rendering(api):
def test_template_rendering(template_path):
api = responder.API(templates_dir=template_path.parent)
api = responder.API(templates_dir=template_path.dirpath())
@api.route("/")
def view(req, resp):
resp.content = api.template(template_path.name, var="hello")
resp.content = api.template(template_path.basename, var="hello")
r = api.requests.get(api.url_for(view))
assert r.text == "hello"
def test_template(api, template_path):
templates = Templates(directory=template_path.parent)
templates = Templates(directory=template_path.dirpath())
@api.route("/{var}/")
def view(req, resp, var):
resp.html = templates.render(template_path.name, var=var)
resp.html = templates.render(template_path.basename, var=var)
r = api.requests.get("/test/")
assert r.text == "test"
def test_template_async(api, template_path):
templates = Templates(directory=template_path.parent, enable_async=True)
templates = Templates(directory=template_path.dirpath(), enable_async=True)
@api.route("/{var}/async")
async def view_async(req, resp, var):
resp.html = await templates.render_async(template_path.name, var=var)
resp.html = await templates.render_async(template_path.basename, var=var)
r = api.requests.get("/test/async")
assert r.text == "test"
@@ -654,11 +604,14 @@ def test_file_uploads(api):
files = await req.media("files")
result = {}
result["hello"] = files["hello"]["content"].decode("utf-8")
# result["not-a-file"] = files["not-a-file"].decode("utf-8")
resp.media = {"files": result}
files = {"hello": ("hello.txt", b"world", "text/plain")}
r = api.requests.post(api.url_for(upload), files=files)
assert r.json() == {"files": {"hello": "world"}}
# # world = io.StringIO("world")
# data = {"hello": ("hello.txt", world, "text/plain"), "not-a-file": b"data only"}
# r = api.requests.post(api.url_for(upload), files=data)
# assert r.json() == {"files": {"hello": "world", "not-a-file": "data only"}}
def test_500(api):
@@ -666,7 +619,7 @@ def test_500(api):
def view(req, resp):
raise ValueError
dumb_client = StarletteTestClient(
dumb_client = responder.api.TestClient(
api, base_url="http://;", raise_server_exceptions=False
)
r = dumb_client.get(api.url_for(view))
@@ -674,24 +627,6 @@ def test_500(api):
assert r.status_code == responder.status_codes.HTTP_500
def test_exception_handler():
api = responder.API(allowed_hosts=[";"])
@api.exception_handler(ValueError)
async def handle_value_error(req, resp, exc):
resp.status_code = 400
resp.media = {"error": str(exc)}
@api.route("/")
def view(req, resp):
raise ValueError("bad input")
client = StarletteTestClient(api, base_url="http://;", raise_server_exceptions=False)
r = client.get(api.url_for(view))
assert r.status_code == 400
assert r.json() == {"error": "bad input"}
def test_404(api):
r = api.requests.get("/foo")
@@ -780,56 +715,6 @@ def test_startup(api):
assert r.text == "hello, world!"
def test_lifespan_context_manager():
from contextlib import asynccontextmanager
state = {"started": False, "stopped": False}
@asynccontextmanager
async def lifespan(app):
state["started"] = True
yield
state["stopped"] = True
api = responder.API(lifespan=lifespan, allowed_hosts=[";"])
@api.route("/")
def index(req, resp):
resp.media = {"started": state["started"]}
with api.requests as session:
r = session.get("http://;/")
assert r.json() == {"started": True}
assert state["stopped"] is True
def test_resp_file(api, tmp_path):
test_file = tmp_path / "hello.txt"
test_file.write_text("hello from file")
@api.route("/download")
def download(req, resp):
resp.file(test_file)
r = api.requests.get(api.url_for(download))
assert r.text == "hello from file"
assert "text/plain" in r.headers["content-type"]
def test_resp_file_binary(api, tmp_path):
test_file = tmp_path / "image.png"
test_file.write_bytes(b"\x89PNG\r\n\x1a\n")
@api.route("/image")
def image(req, resp):
resp.file(test_file, content_type="image/png")
r = api.requests.get(api.url_for(image))
assert r.content == b"\x89PNG\r\n\x1a\n"
assert r.headers["content-type"] == "image/png"
def test_redirects(api, session):
@api.route("/2")
def two(req, resp):
@@ -874,42 +759,6 @@ def test_before_response(api, session):
assert "x-pizza" in r.headers
def test_route_methods_filter(api):
@api.route("/data", methods=["GET"])
def get_data(req, resp):
resp.media = {"method": "get"}
@api.route("/data", methods=["POST"], check_existing=False)
def post_data(req, resp):
resp.media = {"method": "post"}
r = api.requests.get(api.url_for(get_data))
assert r.json() == {"method": "get"}
r = api.requests.post(api.url_for(post_data))
assert r.json() == {"method": "post"}
def test_before_request_short_circuit(api):
"""If a before_request hook sets a status code, the route handler is skipped."""
called = {"handler": False}
@api.route(before_request=True)
def auth_check(req, resp):
resp.status_code = 401
resp.media = {"error": "unauthorized"}
@api.route("/protected")
def protected(req, resp):
called["handler"] = True
resp.text = "secret"
r = api.requests.get(api.url_for(protected))
assert r.status_code == 401
assert r.json() == {"error": "unauthorized"}
assert called["handler"] is False
@pytest.mark.parametrize("enable_hsts", [True, False])
@pytest.mark.parametrize("cors", [True, False])
def test_allowed_hosts(enable_hsts, cors):
@@ -962,28 +811,26 @@ def test_allowed_hosts(enable_hsts, cors):
assert r.status_code == 200
def create_asset(static_dir: Path, name=None, parent_dir: str = None) -> Path:
def create_asset(static_dir, name=None, parent_dir=None):
if name is None:
name = "".join(random.choices(string.ascii_letters, k=6)) # noqa: S311
name = random.choices(string.ascii_letters, k=6) # noqa: S311
# :3
ext = "".join(random.choices(string.ascii_letters, k=2)) # noqa: S311
ext = random.choices(string.ascii_letters, k=2) # noqa: S311
name = f"{name}.{ext}"
if parent_dir is None:
parent_dir = static_dir
else:
parent_dir = static_dir / parent_dir
parent_dir.mkdir()
parent_dir = static_dir.mkdir(parent_dir)
asset = parent_dir / name
asset.write_text("body { color: blue; }", encoding="utf-8")
return Path(asset)
asset = parent_dir.join(name)
asset.write("body { color: blue; }")
return asset
@pytest.mark.parametrize("static_route", [None, "/static", "/custom/static/route"])
def test_staticfiles(tmp_path, static_route):
static_dir = tmp_path / "static"
static_dir.mkdir()
def test_staticfiles(tmpdir, static_route):
static_dir = tmpdir.mkdir("static")
asset1 = create_asset(static_dir)
parent_dir = "css"
@@ -995,10 +842,10 @@ def test_staticfiles(tmp_path, static_route):
static_route = api.static_route
# ok
r = session.get(f"{static_route}/{asset1.name}")
r = session.get(f"{static_route}/{asset1.basename}")
assert r.status_code == api.status_codes.HTTP_200
r = session.get(f"{static_route}/{parent_dir}/{asset2.name}")
r = session.get(f"{static_route}/{parent_dir}/{asset2.basename}")
assert r.status_code == api.status_codes.HTTP_200
# Asset not found
@@ -1013,39 +860,18 @@ def test_staticfiles(tmp_path, static_route):
assert r.status_code == api.status_codes.HTTP_404
def test_staticfiles_add_directory(tmp_path):
static_dir = tmp_path / "static"
static_dir.mkdir()
extra_dir = tmp_path / "extra"
extra_dir.mkdir()
(static_dir / "main.css").write_text("body {}")
(extra_dir / "extra.css").write_text(".extra {}")
api = responder.API(static_dir=str(static_dir))
api.static_app.add_directory(str(extra_dir))
session = api.session()
r = session.get(f"{api.static_route}/main.css")
assert r.status_code == 200
r = session.get(f"{api.static_route}/extra.css")
assert r.status_code == 200
def test_staticfiles_none_dir(tmp_path):
def test_staticfiles_none_dir(tmpdir):
api = responder.API(static_dir=None)
session = api.session()
static_dir = tmp_path / "static"
static_dir.mkdir()
static_dir = tmpdir.mkdir("static")
asset = create_asset(static_dir)
static_route = api.static_route
# ok
r = session.get(f"{static_route}/{asset.name}")
r = session.get(f"{static_route}/{asset.basename}")
assert r.status_code == api.status_codes.HTTP_404
# dir listing
@@ -1057,18 +883,6 @@ def test_staticfiles_none_dir(tmp_path):
api.add_route("/spa", static=True)
def test_static_index_html(tmp_path):
static_dir = tmp_path / "static"
static_dir.mkdir()
(static_dir / "index.html").write_text("<h1>Home</h1>")
api = responder.API(static_dir=str(static_dir), allowed_hosts=[";"])
api.add_route("/", static=True)
r = api.requests.get("http://;/")
assert r.text == "<h1>Home</h1>"
def test_response_html_property(api):
@api.route("/")
def view(req, resp):
-134
View File
@@ -1,134 +0,0 @@
"""
Utility functions for testing server components.
This module provides functions for managing test server instances,
including port allocation and server readiness checking.
"""
import errno
import logging
import socket
import time
import typing as t
from copy import copy
from functools import lru_cache
from urllib.request import urlopen
logger = logging.getLogger(__name__)
def random_port() -> int:
"""
Return a random available port by binding to port 0.
Returns:
int: An available port number that can be used for testing.
"""
sock = socket.socket()
try:
sock.bind(("", 0))
return sock.getsockname()[1]
finally:
sock.close()
@lru_cache(maxsize=None)
def transient_socket_error_numbers() -> t.List[int]:
"""
A list of TCP socket error numbers to ignore in `wait_server_tcp`.
On Windows, Winsock error codes are the Unix error code + 10000.
Returns:
List[int]: A list containing both Unix and Windows-specific error codes.
For each Unix error code 'x', includes both 'x' and 'x + 10000'.
"""
error_numbers = [
errno.EAGAIN,
errno.ECONNABORTED,
errno.ECONNREFUSED,
errno.ETIMEDOUT,
errno.EWOULDBLOCK,
]
error_numbers_effective = copy(error_numbers)
error_numbers_effective.extend(error_number + 10000 for error_number in error_numbers)
return error_numbers_effective
def wait_server_tcp(
port: int,
host: str = "127.0.0.1",
timeout: int = 10,
delay: float = 0.1,
) -> None:
"""
Wait for server to be ready by attempting TCP connections.
Args:
port: The port number to connect to
host: The host to connect to (default: "127.0.0.1")
timeout: Maximum time to wait in seconds (default: 10)
delay: Delay between attempts in seconds (default: 0.1)
Raises:
RuntimeError: If server is not ready within timeout period
"""
endpoint = f"tcp://{host}:{port}/"
logger.debug(f"Waiting for endpoint: {endpoint}")
start_time = time.time()
while time.time() - start_time < timeout:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(delay / 2) # Set socket timeout
error_number = sock.connect_ex((host, port))
if error_number == 0:
break
# Expected errors when server is not ready.
if error_number in transient_socket_error_numbers():
pass
# Unexpected error.
else:
raise RuntimeError(
f"Unexpected error while connecting to {endpoint}: {error_number}"
)
time.sleep(delay)
else:
raise RuntimeError(
f"Server at {endpoint} failed to start within {timeout} seconds"
)
def wait_server_http(
port: int,
host: str = "127.0.0.1",
protocol: str = "http",
attempts: int = 20,
delay: float = 0.1,
) -> None:
"""
Wait for server to be ready by attempting to connect to it.
Args:
port: The port number to connect to
host: The host to connect to (default: "127.0.0.1")
protocol: The protocol to use (default: "http")
attempts: Number of connection attempts (default: 20)
delay: Delay per attempt in seconds (default: 0.1)
Raises:
RuntimeError: If server is not ready after all attempts
"""
url = f"{protocol}://{host}:{port}/"
for attempt in range(1, attempts + 1):
try:
urlopen(url, timeout=delay / 2) # noqa: S310
break
except OSError:
if attempt < attempts: # Don't sleep on last attempt
time.sleep(delay)
else:
raise RuntimeError(
f"Server at {url} failed to respond after {attempts} attempts "
f"(total wait time: {attempts * delay:.1f}s)"
)