Skip to content

Commit 6d1a2bf

Browse files
Merge pull request #29 from QueryaHub/feat/di-chain-request
Feat/di chain request
2 parents e532c47 + ff9751e commit 6d1a2bf

24 files changed

Lines changed: 878 additions & 228 deletions

.github/workflows/ci.yml

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,30 @@ on:
77
branches: [main, master, dev]
88

99
jobs:
10+
lint:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: dtolnay/rust-toolchain@stable
15+
with:
16+
components: rustfmt, clippy
17+
- uses: actions/setup-python@v5
18+
with:
19+
python-version: "3.12"
20+
- uses: astral-sh/setup-uv@v4
21+
with:
22+
enable-cache: true
23+
- name: Sync (locked) dev environment
24+
run: uv sync --frozen --extra dev
25+
- name: Ruff (check + format)
26+
run: |
27+
uv run ruff check oxyroute tests examples
28+
uv run ruff format --check oxyroute tests examples
29+
- name: rustfmt + clippy
30+
run: |
31+
cargo fmt --all -- --check
32+
cargo clippy --all-targets -- -D warnings
33+
1034
test:
1135
strategy:
1236
fail-fast: false
@@ -23,17 +47,28 @@ jobs:
2347
- uses: actions/setup-python@v5
2448
with:
2549
python-version: ${{ matrix.python-version }}
26-
# 3.14 may be pre-release on some runners until GA; keep CI green across the full range.
2750
allow-prerelease: true
28-
- name: Install build deps
29-
run: |
30-
python -m pip install --upgrade pip
31-
python -m pip install maturin pytest oxyjwt httpx granian
32-
- name: Build and install oxyroute
51+
- uses: astral-sh/setup-uv@v4
52+
with:
53+
enable-cache: true
54+
- name: Install locked deps, build and install oxyroute
3355
run: |
34-
maturin build --release
35-
python -m pip install target/wheels/oxyroute-*.whl
56+
set -euo pipefail
57+
uv sync --frozen --extra dev
58+
# `uv sync` can leave a prior wheel; `maturin build` adds another (different platform tag).
59+
# `uv pip install` with two file URLs errors with "conflicting URLs for package oxyroute".
60+
rm -f target/wheels/oxyroute-*.whl
61+
uv run maturin build --release
62+
shopt -s nullglob
63+
wheels=(target/wheels/oxyroute-*.whl)
64+
shopt -u nullglob
65+
if [ "${#wheels[@]}" -ne 1 ] || [ ! -f "${wheels[0]}" ]; then
66+
echo "expected exactly one oxyroute wheel, got: ${#wheels[@]}"
67+
ls -la target/wheels/ 2>/dev/null || true
68+
exit 1
69+
fi
70+
uv pip install --force-reinstall "${wheels[0]}"
3671
- name: Pytest (isolated dir so repo tree does not shadow the installed package)
3772
run: |
3873
cd "$(mktemp -d)"
39-
python -m pytest "$GITHUB_WORKSPACE/tests" -v
74+
UV_PROJECT="$GITHUB_WORKSPACE" uv run python -m pytest "$GITHUB_WORKSPACE/tests" -v

docs/dependencies.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
[← Documentation index](index.md)
44

5-
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.
5+
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)`).
6+
7+
### Request context (optional)
8+
9+
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.
610

711
## Declaring on a route
812

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

20-
`factory` can be **sync** or **async** (the extension detects `async` factories and awaits them in order).
24+
`factory` can be **sync** or **async** (the extension detects `async` factories and awaits them in order). Dependency **names** must be **unique** in the list.
25+
26+
**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`.
2127

2228
## `Depends` marker
2329

docs/handlers.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ handler(**kwargs)
1919
| `json` | `read_json_body` is true and body parses as JSON | `dict`/list/values as converted from `serde_json` to Python |
2020
| `body` | Raw body bytes, when JSON is not used or empty | `bytes` |
2121
| `claims` | `require_jwt` is true and the JWT validates | The decoded JSON claims as a Python object (typically a `dict`) |
22-
| Named dependencies | `dependencies=[("name", factory), ...]` | Return value of each factory, in order (see [dependencies.md](dependencies.md)) |
22+
| 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 |
2323

2424
**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).
2525

oxyroute/__init__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
# Native extension first — prevents circular import with `app` importing `_oxyroute`.
22
import oxyroute._oxyroute # noqa: F401
3-
4-
from oxyroute.app import App, Depends
53
from oxyroute._oxyroute import decode_jwt_hs
4+
from oxyroute.app import App, Depends
65
from oxyroute.response import Response
76

8-
__all__ = ["App", "Depends", "Response", "decode_jwt_hs", "__version__"]
7+
__all__ = ["App", "Depends", "Response", "__version__", "decode_jwt_hs"]
98
__version__ = "0.1.0"

oxyroute/app.py

Lines changed: 43 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from __future__ import annotations
22

3-
from typing import Any, Callable, List, Optional, Tuple, TypeVar, Union
3+
from collections.abc import Callable
4+
from typing import Any, TypeVar
45

56
from . import _oxyroute
67
from .asgi import build_asgi_caller
78

89
F = TypeVar("F", bound=Callable[..., Any])
9-
Dep = Union[Callable[..., Any], _oxyroute.PyDepends]
10+
Dep = Callable[..., Any] | _oxyroute.PyDepends
1011

1112

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

1718

1819
def _norm_dependencies(
19-
deps: Optional[List[Tuple[str, Dep]]],
20-
) -> Optional[List[Tuple[str, Any]]]:
20+
deps: list[tuple[str, Dep]] | None,
21+
) -> list[tuple[str, Any]] | None:
2122
if not deps:
2223
return None
2324
return [(n, _unwrap_dep(c)) for n, c in deps]
@@ -53,12 +54,12 @@ def get(
5354
path: str,
5455
*,
5556
require_jwt: bool = False,
56-
jwt_secret: Optional[str] = None,
57-
algorithms: Optional[List[str]] = None,
58-
jwt_issuer: Optional[str] = None,
59-
jwt_audience: Optional[str] = None,
60-
jwt_leeway: Optional[int] = None,
61-
dependencies: Optional[List[Tuple[str, Dep]]] = None,
57+
jwt_secret: str | None = None,
58+
algorithms: list[str] | None = None,
59+
jwt_issuer: str | None = None,
60+
jwt_audience: str | None = None,
61+
jwt_leeway: int | None = None,
62+
dependencies: list[tuple[str, Dep]] | None = None,
6263
) -> Callable[[F], F]:
6364
return self._route(
6465
"GET",
@@ -78,13 +79,13 @@ def post(
7879
path: str,
7980
*,
8081
require_jwt: bool = False,
81-
jwt_secret: Optional[str] = None,
82-
algorithms: Optional[List[str]] = None,
82+
jwt_secret: str | None = None,
83+
algorithms: list[str] | None = None,
8384
read_json_body: bool = True,
84-
jwt_issuer: Optional[str] = None,
85-
jwt_audience: Optional[str] = None,
86-
jwt_leeway: Optional[int] = None,
87-
dependencies: Optional[List[Tuple[str, Dep]]] = None,
85+
jwt_issuer: str | None = None,
86+
jwt_audience: str | None = None,
87+
jwt_leeway: int | None = None,
88+
dependencies: list[tuple[str, Dep]] | None = None,
8889
) -> Callable[[F], F]:
8990
return self._route(
9091
"POST",
@@ -104,12 +105,12 @@ def put(
104105
path: str,
105106
*,
106107
require_jwt: bool = False,
107-
jwt_secret: Optional[str] = None,
108-
algorithms: Optional[List[str]] = None,
109-
jwt_issuer: Optional[str] = None,
110-
jwt_audience: Optional[str] = None,
111-
jwt_leeway: Optional[int] = None,
112-
dependencies: Optional[List[Tuple[str, Dep]]] = None,
108+
jwt_secret: str | None = None,
109+
algorithms: list[str] | None = None,
110+
jwt_issuer: str | None = None,
111+
jwt_audience: str | None = None,
112+
jwt_leeway: int | None = None,
113+
dependencies: list[tuple[str, Dep]] | None = None,
113114
) -> Callable[[F], F]:
114115
return self._route(
115116
"PUT",
@@ -129,13 +130,13 @@ def patch(
129130
path: str,
130131
*,
131132
require_jwt: bool = False,
132-
jwt_secret: Optional[str] = None,
133-
algorithms: Optional[List[str]] = None,
133+
jwt_secret: str | None = None,
134+
algorithms: list[str] | None = None,
134135
read_json_body: bool = True,
135-
jwt_issuer: Optional[str] = None,
136-
jwt_audience: Optional[str] = None,
137-
jwt_leeway: Optional[int] = None,
138-
dependencies: Optional[List[Tuple[str, Dep]]] = None,
136+
jwt_issuer: str | None = None,
137+
jwt_audience: str | None = None,
138+
jwt_leeway: int | None = None,
139+
dependencies: list[tuple[str, Dep]] | None = None,
139140
) -> Callable[[F], F]:
140141
return self._route(
141142
"PATCH",
@@ -155,12 +156,12 @@ def delete(
155156
path: str,
156157
*,
157158
require_jwt: bool = False,
158-
jwt_secret: Optional[str] = None,
159-
algorithms: Optional[List[str]] = None,
160-
jwt_issuer: Optional[str] = None,
161-
jwt_audience: Optional[str] = None,
162-
jwt_leeway: Optional[int] = None,
163-
dependencies: Optional[List[Tuple[str, Dep]]] = None,
159+
jwt_secret: str | None = None,
160+
algorithms: list[str] | None = None,
161+
jwt_issuer: str | None = None,
162+
jwt_audience: str | None = None,
163+
jwt_leeway: int | None = None,
164+
dependencies: list[tuple[str, Dep]] | None = None,
164165
) -> Callable[[F], F]:
165166
return self._route(
166167
"DELETE",
@@ -180,14 +181,14 @@ def _route(
180181
method: str,
181182
path: str,
182183
require_jwt: bool,
183-
jwt_secret: Optional[str],
184-
algorithms: Optional[List[str]],
184+
jwt_secret: str | None,
185+
algorithms: list[str] | None,
185186
read_json_body: bool,
186-
dependencies: Optional[List[Tuple[str, Dep]]],
187+
dependencies: list[tuple[str, Dep]] | None,
187188
*,
188-
jwt_issuer: Optional[str] = None,
189-
jwt_audience: Optional[str] = None,
190-
jwt_leeway: Optional[int] = None,
189+
jwt_issuer: str | None = None,
190+
jwt_audience: str | None = None,
191+
jwt_leeway: int | None = None,
191192
) -> Callable[[F], F]:
192193
dlist = _norm_dependencies(dependencies)
193194

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

210211
return wrap
211212

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

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

oxyroute/asgi.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
from __future__ import annotations
99

1010
import asyncio
11-
from typing import Any, Callable, Dict, List, Optional, Tuple
11+
from collections.abc import Callable
12+
from typing import Any
1213

1314

14-
def _hdr_from_asgi(raw: List[Tuple[bytes, bytes]]) -> "_HeaderView":
15-
d: Dict[str, str] = {}
15+
def _hdr_from_asgi(raw: list[tuple[bytes, bytes]]) -> _HeaderView:
16+
d: dict[str, str] = {}
1617
for k, v in raw:
1718
dk = k.decode("latin-1").lower()
1819
d[dk] = v.decode("latin-1")
@@ -22,7 +23,7 @@ def _hdr_from_asgi(raw: List[Tuple[bytes, bytes]]) -> "_HeaderView":
2223
class _HeaderView:
2324
__slots__ = ("_d",)
2425

25-
def __init__(self, d: Dict[str, str]) -> None:
26+
def __init__(self, d: dict[str, str]) -> None:
2627
self._d = d
2728

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

3233
class _RsgiScope:
3334
__slots__ = (
34-
"proto",
35+
"authority",
36+
"client",
37+
"headers",
38+
"http_version",
3539
"method",
3640
"path",
41+
"proto",
3742
"query_string",
38-
"headers",
3943
"rsgi_version",
40-
"http_version",
41-
"server",
42-
"client",
4344
"scheme",
44-
"authority",
45+
"server",
4546
)
4647

4748
def __init__(
@@ -62,13 +63,13 @@ def __init__(
6263
self.path = path
6364
self.query_string = query_string
6465
self.headers = headers
65-
self.authority: Optional[str] = None
66+
self.authority: str | None = None
6667

6768

6869
def _norm_headers_asgi(
6970
rsgi_headers: list,
70-
) -> List[Tuple[bytes, bytes]]:
71-
out: List[Tuple[bytes, bytes]] = []
71+
) -> list[tuple[bytes, bytes]]:
72+
out: list[tuple[bytes, bytes]] = []
7273
for p in rsgi_headers:
7374
if not isinstance(p, (list, tuple)) or len(p) != 2:
7475
continue
@@ -83,13 +84,13 @@ def _norm_headers_asgi(
8384

8485

8586
class _RsgiProtocol:
86-
__slots__ = ("_body", "_send", "_loop", "status", "_status")
87+
__slots__ = ("_body", "_loop", "_send", "_status", "status")
8788

8889
def __init__(self, body: bytes, send: Any, main_loop: asyncio.AbstractEventLoop) -> None:
8990
self._body = body
9091
self._send = send
9192
self._loop = main_loop
92-
self._status: Optional[int] = None
93+
self._status: int | None = None
9394
self.status = 200
9495

9596
def _run_send(self, coro: Any) -> None:
@@ -171,7 +172,7 @@ async def _go() -> None:
171172

172173
async def asgi_to_rsgi(
173174
app_rsgi: Callable[[Any, Any], Any],
174-
scope: Dict[str, Any],
175+
scope: dict[str, Any],
175176
receive: Any,
176177
send: Any,
177178
) -> None:
@@ -220,7 +221,7 @@ def _rsgi(s: Any, p: Any) -> Any:
220221
inner = getattr(c, "_app", c)
221222
return inner.handle_rsgi(s, p)
222223

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

226227
return asgi3

oxyroute/response.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ import annotations
22

3+
from collections.abc import Mapping, Sequence
34
from dataclasses import dataclass
4-
from typing import Any, Mapping, Sequence
5+
from typing import Any
56

67
__all__ = ["Response"]
78

0 commit comments

Comments
 (0)