From 74f72d420f994f53dd50c23d2356e3d01fc4baaa Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Apr 2026 16:37:40 +0300 Subject: [PATCH 1/8] docs: add dev-centric branching workflow and link from index --- docs/development-workflow.md | 53 ++++++++++++++++++++++++++++++++++++ docs/index.md | 1 + 2 files changed, 54 insertions(+) create mode 100644 docs/development-workflow.md diff --git a/docs/development-workflow.md b/docs/development-workflow.md new file mode 100644 index 0000000..2927541 --- /dev/null +++ b/docs/development-workflow.md @@ -0,0 +1,53 @@ +# Development workflow: branches and pull requests + +OxyRoute uses **`dev` as the integration branch**. `main` (or the default production branch) is updated from `dev` when maintainers cut a release or merge a stable snapshot—follow what your team documents for release tagging. + +## Branching model + +1. **Always branch from the latest `dev`:** + + ```bash + git fetch origin + git checkout dev + git pull origin dev + ``` + +2. **Create a branch for one GitHub issue** (one topic per PR when possible): + + ```bash + git checkout -b issue-12-rsgi-e2e + # or: feat/12-rsgi-e2e — use a short, kebab-case slug + ``` + + Prefer including the **issue number** in the name so the PR and issue stay linked (e.g. #12). + +3. **Implement the issue** with **small, atomic commits** (one logical change per commit: `fix: …`, `test: …`, `docs: …`). + +4. **Do not mix** code changes and **`.github/ISSUE_BACKLOG/`** (issue template bodies) in the same commit. The backlog is reference material for maintainers; if you need to update it, use a **separate** commit, e.g. `docs: update issue backlog description`. + +5. **Push and open a pull request** with **base = `dev`** and **compare = your branch**. + + ```bash + git push -u origin issue-12-rsgi-e2e + ``` + + On GitHub: **New pull request** → base repository `QueryaHub/OxyRoute`, **base: `dev`**, **compare: `issue-12-rsgi-e2e`**. + +6. In the PR description, use **`Closes #N`** (or `Fixes #N`) if the work fully finishes issue **N**—GitHub will close the issue when the PR is merged. + +7. After merge into `dev`, delete the remote branch (GitHub can do this on merge) and locally: + `git branch -d issue-12-rsgi-e2e` , then continue with the next issue from a fresh `dev`. + +## What not to do + +- Do **not** run `scripts/create-github-issues.sh` again unless you want **duplicate** issues on GitHub. +- Do **not** open a feature PR with base `main` unless the maintainers explicitly ask for a hotfix. + +## `gh` CLI (optional) + +```bash +gh pr create --base dev --head YOUR_BRANCH --title "..." --body "Closes #12" +gh pr list --base dev +``` + +[← Back to documentation index](index.md) · [Contributing (short)](../CONTRIBUTING.md) diff --git a/docs/index.md b/docs/index.md index 71d1f23..608c9c0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,6 +46,7 @@ Granian still invokes a Python `App` object; the “win” is doing routing, bod | [OpenAPI](openapi.md) | `openapi.json` route, title, `openapi_json()` | | [ASGI bridge](asgi.md) | Optional `__call__` for ASGI 3, limitations | | [Development](development.md) | Tests, CI, clippy, running pytest safely | +| [Branching and PRs](development-workflow.md) | `dev` as base, issue branches, `Closes #N`, no mixing code with `ISSUE_BACKLOG` in one commit | | [Contributing](../CONTRIBUTING.md) | Local setup, issue backlog, GitHub `gh` workflow | [← Back to project README](../README.md) From 59beca5ee6cc5470b1c5a4ac4e6b5a354c9bf3f4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Apr 2026 16:37:41 +0300 Subject: [PATCH 2/8] chore: add PR template and dev workflow notes in CONTRIBUTING --- .github/pull_request_template.md | 18 ++++++++++++++++++ CONTRIBUTING.md | 10 ++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..9d3806f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## Summary + +- What this PR does (1–3 sentences). +- If it closes a ticket: `Closes #N` (replace N). + +## Base branch + +- [ ] This PR targets **`dev`**, not `main` (unless maintainers asked for a hotfix). + +## Checklist + +- [ ] `cargo build` (and `cargo clippy` if you touched Rust) +- [ ] `maturin develop` + tests as in [docs/development.md](docs/development.md) (e.g. pytest from a clean cwd / installed wheel) +- [ ] No unrelated drive-by refactors; commits are [atomic and scoped](https://github.com/QueryaHub/OxyRoute/blob/main/docs/development-workflow.md) + +## Note on `.github/ISSUE_BACKLOG/` + +If you only touch issue template files under [`.github/ISSUE_BACKLOG/`](.github/ISSUE_BACKLOG/) (not application code), say so in the summary. Prefer a **separate** PR for backlog-only edits when possible. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6637673..0d34db0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,11 +40,13 @@ cargo build cargo clippy ``` -## Branches and commits +## Branches, PRs, and atomic commits -- Use **topic branches** off `main` (e.g. `fix/query-decode`, `feat/patch-decorator`). -- Prefer **conventional commits** where possible: `feat:`, `fix:`, `docs:`, `ci:`, `chore:`, `test:`. -- For larger work, open a **draft PR** early and link a GitHub **issue** if one exists. +- **Integration branch is `dev`:** create your branch from `dev`, open PRs with **base = `dev`**. The maintainer merges to `dev` and later promotes to `main` for releases as needed. +- **One issue, one PR (when possible):** name the branch with the issue number, e.g. `issue-2-query-decode` or `feat/2-query-decode`. +- Use **atomic commits** (one logical change per commit). **Do not** put `.github/ISSUE_BACKLOG/` changes in the same commit as product code—backlog/templating updates should be a separate `docs:` (or `chore:`) commit, or a separate PR. +- Prefer **conventional commits**: `feat:`, `fix:`, `docs:`, `ci:`, `chore:`, `test:`. +- Full step-by-step: [docs/development-workflow.md](docs/development-workflow.md). ## Creating GitHub issues from the template backlog From d0d3db48f09e0999f79c3cc855265862656168ae Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Apr 2026 16:39:44 +0300 Subject: [PATCH 3/8] fix: percent-decode query string (form_urlencoded) --- Cargo.lock | 16 +++++++++++++++ Cargo.toml | 2 ++ src/params.rs | 54 +++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ada560..daed98a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,6 +57,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures" version = "0.3.32" @@ -285,6 +294,7 @@ name = "oxyroute" version = "0.1.0" dependencies = [ "base64", + "form_urlencoded", "jsonwebtoken", "matchit", "once_cell", @@ -329,6 +339,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" diff --git a/Cargo.toml b/Cargo.toml index b8048a1..1425eb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,8 @@ thiserror = "1" jsonwebtoken = "9" base64 = "0.22" once_cell = "1.19" +# RFC 1738 / WHATWG: percent-decode and + as space in application/x-www-form-urlencoded query +form_urlencoded = "1.2" [features] default = [] diff --git a/src/params.rs b/src/params.rs index d2896f2..ecd2dd2 100644 --- a/src/params.rs +++ b/src/params.rs @@ -2,21 +2,23 @@ use std::collections::HashMap; +use form_urlencoded::parse as parse_urlencoded; use pyo3::prelude::*; use pyo3::types::PyDict; use pyo3::IntoPy; +/// Parse an HTTP `query` string (the part after `?`, without the `?`). +/// Uses [`form_urlencoded`] so keys and values are **percent-decoded** and `+` in values is +/// treated as a space, consistent with `application/x-www-form-urlencoded` / URLSearchParams +/// (see [WHATWG](https://url.spec.whatwg.org/#application/x-www-form-urlencoded)). Duplicate keys +/// are **last-wins** in the returned `HashMap` (not a multimap). pub fn parse_query(q: &str) -> HashMap { let mut m = HashMap::new(); if q.is_empty() { return m; } - for pair in q.split('&') { - if let Some((k, v)) = pair.split_once('=') { - m.insert(k.to_string(), v.to_string()); - } else { - m.insert(pair.to_string(), String::new()); - } + for (k, v) in parse_urlencoded(q.as_bytes()) { + m.insert(k.into_owned(), v.into_owned()); } m } @@ -67,3 +69,43 @@ pub fn header_get_lax(headers: &Bound<'_, PyAny>, name: &str) -> Option } None } + +#[cfg(test)] +mod tests { + use super::parse_query; + + #[test] + fn decodes_percent_encoded_space() { + let m = parse_query("q=hello%20world"); + assert_eq!(m.get("q").map(String::as_str), Some("hello world")); + } + + #[test] + fn decodes_utf8_in_value() { + let m = parse_query("x=%C3%A9"); + assert_eq!(m.get("x").map(String::as_str), Some("é")); + } + + #[test] + fn plus_treated_as_space() { + let m = parse_query("q=a+b"); + assert_eq!(m.get("q").map(String::as_str), Some("a b")); + } + + #[test] + fn duplicate_keys_last_wins() { + let m = parse_query("a=1&a=2"); + assert_eq!(m.get("a").map(String::as_str), Some("2")); + } + + #[test] + fn empty_string_yields_empty_map() { + assert!(parse_query("").is_empty()); + } + + #[test] + fn decodes_key_and_value() { + let m = parse_query("k%3Dey=v%3Dalue"); + assert_eq!(m.get("k=ey").map(String::as_str), Some("v=alue")); + } +} From 67d08f87b81c868ebb3812ad29950d19e558d082 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Apr 2026 16:39:44 +0300 Subject: [PATCH 4/8] test+docs: query decode ASGI test and handler query semantics --- docs/handlers.md | 2 +- tests/test_query_decode.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tests/test_query_decode.py diff --git a/docs/handlers.md b/docs/handlers.md index 94ec79d..db6ea15 100644 --- a/docs/handlers.md +++ b/docs/handlers.md @@ -15,7 +15,7 @@ handler(**kwargs) | Name | When present | Meaning | |------|----------------|--------| | Path parameters | Matched from the route, e.g. `id` for `/items/:id` | String-like values, with coercion for `int` / `float` / `bool` / `str` in the Rust layer where applicable | -| `query` | Request has a query string | A Python `dict` of string keys/values (see implementation for parsing rules) | +| `query` | Request has a query string | A Python `dict` of string keys and values, **percent-decoded**; `+` in values is treated as a space, matching `application/x-www-form-urlencoded` / URLSearchParams ([WHATWG](https://url.spec.whatwg.org/#urlencoded-parsing)). **Duplicate keys** are last-wins (a plain `dict`, not a multimap) | | `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`) | diff --git a/tests/test_query_decode.py b/tests/test_query_decode.py new file mode 100644 index 0000000..2d2e803 --- /dev/null +++ b/tests/test_query_decode.py @@ -0,0 +1,26 @@ +"""Query string: percent-decoding and + handling (see issue #2, src/params.rs).""" + +from __future__ import annotations + +import asyncio + +import httpx + +from oxyroute import App + + +def test_query_value_percent_decoded() -> None: + app = App() + + @app.get("/echo") + def echo(**kwargs) -> str: + return (kwargs.get("query") or {}).get("m", "") + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/echo?m=hello%20world") + assert r.status_code == 200 + assert r.text == "hello world" + + asyncio.run(_run()) From 4b8c8b58b2e15fcf3e99d831195510fff2b841de Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Apr 2026 16:42:43 +0300 Subject: [PATCH 5/8] feat: return JSON 500 on handler and dependency PyErr --- Cargo.lock | 7 ++++ Cargo.toml | 1 + src/dispatch.rs | 108 +++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 102 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index daed98a..a596b51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,6 +228,12 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + [[package]] name = "matchit" version = "0.7.3" @@ -296,6 +302,7 @@ dependencies = [ "base64", "form_urlencoded", "jsonwebtoken", + "log", "matchit", "once_cell", "pyo3", diff --git a/Cargo.toml b/Cargo.toml index 1425eb6..ba4e028 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ base64 = "0.22" once_cell = "1.19" # RFC 1738 / WHATWG: percent-decode and + as space in application/x-www-form-urlencoded query form_urlencoded = "1.2" +log = "0.4" [features] default = [] diff --git a/src/dispatch.rs b/src/dispatch.rs index 8f1023d..a473aed 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -14,6 +14,46 @@ use crate::schema::json_to_py; use crate::state::{map_method_router, AppState}; use crate::token::extract_bearer; +fn oxyroute_debug() -> bool { + std::env::var("OXYROUTE_DEBUG") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) +} + +/// Map Python `PyErr` to HTTP 500 with a JSON body (no exception text unless `OXYROUTE_DEBUG=1`). +async fn send_internal_error( + protocol: &Py, + method: &str, + path: &str, + err: PyErr, +) -> PyResult { + let detail_opt = if oxyroute_debug() { + let d = Python::with_gil(|_py| format!("{err}")); + let d = if d.len() > 4000 { + format!("{}…", &d[..4000]) + } else { + d + }; + log::error!(target: "oxyroute", "{method} {path} — {d}"); + Some(d) + } else { + log::error!(target: "oxyroute", "{method} {path}: internal error (set OXYROUTE_DEBUG=1 for detail)"); + None + }; + let body = if let Some(d) = detail_opt { + serde_json::json!({ "error": "internal server error", "detail": d }).to_string() + } else { + r#"{"error":"internal server error"}"#.to_string() + }; + response::send_text( + protocol, + 500, + &body, + "application/json; charset=utf-8", + ) + .await +} + pub async fn run_rsgi( state: Arc>, scope: Py, @@ -218,23 +258,43 @@ pub async fn run_rsgi( let mut dep_out: Vec = Vec::with_capacity(dep_factories.len()); for (i, fact) in dep_factories.iter().enumerate() { if dep_is_async.get(i) == Some(&true) { - let r = Python::with_gil(|py| -> PyResult { + let r = match Python::with_gil(|py| -> PyResult { Ok(fact.bind(py).call((), None)?.unbind()) - })?; - let fut = Python::with_gil(|py| { + }) { + Ok(x) => x, + Err(e) => { + return send_internal_error(&protocol, &method, &path, e).await; + } + }; + let fut = match Python::with_gil(|py| { let b = r.bind(py).clone(); pyo3_asyncio_0_21::tokio::into_future(b) - })?; - let o = fut.await?; + }) { + Ok(f) => f, + Err(e) => { + return send_internal_error(&protocol, &method, &path, e).await; + } + }; + let o = match fut.await { + Ok(x) => x, + Err(e) => { + return send_internal_error(&protocol, &method, &path, e).await; + } + }; dep_out.push(o); } else { - let o = Python::with_gil(|py| -> PyResult { + let o = match Python::with_gil(|py| -> PyResult { Ok(fact.bind(py).call((), None)?.unbind()) - })?; + }) { + Ok(x) => x, + Err(e) => { + return send_internal_error(&protocol, &method, &path, e).await; + } + }; dep_out.push(o); } } - let (res, run_async) = Python::with_gil(|py| -> PyResult<(PyObject, bool)> { + let (res, run_async) = match Python::with_gil(|py| -> PyResult<(PyObject, bool)> { let kwargs = PyDict::new_bound(py); for (k, v) in param_map { let vpy = value_for_path_param(py, &v); @@ -268,17 +328,32 @@ pub async fn run_rsgi( .call((), Some(&kwargs))? .unbind(); Ok((res, is_async)) - })?; + }) { + Ok(x) => x, + Err(e) => { + return send_internal_error(&protocol, &method, &path, e).await; + } + }; let handler_out: PyObject = if run_async { - let fut = Python::with_gil(|py| { + let fut = match Python::with_gil(|py| { let b = res.bind(py).clone(); pyo3_asyncio_0_21::tokio::into_future(b) - })?; - fut.await? + }) { + Ok(f) => f, + Err(e) => { + return send_internal_error(&protocol, &method, &path, e).await; + } + }; + match fut.await { + Ok(x) => x, + Err(e) => { + return send_internal_error(&protocol, &method, &path, e).await; + } + } } else { res }; - let (status, bytes, content_type) = Python::with_gil(|py| -> PyResult<(u16, Vec, String)> { + let (status, bytes, content_type) = match Python::with_gil(|py| -> PyResult<(u16, Vec, String)> { let b = handler_out.bind(py); if let Ok(s) = b.extract::() { return Ok((200, s.into_bytes(), "text/plain; charset=utf-8".to_string())); @@ -302,6 +377,11 @@ pub async fn run_rsgi( let dumped = jmod.call_method1("dumps", (b.clone().unbind(),))?; let s: String = dumped.extract()?; Ok((200, s.into_bytes(), "application/json; charset=utf-8".to_string())) - })?; + }) { + Ok(x) => x, + Err(e) => { + return send_internal_error(&protocol, &method, &path, e).await; + } + }; response::send_bytes(&protocol, status, &bytes, &content_type).await } From 4936195ce0051e641ce85f4a4d217baceb898632 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Apr 2026 16:42:43 +0300 Subject: [PATCH 6/8] test+docs: 500 error handling and OXYROUTE_DEBUG --- docs/handlers.md | 6 +++ tests/test_handler_errors_500.py | 78 ++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 tests/test_handler_errors_500.py diff --git a/docs/handlers.md b/docs/handlers.md index db6ea15..0c1f37b 100644 --- a/docs/handlers.md +++ b/docs/handlers.md @@ -38,6 +38,12 @@ The Rust layer maps the return value to an HTTP response: For precise behavior and edge cases, refer to the implementation in the repository’s `src/dispatch.rs` and `src/response.rs`. +## Errors in handlers and dependencies + +If a **dependency factory** or the **route handler** raises a Python exception (or building the response fails), the server answers with **500** and a small **JSON** body: `{"error":"internal server error"}`. Exception text and tracebacks are **not** included in the response by default (to avoid leaking internals to clients). + +Set the environment variable **`OXYROUTE_DEBUG=1`** (or `true`) to include a **`detail`** string in that JSON for the same error and to log more at the `log` crate target **`oxyroute`** (see `RUST_LOG`, e.g. `RUST_LOG=oxyroute=error`). + ## See also - [Routing](routing.md) diff --git a/tests/test_handler_errors_500.py b/tests/test_handler_errors_500.py new file mode 100644 index 0000000..c70411c --- /dev/null +++ b/tests/test_handler_errors_500.py @@ -0,0 +1,78 @@ +"""Handler / dependency exceptions map to HTTP 500 (issue #3).""" + +from __future__ import annotations + +import asyncio +import json +import os + +import httpx + +from oxyroute import App + + +def _make_app() -> App: + app = App() + + @app.get("/boom") + def boom() -> str: + raise ValueError("super_secret_token") + + def bad_dep() -> str: + raise RuntimeError("dep fail") + + @app.get("/dep", dependencies=[("x", bad_dep)]) + def with_dep(x: str) -> str: # pragma: no cover - dep fails first + return x + + return app + + +def test_500_no_exception_text_by_default() -> None: + os.environ.pop("OXYROUTE_DEBUG", None) + app = _make_app() + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://t") as c: + r = await c.get("/boom") + assert r.status_code == 500 + data = json.loads(r.text) + assert data.get("error") == "internal server error" + assert "detail" not in data + assert "super_secret" not in r.text + + asyncio.run(_run()) + + +def test_500_includes_detail_when_debug_env() -> None: + os.environ["OXYROUTE_DEBUG"] = "1" + app = _make_app() + try: + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://t") as c: + r = await c.get("/boom") + assert r.status_code == 500 + data = json.loads(r.text) + assert "detail" in data + assert "super_secret_token" in data["detail"] or "ValueError" in data["detail"] + + asyncio.run(_run()) + finally: + os.environ.pop("OXYROUTE_DEBUG", None) + + +def test_dependency_error_returns_500() -> None: + os.environ.pop("OXYROUTE_DEBUG", None) + app = _make_app() + + async def _run() -> None: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://t") as c: + r = await c.get("/dep") + assert r.status_code == 500 + assert "internal" in r.text.lower() + + asyncio.run(_run()) From dca28214c222bde0c28743c549118d72c0c6553e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Apr 2026 16:45:38 +0300 Subject: [PATCH 7/8] fix: RSGI lifespan hooks accept server kwargs (Granian) Granian may pass an extra arg to __rsgi_init__; *args, **kwargs keeps it compatible. Document signatures in rsgi.md. --- docs/rsgi.md | 6 +++--- oxyroute/app.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/rsgi.md b/docs/rsgi.md index c481954..d891396 100644 --- a/docs/rsgi.md +++ b/docs/rsgi.md @@ -18,10 +18,10 @@ The Python `oxyroute.app.App` class implements the async RSGI entry that Granian ## Lifespan (optional) -`App` defines no-op coroutines for servers that expect them: +`App` defines no-op coroutines for servers that expect them. Implementations use `*args, **kwargs` so **Granian** (and any server that passes extra parameters to worker lifespan hooks) can call them without a `TypeError`: -- `async def __rsgi_init__(self) -> None` -- `async def __rsgi_del__(self) -> None` +- `async def __rsgi_init__(self, *args, **kwargs) -> None` +- `async def __rsgi_del__(self, *args, **kwargs) -> None` You can override these in a subclass if you need startup/shutdown hooks; the default does nothing. diff --git a/oxyroute/app.py b/oxyroute/app.py index 6528882..b270079 100644 --- a/oxyroute/app.py +++ b/oxyroute/app.py @@ -134,12 +134,12 @@ def wrap(handler: F) -> F: return wrap - async def __rsgi_init__(self) -> None: # noqa: D401 - """Lifespan hook (no-op) for RSGI servers that call it.""" + async def __rsgi_init__(self, *args: Any, **kwargs: Any) -> None: # noqa: D401 + """Lifespan hook (no-op). Granian may pass extra positional args; accept **kwargs.""" return None - async def __rsgi_del__(self) -> None: # noqa: D401 - """Lifespan teardown hook (no-op) for RSGI servers that call it.""" + async def __rsgi_del__(self, *args: Any, **kwargs: Any) -> None: # noqa: D401 + """Lifespan teardown (no-op). Accept extra args for Granian compatibility.""" return None async def __rsgi__(self, scope: Any, protocol: Any) -> None: From 711d6969fe0d52c00691cf06f6f30aba34b6d236 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Apr 2026 16:45:39 +0300 Subject: [PATCH 8/8] test+ci: Granian e2e, run CI on dev, install granian in workflow - Add e2e test starting granian on a temp RSGI app - Include dev in push/pull_request branch filters - Add granian to test extras install in CI --- .github/workflows/ci.yml | 6 +-- tests/test_granian_e2e.py | 98 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 tests/test_granian_e2e.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ab3847..543e31a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: ci on: push: - branches: [main, master] + branches: [main, master, dev] pull_request: - branches: [main, master] + branches: [main, master, dev] jobs: test: @@ -28,7 +28,7 @@ jobs: - name: Install build deps run: | python -m pip install --upgrade pip - python -m pip install maturin pytest oxyjwt httpx + python -m pip install maturin pytest oxyjwt httpx granian - name: Build and install oxyroute run: | maturin build --release diff --git a/tests/test_granian_e2e.py b/tests/test_granian_e2e.py new file mode 100644 index 0000000..af6e92c --- /dev/null +++ b/tests/test_granian_e2e.py @@ -0,0 +1,98 @@ +""" +Subprocess e2e: real Granian server with --interface rsgi (issue #12). + +Skip if ``granian`` is not installed (``pip install -e .[dev]`` or add granian in CI). +""" + +from __future__ import annotations + +import os +import socket +import subprocess +import sys +import tempfile +import textwrap +import time +from typing import List + +import httpx +import pytest + +pytest.importorskip("granian") + + +def _free_port() -> int: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return port + + +def test_granian_rsgi_returns_handler_body() -> None: + d = tempfile.mkdtemp() + p = os.path.join(d, "e2e_rsgi.py") + with open(p, "w", encoding="utf-8") as f: + f.write( + textwrap.dedent( + """\ + from oxyroute import App + app = App() + + @app.get("/") + def root() -> str: + return "e2e-granian-ok" + """ + ) + ) + port = _free_port() + env = os.environ.copy() + env["PYTHONPATH"] = d + cmd: List[str] = [ + sys.executable, + "-m", + "granian", + "e2e_rsgi:app", + "--host", + "127.0.0.1", + "--port", + str(port), + "--interface", + "rsgi", + "--workers", + "1", + ] + proc = subprocess.Popen( + cmd, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + try: + deadline = time.time() + 20.0 + last_err: str | None = None + while time.time() < deadline: + time.sleep(0.05) + try: + with httpx.Client(timeout=1.0) as c: + r = c.get(f"http://127.0.0.1:{port}/") + if r.status_code == 200 and r.text == "e2e-granian-ok": + return + except (httpx.HTTPError, OSError) as e: + last_err = str(e) + if proc.poll() is not None: + err = proc.stderr.read() if proc.stderr else "" + raise AssertionError( + f"granian exited early: code={proc.returncode} stderr={err!r} last={last_err!r}" + ) + err = proc.stderr.read() if proc.stderr else "" + raise AssertionError( + f"server did not become ready. last={last_err!r} stderr={err!r}" + ) + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill()