diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 875785f..951adcc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,10 +74,29 @@ jobs: - name: Metadata must not link to hijacked domains run: python .github/scripts/check_metadata.py dist/* + docs: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - run: python -m pip install -e . -r requirements/docs.txt -r requirements/examples.txt + - run: mkdocs build --strict + - name: Examples in the README and docs, with every optional library installed + run: | + for f in README.md docs/*.md; do + python tests/doc_examples.py "$f" + done + - name: Start each example server and send it requests + run: python docs/examples/check_examples.py + ci-ok: name: CI OK if: always() - needs: [test, lint, package] + needs: [test, lint, package, docs] runs-on: ubuntu-24.04 steps: - name: All jobs passed diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..15997e3 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,42 @@ +name: Docs + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - run: python -m pip install -r requirements/docs.txt + - run: mkdocs build --strict + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: site + + deploy: + needs: build + runs-on: ubuntu-24.04 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ebcebf..c1307b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,15 @@ error instead. If you depend on the old output, pass `serializer=json.dumps`. follow_untyped_imports = true ``` +### Documentation + +The documentation now lives in this repository, under `docs/`, and is +published at https://bensynapse.github.io/jsonrpcserver/. The framework +examples moved there from the wiki. CI starts each example server and sends +it requests, and runs every code example in the docs. The websockets example +uses the current `websockets.asyncio` API (#287). A new Security page covers +the settings to check before exposing a server. + ### Deprecations Three functions in `jsonrpcserver.response` have new names. The old names still diff --git a/README.md b/README.md index 0bab1c4..0edf0cc 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@
-
+
@@ -15,39 +15,49 @@
- Documentation | - Developer Wiki + Documentation | + Examples | + Changelog
https://github.com/user-attachments/assets/94fb4f04-a5f1-41ca-84dd-7e18b87990e0 -## 🚀 Installation +## Installation ```sh pip install jsonrpcserver ``` -## ⚒️ Usage +It supports Python 3.8 and later. + +## Usage ```python -from jsonrpcserver import dispatch, method, Success +from jsonrpcserver import Result, Success, dispatch, method @method -def ping(): +def ping() -> Result: return Success("pong") +``` - -response = dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}') -# => '{"jsonrpc": "2.0", "result": "pong", "id": 1}' +```python +>>> dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}') +'{"jsonrpc": "2.0", "result": "pong", "id": 1}' ``` -## 📚 Documentation +jsonrpcserver doesn't listen on a port itself. The +[examples](https://bensynapse.github.io/jsonrpcserver/examples/) show it with +Flask, FastAPI, Django, aiohttp, websockets, ZeroMQ and more. + +Before you expose a server to the internet, read the +[security notes](https://bensynapse.github.io/jsonrpcserver/security/). -👉 Full documentation is at [explodinglabs.com/jsonrpcserver/](https://explodinglabs.com/jsonrpcserver/). +## Documentation -👉 See the [Wiki](https://github.com/explodinglabs/jsonrpcserver/wiki) for community-editable usage examples in various frameworks, libraries, and transport protocols. +Full documentation is at +[bensynapse.github.io/jsonrpcserver](https://bensynapse.github.io/jsonrpcserver/). -## ➡️ See Also +## See also -- [jsonrpcclient](https://github.com/explodinglabs/jsonrpcclient) – Create JSON-RPC requests and parse responses in Python +- [jsonrpcclient](https://github.com/bensynapse/jsonrpcclient): create JSON-RPC requests and parse responses in Python diff --git a/docs/assets/extra.css b/docs/assets/extra.css new file mode 100644 index 0000000..26e3b96 --- /dev/null +++ b/docs/assets/extra.css @@ -0,0 +1,106 @@ +:root { + /* ✅ Fonts */ + --md-text-font: -apple-system, BlinkMacSystemFont, "Segoe UI Adjusted", + "Segoe UI", "Liberation Sans", sans-serif; + --md-code-font: "SF Mono", SFMono-Regular, ui-monospace, "DejaVu Sans Mono", + Menlo, Consolas, monospace; + + /* ✅ Light Mode Text Colors */ + --md-default-fg-color: #111; +} + +[data-md-color-scheme="slate"] { + /* ✅ Dark Mode Text Colors */ + --md-default-fg-color: #e9e9e9; +} + +/* Fix a horizontal scroll isue */ +.md-typeset * { + margin-left: 0 !important; + margin-right: 0 !important; +} + +.md-typeset { + margin-left: 0 !important; + margin-right: 0 !important; + + p, + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 0; + padding: 0; + padding-left: 20px; + padding-right: 20px; + } + + /* Change header fonts */ + h1, h2, h3, h4, h5, h6 { + font-weight: 700; + font-style: normal; + letter-spacing: -0.5px; + line-height: 140%; + } + + h1 { + color: var(--md-default-fg-color); + font-size: 1.2rem; + margin-bottom: 1em; + } + + h2 { + font-size: 1rem; + } + + h3 { + font-size: 0.9rem; + } + + p, ul, ol, pre { + font-size: 17px; + margin-top: 1em; + margin-bottom: 1em; + } + + ul, ol { + margin-left: 3rem; + padding-left: 3rem; + } + + blockquote { + border-left: 0 !important; + border-radius: 0; + margin: 0; + margin-bottom: 1em; + padding: 1px; + background-color: rgba(255, 87, 34, 0.05); + + [data-md-color-scheme="slate"] { + background-color: rgba(255, 204, 0, 0.1); + } + } + + pre { + line-height: 150%; + } + pre > code { + padding-left: 20px; + } +} + +.md-content__inner { + /* Target only the page title (first H1 in the article) */ + .md-typeset { + h1:first-of-type { + padding-left: 20px; + } + } +} + +article { + padding-bottom: 3rem; +} + diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 0000000..d7024dc Binary files /dev/null and b/docs/assets/logo.png differ diff --git a/docs/async.md b/docs/async.md new file mode 100644 index 0000000..c553a1a --- /dev/null +++ b/docs/async.md @@ -0,0 +1,82 @@ +# Async + +For asyncio servers, use `async_dispatch`. Methods can be `async`, and their +`await`s don't block the server. + +```python +import asyncio + +from jsonrpcserver import Result, Success, async_dispatch, method + + +@method +async def ping() -> Result: + return Success("pong") +``` + +```python +>>> asyncio.run(async_dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}')) +'{"jsonrpc": "2.0", "result": "pong", "id": 1}' +``` + +In a real server you're already inside a coroutine, so write +`response = await async_dispatch(request)`. + +`async_dispatch` takes the same options as [`dispatch`](dispatch.md). +`async_dispatch_to_serializable` and `async_dispatch_to_response` are the async +versions of the other two functions. + +Plain functions work as methods too, but they run on the event loop, so a slow +one holds up everything else. Async methods don't work with the synchronous +`dispatch`. The client gets an Internal error, and the log says to use +`async_dispatch`. + +## Batches run concurrently + +The requests in a batch run at the same time. These ten requests take about a +tenth of a second, not one second: + +```python +import json +import time + + +@method +async def wait() -> Result: + await asyncio.sleep(0.1) + return Success() + + +batch = json.dumps([{"jsonrpc": "2.0", "method": "wait", "id": n} for n in range(10)]) +``` + +```python +>>> start = time.monotonic() +>>> responses = json.loads(asyncio.run(async_dispatch(batch))) +>>> len(responses) +10 +>>> time.monotonic() - start < 0.5 +True +``` + +Every request in a batch runs at once, so set `max_batch_size` if clients +can send big batches. See [Security](security.md). + +## Notifications + +A notification is a request with no `id`. The spec says not to respond to it, +so `async_dispatch` gives an empty string: + +```python +>>> asyncio.run(async_dispatch('{"jsonrpc": "2.0", "method": "ping"}')) +'' +``` + +With HTTP you have to send something, so send an empty body with status 204. +With websockets or a message queue, you can skip sending: + +```python +async def handle(request: str, send) -> None: + if response := await async_dispatch(request): + await send(response) +``` diff --git a/docs/dispatch.md b/docs/dispatch.md new file mode 100644 index 0000000..bbefd8f --- /dev/null +++ b/docs/dispatch.md @@ -0,0 +1,174 @@ +# Dispatch + +`dispatch` takes a JSON-RPC request string, calls the method and gives a +JSON-RPC response string. + +```python +from jsonrpcserver import Result, Success, dispatch, method + + +@method +def ping() -> Result: + return Success("pong") +``` + +```python +>>> dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}') +'{"jsonrpc": "2.0", "result": "pong", "id": 1}' +``` + +It never raises for a bad request or a failing method. Those become JSON-RPC +error responses, as the spec requires: + +```python +>>> dispatch('{"jsonrpc": "2.0", "method": "nope", "id": 1}') +'{"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found", "data": "nope"}, "id": 1}' +>>> dispatch("{") +'{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error", "data": "Expecting property name enclosed in double quotes: line 1 column 2 (char 1)"}, "id": null}' +``` + +A batch gets a list of responses, in the same order: + +```python +>>> dispatch('[{"jsonrpc": "2.0", "method": "ping", "id": 1}, {"jsonrpc": "2.0", "method": "ping", "id": 2}]') +'[{"jsonrpc": "2.0", "result": "pong", "id": 1}, {"jsonrpc": "2.0", "result": "pong", "id": 2}]' +``` + +A notification, or a batch of only notifications, gives an empty string. The +spec says not to respond to those. + +[See how dispatch is used in different frameworks.](examples.md) + +## Options + +All of these are keyword arguments, apart from `methods`, which can also be +the second positional argument. + +### methods + +The methods that requests can call, as a dict of names to functions. Use this +instead of the `@method` decorator: + +```python +def multiply(a: int, b: int) -> Result: + return Success(a * b) +``` + +```python +>>> dispatch('{"jsonrpc": "2.0", "method": "multiply", "params": [2, 3], "id": 1}', methods={"multiply": multiply}) +'{"jsonrpc": "2.0", "result": 6, "id": 1}' +``` + +The default is the dict that `@method` fills in. + +### context + +If given, it's passed as the first argument to every method. Use it for things +like the database connection or the logged-in user: + +```python +def greet(context: str, name: str) -> Result: + return Success(context + " " + name) +``` + +```python +>>> dispatch('{"jsonrpc": "2.0", "method": "greet", "params": ["Beau"], "id": 1}', methods={"greet": greet}, context="Hello") +'{"jsonrpc": "2.0", "result": "Hello Beau", "id": 1}' +``` + +The client can't see or set it. + +### debug + +When a method raises an exception it doesn't catch, the client gets a -32603 +"Internal error" with no details, and the exception is logged. With +`debug=True`, the exception message goes into the response's `data` too: + +```python +def broken() -> Result: + raise ValueError("Something went wrong") +``` + +```python +>>> import logging +>>> logging.disable(logging.CRITICAL) # Keep the logged traceback out of this page. +>>> dispatch('{"jsonrpc": "2.0", "method": "broken", "id": 1}', methods={"broken": broken}) +'{"jsonrpc": "2.0", "error": {"code": -32603, "message": "Internal error"}, "id": 1}' +>>> dispatch('{"jsonrpc": "2.0", "method": "broken", "id": 1}', methods={"broken": broken}, debug=True) +'{"jsonrpc": "2.0", "error": {"code": -32603, "message": "Internal error", "data": "Something went wrong"}, "id": 1}' +>>> logging.disable(logging.NOTSET) +``` + +Only use it in development. Exception messages can include passwords, file +paths and SQL. The default is `False`. + +### max_batch_size + +The most requests a batch may hold. A bigger batch gets a single -32600 +"Invalid request" response, and none of its requests are run: + +```python +>>> dispatch('[{"jsonrpc": "2.0", "method": "ping", "id": 1}, {"jsonrpc": "2.0", "method": "ping", "id": 2}]', max_batch_size=1) +'{"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid request", "data": "The batch has 2 requests. The limit is 1."}, "id": null}' +``` + +The default, `None`, means no limit. A server open to the internet should set +one. See [Security](security.md). + +### deserializer + +The function that parses the request string. The default is `json.loads`. + + +```python +import ujson + +dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', deserializer=ujson.loads) +``` + +### serializer + +The function that turns the response into a string. The default is +`json.dumps` with `allow_nan=False`, so a result containing `NaN` or +`Infinity` gives an Internal error instead of output that isn't valid JSON. + +If the serializer raises for a response (say the method returned a +`datetime`), that response becomes an Internal error. The rest of a batch is +sent as usual. + + +```python +dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', serializer=ujson.dumps) +``` + +### validator + +The function that checks each request against the JSON-RPC spec, after +parsing. It should raise an exception, any exception, if the request is +invalid. In a batch, it's called once for each request. The default checks +against a JSON schema. + +Validation takes most of the time for a small method. If you're sure the +requests are valid, turn it off: + +```python +dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', validator=lambda _: None) +``` + +## Other return types + +`dispatch` is an alias for `dispatch_to_json`. Two other functions take the +same options, apart from `serializer`, and give the response in other forms: + +- `dispatch_to_serializable` gives a dict, a list of dicts for a batch, or + `None` for a notification. +- `dispatch_to_response` gives `Response` objects. Each is an oslash `Right` + holding a `SuccessResponse`, or a `Left` holding an `ErrorResponse`. Both are + defined in + [`jsonrpcserver.response`](https://github.com/bensynapse/jsonrpcserver/blob/main/jsonrpcserver/response.py). + +```python +>>> from jsonrpcserver import dispatch_to_serializable +>>> dispatch_to_serializable('{"jsonrpc": "2.0", "method": "ping", "id": 1}') +{'jsonrpc': '2.0', 'result': 'pong', 'id': 1} +``` diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..78bcf48 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,102 @@ +# Examples + +jsonrpcserver doesn't listen on a port itself. You receive the request with a +framework or transport library, pass it to `dispatch` or `async_dispatch`, and +send back the result. Each example below answers `ping` with `pong` on +`localhost:5000`. CI starts every one of them and sends it real requests. + +For HTTP, send status 204 with an empty body when `dispatch` gives an empty +string. That means the request was a notification. With websockets and most +message queues, you can send nothing at all. + +The libraries' own docs cover running them in production. + +## http.server + +Python's built-in HTTP server, with no other dependencies: + +```python +--8<-- "docs/examples/http_server.py" +``` + +jsonrpcserver also has a small built-in server, `serve()`. It's for trying +things out, not for production: + +```python +--8<-- "docs/examples/serve.py" +``` + +## Flask + +```python +--8<-- "docs/examples/flask_server.py" +``` + +## Werkzeug + +```python +--8<-- "docs/examples/werkzeug_server.py" +``` + +## Django + +A whole Django project in one file. In your project, the view goes in an app +and the URL pattern in its `urls.py`. + +```python +--8<-- "docs/examples/django_server.py" +``` + +## FastAPI + +```python +--8<-- "docs/examples/fastapi_server.py" +``` + +## aiohttp + +```python +--8<-- "docs/examples/aiohttp_server.py" +``` + +## Sanic + +```python +--8<-- "docs/examples/sanic_server.py" +``` + +## Tornado + +```python +--8<-- "docs/examples/tornado_server.py" +``` + +## websockets + +Uses the `websockets.asyncio` server from websockets 13 and later. + +```python +--8<-- "docs/examples/websockets_server.py" +``` + +## ZeroMQ + +Using [pyzmq](https://pyzmq.readthedocs.io/): + +```python +--8<-- "docs/examples/zeromq_server.py" +``` + +With asyncio, using pyzmq's `zmq.asyncio`: + +```python +--8<-- "docs/examples/zeromq_async_server.py" +``` + +## Socket.IO + +Using [Flask-SocketIO](https://flask-socketio.readthedocs.io/): + +```python +--8<-- "docs/examples/socketio_server.py" +``` diff --git a/docs/examples/aiohttp_server.py b/docs/examples/aiohttp_server.py new file mode 100644 index 0000000..11e0137 --- /dev/null +++ b/docs/examples/aiohttp_server.py @@ -0,0 +1,21 @@ +from aiohttp import web + +from jsonrpcserver import Result, Success, async_dispatch, method + + +@method +async def ping() -> Result: + return Success("pong") + + +async def handle(request: web.Request) -> web.Response: + if response := await async_dispatch(await request.text()): + return web.Response(text=response, content_type="application/json") + return web.Response(status=204) + + +app = web.Application() +app.router.add_post("/", handle) + +if __name__ == "__main__": + web.run_app(app, host="localhost", port=5000) diff --git a/docs/examples/check_examples.py b/docs/examples/check_examples.py new file mode 100644 index 0000000..b755796 --- /dev/null +++ b/docs/examples/check_examples.py @@ -0,0 +1,161 @@ +"""Start each example server in this directory and send it real requests. + +Usage: python docs/examples/check_examples.py [EXAMPLE.py ...] + +Each server must answer a "ping" request with "pong". The HTTP servers must +also answer a notification with 204 No Content and an empty body. The servers +listen on localhost port 5000, as the docs say. Needs the packages in +requirements/examples.txt. +""" + +import json +import socket +import subprocess +import sys +import time +import urllib.request +from pathlib import Path +from typing import Callable, Dict, List, Tuple + +HERE = Path(__file__).parent +PORT = 5000 +REQUEST = json.dumps({"jsonrpc": "2.0", "method": "ping", "id": 1}) +NOTIFICATION = json.dumps({"jsonrpc": "2.0", "method": "ping"}) +EXPECTED = {"jsonrpc": "2.0", "result": "pong", "id": 1} + + +def post(body: str) -> Tuple[int, str, str]: + request = urllib.request.Request( + f"http://localhost:{PORT}/", + data=body.encode(), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(request, timeout=10) as response: + return ( + response.status, + response.headers.get("Content-Type", ""), + response.read().decode(), + ) + + +def check_http() -> None: + status, content_type, body = post(REQUEST) + assert status == 200, status + assert content_type.startswith("application/json"), content_type + assert json.loads(body) == EXPECTED, body + status, _, body = post(NOTIFICATION) + assert (status, body) == (204, ""), (status, body) + + +def check_websockets() -> None: + from websockets.sync.client import connect + + with connect(f"ws://localhost:{PORT}") as websocket: + websocket.send(REQUEST) + assert json.loads(websocket.recv(timeout=10)) == EXPECTED + + +def check_zeromq() -> None: + import zmq + + context = zmq.Context() + client = context.socket(zmq.REQ) + client.setsockopt(zmq.RCVTIMEO, 10000) + client.setsockopt(zmq.LINGER, 0) + client.connect(f"tcp://localhost:{PORT}") + try: + client.send_string(REQUEST) + assert json.loads(client.recv_string()) == EXPECTED + client.send_string(NOTIFICATION) + assert client.recv_string() == "" + finally: + client.close() + context.term() + + +def check_socketio() -> None: + import socketio + + with socketio.SimpleClient() as client: + client.connect(f"http://localhost:{PORT}") + client.emit("message", REQUEST) + event, data = client.receive(timeout=10) + assert event == "message", event + assert json.loads(data) == EXPECTED, data + + +EXAMPLES: Dict[str, Callable[[], None]] = { + "http_server.py": check_http, + "serve.py": check_http, + "flask_server.py": check_http, + "werkzeug_server.py": check_http, + "django_server.py": check_http, + "fastapi_server.py": check_http, + "aiohttp_server.py": check_http, + "sanic_server.py": check_http, + "tornado_server.py": check_http, + "websockets_server.py": check_websockets, + "zeromq_server.py": check_zeromq, + "zeromq_async_server.py": check_zeromq, + "socketio_server.py": check_socketio, +} + + +def port_open() -> bool: + try: + with socket.create_connection(("localhost", PORT), timeout=1): + return True + except OSError: + return False + + +def run(example: str) -> bool: + if port_open(): + print(f"FAIL {example}: port {PORT} is already in use") + return False + server = subprocess.Popen( + [sys.executable, str(HERE / example)], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + deadline = time.monotonic() + 60 + while not port_open(): + if server.poll() is not None or time.monotonic() > deadline: + raise RuntimeError("the server didn't start") + time.sleep(0.2) + EXAMPLES[example]() + except Exception as exc: + server.kill() + output, _ = server.communicate() + print(f"FAIL {example}: {type(exc).__name__}: {exc}\n{output}") + return False + finally: + if server.poll() is None: + server.terminate() + try: + server.communicate(timeout=10) + except subprocess.TimeoutExpired: + server.kill() + server.communicate() + # Wait for the port to be free for the next example. + deadline = time.monotonic() + 10 + while port_open() and time.monotonic() < deadline: + time.sleep(0.2) + print(f"ok {example}") + return True + + +def main(names: List[str]) -> int: + on_disk = {path.name for path in HERE.glob("*.py")} - {Path(__file__).name} + missing = on_disk - set(EXAMPLES) + if missing: + print(f"Examples with no check: {sorted(missing)}") + return 1 + results = [run(name) for name in names or EXAMPLES] + return 0 if all(results) else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/docs/examples/django_server.py b/docs/examples/django_server.py new file mode 100644 index 0000000..db58863 --- /dev/null +++ b/docs/examples/django_server.py @@ -0,0 +1,36 @@ +import sys + +from django.conf import settings +from django.http import HttpRequest, HttpResponse +from django.urls import path +from django.views.decorators.csrf import csrf_exempt + +from jsonrpcserver import Result, Success, dispatch, method + +# A whole Django project in one file. In a real project, the view and the URL +# pattern go in your app as usual. +settings.configure( + ROOT_URLCONF=__name__, + ALLOWED_HOSTS=["localhost", "127.0.0.1"], + SECRET_KEY="replace-me", +) + + +@method +def ping() -> Result: + return Success("pong") + + +@csrf_exempt +def jsonrpc(request: HttpRequest) -> HttpResponse: + if response := dispatch(request.body.decode()): + return HttpResponse(response, content_type="application/json") + return HttpResponse(status=204) + + +urlpatterns = [path("", jsonrpc)] + +if __name__ == "__main__": + from django.core.management import execute_from_command_line + + execute_from_command_line([sys.argv[0], "runserver", "5000", "--noreload"]) diff --git a/docs/examples/fastapi_server.py b/docs/examples/fastapi_server.py new file mode 100644 index 0000000..4a36856 --- /dev/null +++ b/docs/examples/fastapi_server.py @@ -0,0 +1,22 @@ +import uvicorn +from fastapi import FastAPI, Request, Response + +from jsonrpcserver import Result, Success, async_dispatch, method + +app = FastAPI() + + +@method +async def ping() -> Result: + return Success("pong") + + +@app.post("/") +async def index(request: Request) -> Response: + if response := await async_dispatch((await request.body()).decode()): + return Response(response, media_type="application/json") + return Response(status_code=204) + + +if __name__ == "__main__": + uvicorn.run(app, host="localhost", port=5000) diff --git a/docs/examples/flask_server.py b/docs/examples/flask_server.py new file mode 100644 index 0000000..6212f7a --- /dev/null +++ b/docs/examples/flask_server.py @@ -0,0 +1,21 @@ +from flask import Flask, Response, request + +from jsonrpcserver import Result, Success, dispatch, method + +app = Flask(__name__) + + +@method +def ping() -> Result: + return Success("pong") + + +@app.post("/") +def index() -> Response: + if response := dispatch(request.get_data(as_text=True)): + return Response(response, content_type="application/json") + return Response(status=204) + + +if __name__ == "__main__": + app.run(port=5000) diff --git a/docs/examples/http_server.py b/docs/examples/http_server.py new file mode 100644 index 0000000..92dc91e --- /dev/null +++ b/docs/examples/http_server.py @@ -0,0 +1,29 @@ +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from jsonrpcserver import Result, Success, dispatch, method + + +@method +def ping() -> Result: + return Success("pong") + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + length = int(self.headers["Content-Length"]) + response = dispatch(self.rfile.read(length).decode()) + if response: + body = response.encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + # A notification. There's nothing to send back. + self.send_response(204) + self.end_headers() + + +if __name__ == "__main__": + ThreadingHTTPServer(("localhost", 5000), Handler).serve_forever() diff --git a/docs/examples/sanic_server.py b/docs/examples/sanic_server.py new file mode 100644 index 0000000..5ef95f1 --- /dev/null +++ b/docs/examples/sanic_server.py @@ -0,0 +1,21 @@ +from sanic import HTTPResponse, Request, Sanic + +from jsonrpcserver import Result, Success, async_dispatch, method + +app = Sanic("jsonrpc") + + +@method +async def ping() -> Result: + return Success("pong") + + +@app.post("/") +async def index(request: Request) -> HTTPResponse: + if response := await async_dispatch(request.body.decode()): + return HTTPResponse(response, content_type="application/json") + return HTTPResponse(status=204) + + +if __name__ == "__main__": + app.run(host="localhost", port=5000, single_process=True) diff --git a/docs/examples/serve.py b/docs/examples/serve.py new file mode 100644 index 0000000..db6a68d --- /dev/null +++ b/docs/examples/serve.py @@ -0,0 +1,10 @@ +from jsonrpcserver import Result, Success, method, serve + + +@method +def ping() -> Result: + return Success("pong") + + +if __name__ == "__main__": + serve("localhost", 5000) diff --git a/docs/examples/socketio_server.py b/docs/examples/socketio_server.py new file mode 100644 index 0000000..86f14ba --- /dev/null +++ b/docs/examples/socketio_server.py @@ -0,0 +1,24 @@ +from flask import Flask +from flask_socketio import SocketIO, send + +from jsonrpcserver import Result, Success, dispatch, method + +app = Flask(__name__) +socketio = SocketIO(app) + + +@method +def ping() -> Result: + return Success("pong") + + +@socketio.on("message") +def handle_message(message: str) -> None: + if response := dispatch(message): + send(response) + + +if __name__ == "__main__": + # Werkzeug's server is for development. See the Flask-SocketIO docs for + # production servers. + socketio.run(app, host="localhost", port=5000, allow_unsafe_werkzeug=True) diff --git a/docs/examples/tornado_server.py b/docs/examples/tornado_server.py new file mode 100644 index 0000000..006eba5 --- /dev/null +++ b/docs/examples/tornado_server.py @@ -0,0 +1,29 @@ +import asyncio + +from tornado import web + +from jsonrpcserver import Result, Success, async_dispatch, method + + +@method +async def ping() -> Result: + return Success("pong") + + +class MainHandler(web.RequestHandler): + async def post(self) -> None: + if response := await async_dispatch(self.request.body.decode()): + self.set_header("Content-Type", "application/json") + self.write(response) + else: + self.set_status(204) + + +async def main() -> None: + app = web.Application([(r"/", MainHandler)]) + app.listen(5000, address="localhost") + await asyncio.Event().wait() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/websockets_server.py b/docs/examples/websockets_server.py new file mode 100644 index 0000000..d2b2bff --- /dev/null +++ b/docs/examples/websockets_server.py @@ -0,0 +1,27 @@ +import asyncio + +from websockets.asyncio.server import ServerConnection, serve + +from jsonrpcserver import Result, Success, async_dispatch, method + + +@method +async def ping() -> Result: + return Success("pong") + + +async def handler(websocket: ServerConnection) -> None: + async for message in websocket: + request = message.decode() if isinstance(message, bytes) else message + # Unlike HTTP, there's no need to answer a notification. + if response := await async_dispatch(request): + await websocket.send(response) + + +async def main() -> None: + async with serve(handler, "localhost", 5000) as server: + await server.serve_forever() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/werkzeug_server.py b/docs/examples/werkzeug_server.py new file mode 100644 index 0000000..d456634 --- /dev/null +++ b/docs/examples/werkzeug_server.py @@ -0,0 +1,20 @@ +from werkzeug.serving import run_simple +from werkzeug.wrappers import Request, Response + +from jsonrpcserver import Result, Success, dispatch, method + + +@method +def ping() -> Result: + return Success("pong") + + +@Request.application +def application(request: Request) -> Response: + if response := dispatch(request.get_data(as_text=True)): + return Response(response, content_type="application/json") + return Response(status=204) + + +if __name__ == "__main__": + run_simple("localhost", 5000, application) diff --git a/docs/examples/zeromq_async_server.py b/docs/examples/zeromq_async_server.py new file mode 100644 index 0000000..d10f47d --- /dev/null +++ b/docs/examples/zeromq_async_server.py @@ -0,0 +1,23 @@ +import asyncio + +import zmq +import zmq.asyncio + +from jsonrpcserver import Result, Success, async_dispatch, method + + +@method +async def ping() -> Result: + return Success("pong") + + +async def main() -> None: + socket = zmq.asyncio.Context().socket(zmq.REP) + socket.bind("tcp://*:5000") + while True: + request = await socket.recv_string() + await socket.send_string(await async_dispatch(request)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/examples/zeromq_server.py b/docs/examples/zeromq_server.py new file mode 100644 index 0000000..63deeff --- /dev/null +++ b/docs/examples/zeromq_server.py @@ -0,0 +1,18 @@ +import zmq + +from jsonrpcserver import Result, Success, dispatch, method + + +@method +def ping() -> Result: + return Success("pong") + + +if __name__ == "__main__": + socket = zmq.Context().socket(zmq.REP) + socket.bind("tcp://*:5000") + while True: + request = socket.recv_string() + # A REP socket must reply to every request, so a notification gets an + # empty message. + socket.send_string(dispatch(request)) diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..faac95c --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,58 @@ +# FAQ + +## How do I turn off request validation? + +Validating each request against the JSON-RPC schema takes about three +quarters of the dispatch time for a trivial method. If you know the requests +are valid, for example because your own code makes them, turn it off: + +```python +from jsonrpcserver import Result, Success, dispatch, method + + +@method +def ping() -> Result: + return Success("pong") + + +dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', validator=lambda _: None) +``` + +Without validation, a malformed request can give a less helpful error, but +`dispatch` still won't raise. + +## Which HTTP status code should I send? + +```python +def status(response: str) -> int: + return 200 if response else 204 +``` + +A JSON-RPC error is still a successful HTTP exchange, so send 200 with it. If +`dispatch` gives an empty string, the request was a notification and there's +no body, so send 204 No Content. The built-in `serve()` does this. + +## How do I rename a method? + +Use `@method(name="new_name")`, or pass your own dict as the +[methods](dispatch.md#methods) argument. + +## Can a method get the HTTP request, user or database connection? + +Pass it as [context](dispatch.md#context). It becomes the first argument of +every method, and the client can't change it. + +## How do I get the response as a dict instead of a string? + +Use `dispatch_to_serializable`. See +[other return types](dispatch.md#other-return-types). + +## Why does my async method give an Internal error? + +You called it with `dispatch`. Async methods need +[`async_dispatch`](async.md). + +## Where did the examples on the wiki go? + +They're on the [Examples](examples.md) page now, and CI runs each one against +a real server. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..1fc433c --- /dev/null +++ b/docs/index.md @@ -0,0 +1,65 @@ + + +# jsonrpcserver + + + +_Process incoming JSON-RPC requests in Python._ + +jsonrpcserver takes a [JSON-RPC 2.0](https://www.jsonrpc.org/specification) +request string, calls your function and gives you the response string. It +doesn't listen on a port itself, so it works with any framework or transport: +HTTP, websockets, ZeroMQ, message queues and so on. + +## Installation + +```sh +pip install jsonrpcserver +``` + +It supports Python 3.8 and later. + +## Quick start + +Write a method: + +```python +from jsonrpcserver import Result, Success, dispatch, method + + +@method +def ping() -> Result: + return Success("pong") +``` + +Then pass the request to `dispatch`: + +```python +>>> dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}') +'{"jsonrpc": "2.0", "result": "pong", "id": 1}' +``` + +Send that string back to the client. For a notification (a request without an +`id`), `dispatch` gives an empty string, meaning there's nothing to send: + +```python +>>> dispatch('{"jsonrpc": "2.0", "method": "ping"}') +'' +``` + +## Next + +- [Methods](methods.md): writing methods, parameters and errors. +- [Dispatch](dispatch.md): the options `dispatch` takes. +- [Async](async.md): `async_dispatch` for asyncio servers. +- [Examples](examples.md): Flask, FastAPI, Django, aiohttp, websockets, + ZeroMQ and more. +- [Security](security.md): what to set before you expose a server. +- [FAQ](faq.md) + +jsonrpcclient, the companion library for the other end of the connection, is +at [bensynapse/jsonrpcclient](https://github.com/bensynapse/jsonrpcclient). diff --git a/docs/methods.md b/docs/methods.md new file mode 100644 index 0000000..2b64359 --- /dev/null +++ b/docs/methods.md @@ -0,0 +1,156 @@ +# Methods + +Methods are the functions a JSON-RPC request can call. To write one, decorate +a function with `@method`: + +```python +from jsonrpcserver import Error, InvalidParams, Result, Success, dispatch, method + + +@method +def ping() -> Result: + return Success("pong") +``` + +The decorator adds the function to a dict of methods inside jsonrpcserver, +which `dispatch` uses by default. It returns the function unchanged, so you +can still call it yourself. + +To use a different name in requests, pass `name`: + +```python +@method(name="sum") +def add_numbers(a: int, b: int) -> Result: + return Success(a + b) +``` + +```python +>>> dispatch('{"jsonrpc": "2.0", "method": "sum", "params": [2, 3], "id": 1}') +'{"jsonrpc": "2.0", "result": 5, "id": 1}' +``` + +If you'd rather not use a decorator, pass the methods to `dispatch` yourself. +See [methods](dispatch.md#methods) on the Dispatch page. + +## Results + +A method returns `Success` or `Error`. They are the `result` and `error` parts +of a [JSON-RPC response](https://www.jsonrpc.org/specification#response_object). +jsonrpcserver adds the `jsonrpc` and `id` parts. + +`Success` takes the result value. If there's nothing to return, call it with +no argument and the result is `null`: + +```python +@method +def log(message: str) -> Result: + return Success() +``` + +```python +>>> dispatch('{"jsonrpc": "2.0", "method": "log", "params": ["hi"], "id": 1}') +'{"jsonrpc": "2.0", "result": null, "id": 1}' +``` + +`Error` takes a code, a message and, optionally, some data. The code must be +an integer and the message a string. + +```python +@method +def divide(a: float, b: float) -> Result: + if b == 0: + return Error(1, "Can't divide by zero", {"a": a}) + return Success(a / b) +``` + +```python +>>> dispatch('{"jsonrpc": "2.0", "method": "divide", "params": [1, 0], "id": 1}') +'{"jsonrpc": "2.0", "error": {"code": 1, "message": "Can\'t divide by zero", "data": {"a": 1}}, "id": 1}' +``` + +You can also raise `JsonRpcError`, which takes the same arguments as `Error`. +That's handy deep inside other functions: + +```python +from jsonrpcserver import JsonRpcError + + +def check_positive(number: float) -> None: + if number < 0: + raise JsonRpcError(2, "Must be positive") + + +@method +def square_root(number: float) -> Result: + check_positive(number) + return Success(number**0.5) +``` + +```python +>>> dispatch('{"jsonrpc": "2.0", "method": "square_root", "params": [-4], "id": 1}') +'{"jsonrpc": "2.0", "error": {"code": 2, "message": "Must be positive"}, "id": 1}' +``` + +Any other exception gives a -32603 "Internal error" response. The exception +isn't sent to the client, but it is logged. See [Security](security.md). + +## Parameters + +Positional and named parameters both work. jsonrpcserver checks the request's +`params` against the function's signature before calling it: + +```python +@method +def hello(name: str) -> Result: + return Success("Hello " + name) +``` + +```python +>>> dispatch('{"jsonrpc": "2.0", "method": "hello", "params": ["Beau"], "id": 1}') +'{"jsonrpc": "2.0", "result": "Hello Beau", "id": 1}' +>>> dispatch('{"jsonrpc": "2.0", "method": "hello", "params": {"name": "Beau"}, "id": 1}') +'{"jsonrpc": "2.0", "result": "Hello Beau", "id": 1}' +``` + +If they don't fit, the client gets -32602 "Invalid params": + +```python +>>> dispatch('{"jsonrpc": "2.0", "method": "hello", "params": [], "id": 1}') +'{"jsonrpc": "2.0", "error": {"code": -32602, "message": "Invalid params", "data": "missing a required argument: \'name\'"}, "id": 1}' +``` + +jsonrpcserver doesn't check the types of the values. A method gets whatever +JSON gave: `str`, `int`, `float`, `bool`, `None`, `list` or `dict`. + +## Invalid params + +To reject values yourself, return `InvalidParams`. It's a shortcut for +`Error(-32602, "Invalid params", data)`: + +```python +@method +def rate(stars: int) -> Result: + if stars not in range(1, 6): + return InvalidParams("Stars must be 1 to 5") + return Success() +``` + +```python +>>> dispatch('{"jsonrpc": "2.0", "method": "rate", "params": [6], "id": 1}') +'{"jsonrpc": "2.0", "error": {"code": -32602, "message": "Invalid params", "data": "Stars must be 1 to 5"}, "id": 1}' +``` + +## Type checking + +`@method` keeps the function's signature, so mypy and pyright check calls to +your methods as usual. + +`Result` comes from the oslash library, which has type hints but no `py.typed` +marker. pyright reads them anyway. mypy treats `Result` as `Any` unless you add +this to `pyproject.toml`: + +```toml +[[tool.mypy.overrides]] +module = ["oslash", "oslash.*"] +follow_untyped_imports = true +``` diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..f3da415 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,50 @@ +# Roadmap + +This is the plan for the next major version. Nothing here is released yet, and +it may change. 5.x keeps its current API, and gets bug and security fixes. + +Discussion happens in the +[issues](https://github.com/bensynapse/jsonrpcserver/issues) and in the draft +[6.0 pull request (#255)](https://github.com/bensynapse/jsonrpcserver/pull/255). + +## 6.0 + +These change behaviour or remove things, so they wait for a major version. + +**Results without oslash.** `Success`, `Error` and the dispatch functions are +built on the oslash library. Its last release for Python 3.8 to 3.11 came out +in 2020, and its newer releases need Python 3.12. 6.0 will replace it with plain typed +result classes, or with `returns` as #255 started. Code that only uses +`Success`, `Error` and `dispatch` should keep working. Code that inspects the +`Left` and `Right` objects from `dispatch_to_response` will need changes. + +**Safer defaults.** + +- A default `max_batch_size`, instead of no limit. +- Reject `NaN`, `Infinity` and out-of-range numbers in requests by default. +- Make an `Error` with a non-integer code an error, not a warning. The same + goes for method names starting with `rpc.`. +- Optionally, a limit on how many requests of a batch `async_dispatch` runs + at once. + +**A faster validator.** Schema validation with jsonschema takes about three +quarters of the dispatch time for small methods. jsonschema also pulls in a +Rust extension through its dependencies. A small hand-written check of the +five request fields would be faster and lighter. + +**Cleanups.** + +- Remove `serialize_error`, `serialize_success` and `to_serializable_one`, + which 5.0.10 deprecated. +- Decide whether async methods need their own decorator, as #255 proposes, + or whether `async_dispatch` keeps accepting both kinds. Use the same option + names in the sync and async functions. +- Rework or remove the built-in `serve()` development server. +- Drop Python versions that have reached end of life. + +**Ideas under discussion.** + +- Let a method return a plain value instead of `Success(value)` (#285). +- Middleware around method calls (#126). Decorators on your own functions + already cover most uses. +- OpenRPC schema generation (#156), probably as a separate package. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..2731747 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,129 @@ +# Security + +jsonrpcserver handles requests from clients you may not trust. These are the +things to know before you put a server on the internet. + +## Exception messages stay on the server + +If a method raises an exception it doesn't catch, the client gets a -32603 +"Internal error" with no `data`. The exception and its traceback are logged +through the `jsonrpcserver.dispatcher` logger (`jsonrpcserver.async_dispatcher` +for `async_dispatch`), so make sure your logging configuration keeps them. + +Before 5.0.10, the exception message was sent to the client. Messages from +database drivers and HTTP clients often contain connection strings, passwords, +hostnames, file paths or SQL. Upgrade if you're on an older version. + +`debug=True` puts the message back in the response. Use it only in +development. + +Errors you return on purpose with `Error`, `InvalidParams` or `JsonRpcError` +are sent as they are, so don't put secrets in their `data` either. + +## Limit batch size + +A single request can be a batch of thousands of requests. Each one is +validated and run. With `async_dispatch`, they all run at once. A 5 MB batch of +100,000 pings took about 4 seconds of CPU time in one test. If the methods do +I/O, a batch also multiplies the load on your database or the APIs you call. + +Set `max_batch_size` on every dispatch call: + +```python +from jsonrpcserver import dispatch + +response = dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', max_batch_size=100) +``` + +Also limit the size of the request body in your web server or framework. + +## Every parameter is up to the client + +The client chooses the arguments, positional or named, for every parameter +your method has. That includes keyword-only parameters and ones with default +values. So this is unsafe: + +```python +from jsonrpcserver import Result, Success, method + + +@method +def transfer(amount: int, *, skip_checks: bool = False) -> Result: + ... + return Success() +``` + +A client can send `{"amount": 100, "skip_checks": true}`. Keep server-side +options out of a method's signature. Pass them through `context`, which the +client can't set, or use a separate function. + +The values themselves are whatever the JSON held. jsonrpcserver doesn't check +them against your type hints, so check them in the method. + +## Only expose the methods you mean to + +`@method` adds functions to one dict for the whole process. Any module you +import that uses `@method` adds methods to it, and they can all be called +through `dispatch` unless you pass `methods`. A later `@method` with the same +name replaces the earlier one without a warning. + +For a public server, consider passing an explicit dict: + +```python +from jsonrpcserver import Result, Success, dispatch + + +def ping() -> Result: + return Success("pong") + + +METHODS = {"ping": ping} + +response = dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', METHODS) +``` + +## The built-in server is for development + +`serve()` is a small server built on Python's `http.server`. It has no TLS, +no authentication and no request size limit. Use it to try things out, and put +`dispatch` behind a real web server or framework in production. The +[examples](examples.md) show how. + +## NaN and Infinity + +Python's `json` module accepts `NaN`, `Infinity` and numbers like `1e400` in a +request, which aren't valid JSON. To reject them, pass a stricter +`deserializer`: + +```python +import json +import math + + +def reject(constant: str) -> None: + raise ValueError(f"{constant} is not valid JSON") + + +def finite_float(text: str) -> float: + number = float(text) + if math.isinf(number): + raise ValueError(f"{text} is too big") + return number + + +def strict_loads(request: str): + return json.loads(request, parse_constant=reject, parse_float=finite_float) +``` + +```python +>>> dispatch('{"jsonrpc": "2.0", "method": "ping", "params": [NaN], "id": 1}', METHODS, deserializer=strict_loads) +'{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error", "data": "NaN is not valid JSON"}, "id": null}' +``` + +Responses never contain them. The default serializer refuses to write them. + +## Reporting a vulnerability + +Please report security problems privately, through the +[Security tab](https://github.com/bensynapse/jsonrpcserver/security) on GitHub. +See [SECURITY.md](https://github.com/bensynapse/jsonrpcserver/blob/main/SECURITY.md). diff --git a/jsonrpcserver/exceptions.py b/jsonrpcserver/exceptions.py index d1e1e09..67b8426 100644 --- a/jsonrpcserver/exceptions.py +++ b/jsonrpcserver/exceptions.py @@ -9,7 +9,7 @@ class JsonRpcError(Exception): """A JsonRpcError exception can be raised from inside a method, as an alternate way to return an error response. See - https://github.com/explodinglabs/jsonrpcserver/discussions/158 + https://bensynapse.github.io/jsonrpcserver/methods/#results """ def __init__(self, code: int, message: str, data: Any = NODATA): diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..dd6c237 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,51 @@ +site_name: jsonrpcserver +site_description: Process incoming JSON-RPC requests in Python +site_url: https://bensynapse.github.io/jsonrpcserver/ +repo_url: https://github.com/bensynapse/jsonrpcserver +repo_name: bensynapse/jsonrpcserver +edit_uri: edit/main/docs/ +theme: + name: material + logo: assets/logo.png + features: + - navigation.footer + - content.code.copy + palette: + - media: "(prefers-color-scheme)" + scheme: default + primary: "pink" + toggle: + icon: material/brightness-auto + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: "pink" + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: "pink" + toggle: + icon: material/brightness-4 + name: Switch to system preference +plugins: + - search +extra_css: + - assets/extra.css +markdown_extensions: + - admonition + - pymdownx.highlight + - pymdownx.superfences + - pymdownx.snippets: + check_paths: true +nav: + - Home: index.md + - Methods: methods.md + - Dispatch: dispatch.md + - Async: async.md + - Examples: examples.md + - Security: security.md + - FAQ: faq.md + - Roadmap: roadmap.md + - Changelog: https://github.com/bensynapse/jsonrpcserver/blob/main/CHANGELOG.md diff --git a/pyproject.toml b/pyproject.toml index f757085..fc37e8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,10 @@ [tool.ruff] target-version = "py38" +[tool.ruff.format] +# Its code blocks are "--8<--" include lines, not Python. +exclude = ["docs/examples.md"] + [tool.ruff.lint] select = ["E", "W", "F", "I", "UP", "B", "C4", "SIM", "RUF"] diff --git a/requirements/docs.txt b/requirements/docs.txt new file mode 100644 index 0000000..0c525b8 --- /dev/null +++ b/requirements/docs.txt @@ -0,0 +1,2 @@ +mkdocs==1.6.1 +mkdocs-material==9.7.7 diff --git a/requirements/examples.txt b/requirements/examples.txt new file mode 100644 index 0000000..6314829 --- /dev/null +++ b/requirements/examples.txt @@ -0,0 +1,14 @@ +# The libraries the docs examples use. CI runs the examples on Python 3.13. +aiohttp==3.14.4 +django==6.1.1 +fastapi==0.142.2 +flask==3.1.3 +flask-socketio==5.6.1 +python-socketio[client]==5.17.0 +pyzmq==27.2.0 +sanic==25.12.1 +tornado==6.5.10 +ujson==6.0.0 +uvicorn==0.54.0 +websockets==17.2 +werkzeug==3.1.9 diff --git a/tests/doc_examples.py b/tests/doc_examples.py new file mode 100644 index 0000000..e8aa78d --- /dev/null +++ b/tests/doc_examples.py @@ -0,0 +1,104 @@ +"""Run the Python examples in Markdown files. + +Usage: python tests/doc_examples.py FILE.md [FILE.md ...] + +Each file runs in this process with one shared namespace, top to bottom, so +request ids count up from 1 as they do for a reader trying the examples. Run +each file in a fresh interpreter (tests/test_docs.py does). + +A python block that contains ">>>" is a doctest: the output must match, and +"..." matches anything. Any other python block just has to run. + +An HTML comment on the line before a block can skip it: + skip unless ujson can be imported + skip on older Pythons +Blocks that include a file with "--8<--" are skipped. Those are the transport +examples, which docs/examples/check_examples.py runs against a test server. +""" + +import doctest +import importlib.util +import re +import sys +from pathlib import Path +from typing import Any, Dict, List, NamedTuple + +FENCE = re.compile(r"^```\s*(\w*)\s*$") +MARKER = re.compile(r"^$") + + +class Block(NamedTuple): + line: int + code: str + marker: str + + +def python_blocks(text: str) -> List[Block]: + blocks: List[Block] = [] + lines = text.splitlines() + i = 0 + while i < len(lines): + match = FENCE.match(lines[i]) + if not match: + i += 1 + continue + language, start = match.group(1), i + i += 1 + body: List[str] = [] + while i < len(lines) and not FENCE.match(lines[i]): + body.append(lines[i]) + i += 1 + i += 1 + if language in ("python", "py", "pycon"): + marker = lines[start - 1].strip() if start > 0 else "" + blocks.append(Block(start + 1, "\n".join(body) + "\n", marker)) + return blocks + + +def skip_reason(block: Block) -> str: + if "--8<--" in block.code: + return "included file" + match = MARKER.match(block.marker) + if not match: + return "" + kind, value = match.groups() + if kind == "requires": + return "" if importlib.util.find_spec(value) else f"needs {value}" + wanted = tuple(int(part) for part in value.split(".")) + return f"needs Python {value}" if sys.version_info < wanted else "" + + +def run_file(path: Path) -> int: + namespace: Dict[str, Any] = {"__name__": "__main__"} + runner = doctest.DocTestRunner( + optionflags=doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE + ) + parser = doctest.DocTestParser() + failures = ran = skipped = 0 + for block in python_blocks(path.read_text()): + reason = skip_reason(block) + if reason: + print(f"{path}:{block.line}: skipped ({reason})") + skipped += 1 + continue + ran += 1 + if ">>>" in block.code: + test = parser.get_doctest( + block.code, namespace, str(path), str(path), block.line + ) + failures += runner.run(test, clear_globs=False).failed + # DocTest works on a copy of the namespace. Keep what it defined. + namespace.update(test.globs) + else: + try: + exec(compile(block.code, f"{path}:{block.line}", "exec"), namespace) + except Exception as exc: + print(f"{path}:{block.line}: {type(exc).__name__}: {exc}") + failures += 1 + print(f"{path}: {ran} blocks run, {skipped} skipped, {failures} failed") + return failures + + +if __name__ == "__main__": + total = sum(run_file(Path(arg)) for arg in sys.argv[1:]) + sys.exit(1 if total else 0) diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 0000000..b6abe50 --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,22 @@ +"""Every Python example in the README and docs must run and show true output.""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).parent.parent +FILES = [ROOT / "README.md", *sorted((ROOT / "docs").glob("*.md"))] + + +@pytest.mark.parametrize("path", FILES, ids=lambda path: path.name) +def test_doc_examples(path: Path) -> None: + # A fresh interpreter per file, so methods registered with @method on one + # page don't leak into another. + result = subprocess.run( + [sys.executable, str(ROOT / "tests" / "doc_examples.py"), str(path)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr