Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 44 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ on:
branches: [main, master, dev]

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v4
with:
enable-cache: true
- name: Sync (locked) dev environment
run: uv sync --frozen --extra dev
- name: Ruff (check + format)
run: |
uv run ruff check oxyroute tests examples
uv run ruff format --check oxyroute tests examples
- name: rustfmt + clippy
run: |
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings

test:
strategy:
fail-fast: false
Expand All @@ -23,17 +47,28 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
# 3.14 may be pre-release on some runners until GA; keep CI green across the full range.
allow-prerelease: true
- name: Install build deps
run: |
python -m pip install --upgrade pip
python -m pip install maturin pytest oxyjwt httpx granian
- name: Build and install oxyroute
- uses: astral-sh/setup-uv@v4
with:
enable-cache: true
- name: Install locked deps, build and install oxyroute
run: |
maturin build --release
python -m pip install target/wheels/oxyroute-*.whl
set -euo pipefail
uv sync --frozen --extra dev
# `uv sync` can leave a prior wheel; `maturin build` adds another (different platform tag).
# `uv pip install` with two file URLs errors with "conflicting URLs for package oxyroute".
rm -f target/wheels/oxyroute-*.whl
uv run maturin build --release
shopt -s nullglob
wheels=(target/wheels/oxyroute-*.whl)
shopt -u nullglob
if [ "${#wheels[@]}" -ne 1 ] || [ ! -f "${wheels[0]}" ]; then
echo "expected exactly one oxyroute wheel, got: ${#wheels[@]}"
ls -la target/wheels/ 2>/dev/null || true
exit 1
fi
uv pip install --force-reinstall "${wheels[0]}"
- name: Pytest (isolated dir so repo tree does not shadow the installed package)
run: |
cd "$(mktemp -d)"
python -m pytest "$GITHUB_WORKSPACE/tests" -v
UV_PROJECT="$GITHUB_WORKSPACE" uv run python -m pytest "$GITHUB_WORKSPACE/tests" -v
10 changes: 8 additions & 2 deletions docs/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

[← Documentation index](index.md)

OxyRoute supports a **linear** list of **named** dependency factories. At request time, each factory is called in order; its return value is injected into the route handler as a **keyword argument** with the given name.
OxyRoute supports a **linear** list of **named** dependency factories. At request time, each factory is called in order; its return value is injected into the route handler as a **keyword argument** with the given name. Factories that appear **later** in the list are called with **keyword arguments** for every **earlier** name and value (so a factory can depend on a previous one by using the same parameter name, e.g. `def b(a: int): …` when the first tuple is `("a", make_a)`).

### Request context (optional)

If a factory’s signature includes a parameter named `request`, the extension passes a **dict** (once per request, shared) with string keys: `method`, `path`, `query_string`, and `headers` (a flat `str` → `str` map, when the underlying RSGI scope exposes headers—see the ASGI bridge in `oxyroute.asgi`). Factories that do **not** declare `request` are still called with **no** extra arguments when they have no prior dependencies, preserving older behavior.

## Declaring on a route

Expand All @@ -17,7 +21,9 @@ def list_items(db: str) -> str:
return f"ok {db}"
```

`factory` can be **sync** or **async** (the extension detects `async` factories and awaits them in order).
`factory` can be **sync** or **async** (the extension detects `async` factories and awaits them in order). Dependency **names** must be **unique** in the list.

**Example (chaining):** `dependencies=[("a", make_a), ("b", make_b)]` with `def make_b(a): return a + 1` — the second callable receives the value bound to `a`.

## `Depends` marker

Expand Down
2 changes: 1 addition & 1 deletion docs/handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ handler(**kwargs)
| `json` | `read_json_body` is true and body parses as JSON | `dict`/list/values as converted from `serde_json` to Python |
| `body` | Raw body bytes, when JSON is not used or empty | `bytes` |
| `claims` | `require_jwt` is true and the JWT validates | The decoded JSON claims as a Python object (typically a `dict`) |
| Named dependencies | `dependencies=[("name", factory), ...]` | Return value of each factory, in order (see [dependencies.md](dependencies.md)) |
| Named dependencies | `dependencies=[("name", factory), ...]` | Return value of each factory, in order (see [dependencies.md](dependencies.md)). Only dependencies whose **names** appear on the route handler’s signature (or `**kwargs`) are passed to the handler—intermediate-only dependencies are not forwarded |

**JWT:** if `require_jwt` is set but validation fails, the **handler is not called**; the response is 401 (or a dedicated “Expired” string for expired signature when applicable). See [jwt.md](jwt.md).

Expand Down
5 changes: 2 additions & 3 deletions oxyroute/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
# Native extension first — prevents circular import with `app` importing `_oxyroute`.
import oxyroute._oxyroute # noqa: F401

from oxyroute.app import App, Depends
from oxyroute._oxyroute import decode_jwt_hs
from oxyroute.app import App, Depends
from oxyroute.response import Response

__all__ = ["App", "Depends", "Response", "decode_jwt_hs", "__version__"]
__all__ = ["App", "Depends", "Response", "__version__", "decode_jwt_hs"]
__version__ = "0.1.0"
85 changes: 43 additions & 42 deletions oxyroute/app.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from __future__ import annotations

from typing import Any, Callable, List, Optional, Tuple, TypeVar, Union
from collections.abc import Callable
from typing import Any, TypeVar

from . import _oxyroute
from .asgi import build_asgi_caller

F = TypeVar("F", bound=Callable[..., Any])
Dep = Union[Callable[..., Any], _oxyroute.PyDepends]
Dep = Callable[..., Any] | _oxyroute.PyDepends


def _unwrap_dep(f: Dep) -> Any:
Expand All @@ -16,8 +17,8 @@ def _unwrap_dep(f: Dep) -> Any:


def _norm_dependencies(
deps: Optional[List[Tuple[str, Dep]]],
) -> Optional[List[Tuple[str, Any]]]:
deps: list[tuple[str, Dep]] | None,
) -> list[tuple[str, Any]] | None:
if not deps:
return None
return [(n, _unwrap_dep(c)) for n, c in deps]
Expand Down Expand Up @@ -53,12 +54,12 @@ def get(
path: str,
*,
require_jwt: bool = False,
jwt_secret: Optional[str] = None,
algorithms: Optional[List[str]] = None,
jwt_issuer: Optional[str] = None,
jwt_audience: Optional[str] = None,
jwt_leeway: Optional[int] = None,
dependencies: Optional[List[Tuple[str, Dep]]] = None,
jwt_secret: str | None = None,
algorithms: list[str] | None = None,
jwt_issuer: str | None = None,
jwt_audience: str | None = None,
jwt_leeway: int | None = None,
dependencies: list[tuple[str, Dep]] | None = None,
) -> Callable[[F], F]:
return self._route(
"GET",
Expand All @@ -78,13 +79,13 @@ def post(
path: str,
*,
require_jwt: bool = False,
jwt_secret: Optional[str] = None,
algorithms: Optional[List[str]] = None,
jwt_secret: str | None = None,
algorithms: list[str] | None = None,
read_json_body: bool = True,
jwt_issuer: Optional[str] = None,
jwt_audience: Optional[str] = None,
jwt_leeway: Optional[int] = None,
dependencies: Optional[List[Tuple[str, Dep]]] = None,
jwt_issuer: str | None = None,
jwt_audience: str | None = None,
jwt_leeway: int | None = None,
dependencies: list[tuple[str, Dep]] | None = None,
) -> Callable[[F], F]:
return self._route(
"POST",
Expand All @@ -104,12 +105,12 @@ def put(
path: str,
*,
require_jwt: bool = False,
jwt_secret: Optional[str] = None,
algorithms: Optional[List[str]] = None,
jwt_issuer: Optional[str] = None,
jwt_audience: Optional[str] = None,
jwt_leeway: Optional[int] = None,
dependencies: Optional[List[Tuple[str, Dep]]] = None,
jwt_secret: str | None = None,
algorithms: list[str] | None = None,
jwt_issuer: str | None = None,
jwt_audience: str | None = None,
jwt_leeway: int | None = None,
dependencies: list[tuple[str, Dep]] | None = None,
) -> Callable[[F], F]:
return self._route(
"PUT",
Expand All @@ -129,13 +130,13 @@ def patch(
path: str,
*,
require_jwt: bool = False,
jwt_secret: Optional[str] = None,
algorithms: Optional[List[str]] = None,
jwt_secret: str | None = None,
algorithms: list[str] | None = None,
read_json_body: bool = True,
jwt_issuer: Optional[str] = None,
jwt_audience: Optional[str] = None,
jwt_leeway: Optional[int] = None,
dependencies: Optional[List[Tuple[str, Dep]]] = None,
jwt_issuer: str | None = None,
jwt_audience: str | None = None,
jwt_leeway: int | None = None,
dependencies: list[tuple[str, Dep]] | None = None,
) -> Callable[[F], F]:
return self._route(
"PATCH",
Expand All @@ -155,12 +156,12 @@ def delete(
path: str,
*,
require_jwt: bool = False,
jwt_secret: Optional[str] = None,
algorithms: Optional[List[str]] = None,
jwt_issuer: Optional[str] = None,
jwt_audience: Optional[str] = None,
jwt_leeway: Optional[int] = None,
dependencies: Optional[List[Tuple[str, Dep]]] = None,
jwt_secret: str | None = None,
algorithms: list[str] | None = None,
jwt_issuer: str | None = None,
jwt_audience: str | None = None,
jwt_leeway: int | None = None,
dependencies: list[tuple[str, Dep]] | None = None,
) -> Callable[[F], F]:
return self._route(
"DELETE",
Expand All @@ -180,14 +181,14 @@ def _route(
method: str,
path: str,
require_jwt: bool,
jwt_secret: Optional[str],
algorithms: Optional[List[str]],
jwt_secret: str | None,
algorithms: list[str] | None,
read_json_body: bool,
dependencies: Optional[List[Tuple[str, Dep]]],
dependencies: list[tuple[str, Dep]] | None,
*,
jwt_issuer: Optional[str] = None,
jwt_audience: Optional[str] = None,
jwt_leeway: Optional[int] = None,
jwt_issuer: str | None = None,
jwt_audience: str | None = None,
jwt_leeway: int | None = None,
) -> Callable[[F], F]:
dlist = _norm_dependencies(dependencies)

Expand All @@ -209,11 +210,11 @@ def wrap(handler: F) -> F:

return wrap

async def __rsgi_init__(self, *args: Any, **kwargs: Any) -> None: # noqa: D401
async def __rsgi_init__(self, *args: Any, **kwargs: Any) -> None:
"""Lifespan hook (no-op). Granian may pass extra positional args; accept **kwargs."""
return None

async def __rsgi_del__(self, *args: Any, **kwargs: Any) -> None: # noqa: D401
async def __rsgi_del__(self, *args: Any, **kwargs: Any) -> None:
"""Lifespan teardown (no-op). Accept extra args for Granian compatibility."""
return None

Expand Down
35 changes: 18 additions & 17 deletions oxyroute/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
from __future__ import annotations

import asyncio
from typing import Any, Callable, Dict, List, Optional, Tuple
from collections.abc import Callable
from typing import Any


def _hdr_from_asgi(raw: List[Tuple[bytes, bytes]]) -> "_HeaderView":
d: Dict[str, str] = {}
def _hdr_from_asgi(raw: list[tuple[bytes, bytes]]) -> _HeaderView:
d: dict[str, str] = {}
for k, v in raw:
dk = k.decode("latin-1").lower()
d[dk] = v.decode("latin-1")
Expand All @@ -22,7 +23,7 @@ def _hdr_from_asgi(raw: List[Tuple[bytes, bytes]]) -> "_HeaderView":
class _HeaderView:
__slots__ = ("_d",)

def __init__(self, d: Dict[str, str]) -> None:
def __init__(self, d: dict[str, str]) -> None:
self._d = d

def get(self, k: str, default: str = "") -> str:
Expand All @@ -31,17 +32,17 @@ def get(self, k: str, default: str = "") -> str:

class _RsgiScope:
__slots__ = (
"proto",
"authority",
"client",
"headers",
"http_version",
"method",
"path",
"proto",
"query_string",
"headers",
"rsgi_version",
"http_version",
"server",
"client",
"scheme",
"authority",
"server",
)

def __init__(
Expand All @@ -62,13 +63,13 @@ def __init__(
self.path = path
self.query_string = query_string
self.headers = headers
self.authority: Optional[str] = None
self.authority: str | None = None


def _norm_headers_asgi(
rsgi_headers: list,
) -> List[Tuple[bytes, bytes]]:
out: List[Tuple[bytes, bytes]] = []
) -> list[tuple[bytes, bytes]]:
out: list[tuple[bytes, bytes]] = []
for p in rsgi_headers:
if not isinstance(p, (list, tuple)) or len(p) != 2:
continue
Expand All @@ -83,13 +84,13 @@ def _norm_headers_asgi(


class _RsgiProtocol:
__slots__ = ("_body", "_send", "_loop", "status", "_status")
__slots__ = ("_body", "_loop", "_send", "_status", "status")

def __init__(self, body: bytes, send: Any, main_loop: asyncio.AbstractEventLoop) -> None:
self._body = body
self._send = send
self._loop = main_loop
self._status: Optional[int] = None
self._status: int | None = None
self.status = 200

def _run_send(self, coro: Any) -> None:
Expand Down Expand Up @@ -171,7 +172,7 @@ async def _go() -> None:

async def asgi_to_rsgi(
app_rsgi: Callable[[Any, Any], Any],
scope: Dict[str, Any],
scope: dict[str, Any],
receive: Any,
send: Any,
) -> None:
Expand Down Expand Up @@ -220,7 +221,7 @@ def _rsgi(s: Any, p: Any) -> Any:
inner = getattr(c, "_app", c)
return inner.handle_rsgi(s, p)

async def asgi3(scope: Dict[str, Any], receive: Any, send: Any) -> None:
async def asgi3(scope: dict[str, Any], receive: Any, send: Any) -> None:
await asgi_to_rsgi(_rsgi, scope, receive, send)

return asgi3
3 changes: 2 additions & 1 deletion oxyroute/response.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from __future__ import annotations

from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Mapping, Sequence
from typing import Any

__all__ = ["Response"]

Expand Down
Loading
Loading