From 670aa6569a380418a81017223a2b2595460fcb42 Mon Sep 17 00:00:00 2001
From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com>
Date: Tue, 15 Sep 2026 12:50:13 +0200
Subject: [PATCH 01/14] docs: epythet repair, blank lines before lists and
doctests, Note: sections
Mechanical rewrite by `epythet repair qh --write` (epythet 0.2.9): 38 docstrings
in 14 files. Level 0.5 validate errors 15 -> 0 (DR008 x12, DR003 x3, DR014 x2).
---
qh/app.py | 9 +++++++++
qh/async_endpoints.py | 2 ++
qh/async_tasks.py | 5 ++++-
qh/au_integration.py | 11 ++++++++---
qh/base.py | 1 +
qh/client.py | 3 +++
qh/config.py | 3 +++
qh/conventions.py | 5 +++++
qh/jsclient.py | 2 ++
qh/openapi.py | 2 ++
qh/rules.py | 2 ++
qh/stores_qh.py | 3 +++
qh/testing.py | 11 +++++++++++
qh/types.py | 4 ++++
14 files changed, 59 insertions(+), 4 deletions(-)
diff --git a/qh/app.py b/qh/app.py
index d3305dc..e7c5a6f 100644
--- a/qh/app.py
+++ b/qh/app.py
@@ -43,6 +43,7 @@ def mk_app(
Args:
funcs: Functions to expose as HTTP endpoints. Can be:
+
- A single callable
- A list of callables
- A dict mapping callables to their route configurations
@@ -51,12 +52,14 @@ def mk_app(
If None, creates a new app.
config: Optional app-level configuration. Can be:
+
- AppConfig object
- Dict that will be converted to AppConfig
- None (uses defaults)
use_conventions: Whether to use convention-based routing.
If True, infers paths and methods from function names:
+
- get_user(user_id) → GET /users/{user_id}
- list_users() → GET /users
- create_user(user) → POST /users
@@ -66,6 +69,7 @@ def mk_app(
get a task ID instead of blocking for the result.
async_config: Configuration for async task processing. Can be:
+
- None (uses default TaskConfig for functions in async_funcs)
- TaskConfig object (applies to all async_funcs)
- Dict mapping function names to TaskConfig objects
@@ -83,27 +87,32 @@ def mk_app(
Examples:
Simple case - just functions:
+
>>> def add(x: int, y: int) -> int:
... return x + y
>>> app = mk_app([add])
With conventions:
+
>>> def get_user(user_id: str): ...
>>> def list_users(): ...
>>> app = mk_app([get_user, list_users], use_conventions=True)
With configuration:
+
>>> app = mk_app(
... [add],
... config={'path_prefix': '/api', 'default_methods': ['POST']}
... )
Per-function configuration:
+
>>> app = mk_app({
... add: {'methods': ['GET', 'POST'], 'path': '/calculate/add'},
... })
With async support:
+
>>> def expensive_task(n: int) -> int:
... import time
... time.sleep(5)
diff --git a/qh/async_endpoints.py b/qh/async_endpoints.py
index f7c09ae..a04a92c 100644
--- a/qh/async_endpoints.py
+++ b/qh/async_endpoints.py
@@ -20,6 +20,7 @@ def add_task_endpoints(
Add task management endpoints for a specific function.
Creates the following endpoints:
+
- GET {path_prefix}/{task_id}/status - Get task status
- GET {path_prefix}/{task_id}/result - Get task result (waits if needed)
- GET {path_prefix}/{task_id} - Get complete task info
@@ -145,6 +146,7 @@ def add_global_task_endpoints(
Add global task management endpoints (cross all functions).
Creates:
+
- GET {path_prefix}/ - List all recent tasks
Args:
diff --git a/qh/async_tasks.py b/qh/async_tasks.py
index 4e32045..a6f2282 100644
--- a/qh/async_tasks.py
+++ b/qh/async_tasks.py
@@ -5,12 +5,14 @@
by returning task IDs immediately and allowing clients to poll for results.
Terminology (standard async task processing):
+
- Task: An asynchronous computation
- Task ID: Unique identifier for tracking a task
- Task Status: State of the task (pending, running, completed, failed)
- Task Result: The output of the completed task
Design Philosophy:
+
- Convention over configuration with escape hatches
- Pluggable backends (in-memory, file-based, au, Celery, etc.)
- HTTP-first patterns (query params, standard endpoints)
@@ -442,7 +444,8 @@ def cancel_task(self, task_id: str) -> bool:
"""
Cancel a task (if possible).
- Note: Cancellation is best-effort and may not work for all executors.
+ Note:
+ Cancellation is best-effort and may not work for all executors.
Returns:
True if task was cancelled or deleted
diff --git a/qh/au_integration.py b/qh/au_integration.py
index 5adbb78..c2bdb54 100644
--- a/qh/au_integration.py
+++ b/qh/au_integration.py
@@ -5,6 +5,7 @@
with qh's user-friendly HTTP interface.
Philosophy:
+
- qh provides the HTTP layer (each function gets its own endpoint)
- au provides the execution backend and result storage
- This module bridges them together
@@ -117,7 +118,8 @@ def get_task(self, task_id: str) -> Optional[TaskInfo]:
def update_task(self, task_info: TaskInfo) -> None:
"""Update task information.
- Note: au manages its own state, so this is mostly a no-op.
+ Note:
+ au manages its own state, so this is mostly a no-op.
"""
pass
@@ -165,8 +167,9 @@ def submit_task(
) -> None:
"""Submit a task to au backend.
- Note: au handles result storage internally, so we don't use the callback.
- The callback is for qh's built-in backends, but au's store handles this.
+ Note:
+ au handles result storage internally, so we don't use the callback.
+ The callback is for qh's built-in backends, but au's store handles this.
"""
# Call au backend's launch() method directly with our custom task_id (key)
# au will store the result in its store when done
@@ -200,6 +203,7 @@ def use_au_backend(
TaskConfig configured to use au
Example:
+
>>> from au import ThreadBackend, FileSystemStore # doctest: +SKIP
>>> from qh import mk_app # doctest: +SKIP
>>> from qh.au_integration import use_au_backend # doctest: +SKIP
@@ -218,6 +222,7 @@ def use_au_backend(
... )
Example with au's global config:
+
>>> # Set AU environment variables:
>>> # AU_BACKEND=redis
>>> # AU_REDIS_URL=redis://localhost:6379
diff --git a/qh/base.py b/qh/base.py
index 9d2585f..488b176 100644
--- a/qh/base.py
+++ b/qh/base.py
@@ -112,6 +112,7 @@ def mk_fastapi_app(
Expose Python callables as FastAPI routes.
funcs can be:
+
- dict mapping func -> RouteConfig dict
- list of callables or dicts with 'func' key
- single callable
diff --git a/qh/client.py b/qh/client.py
index abcc0eb..fdafc8f 100644
--- a/qh/client.py
+++ b/qh/client.py
@@ -153,6 +153,7 @@ def mk_client_from_openapi(
HttpClient with functions for each endpoint
Example:
+
>>> from qh.client import mk_client_from_openapi # doctest: +SKIP
>>> spec = {'paths': {'/add': {...}}, ...} # doctest: +SKIP
>>> client = mk_client_from_openapi(spec, 'http://localhost:8000') # doctest: +SKIP
@@ -223,6 +224,7 @@ def mk_client_from_url(
HttpClient with functions for each endpoint
Example:
+
>>> from qh.client import mk_client_from_url # doctest: +SKIP
>>> client = mk_client_from_url('http://localhost:8000/openapi.json') # doctest: +SKIP
>>> result = client.add(x=3, y=5) # doctest: +SKIP
@@ -255,6 +257,7 @@ def mk_client_from_app(app, base_url: str = "http://testserver") -> HttpClient:
HttpClient that uses FastAPI TestClient under the hood
Example:
+
>>> from qh import mk_app # doctest: +SKIP
>>> from qh.client import mk_client_from_app # doctest: +SKIP
>>> app = mk_app([add, subtract]) # doctest: +SKIP
diff --git a/qh/config.py b/qh/config.py
index 9f9fe7a..7b22fae 100644
--- a/qh/config.py
+++ b/qh/config.py
@@ -2,6 +2,7 @@
Configuration system for qh with layered defaults.
Configuration flows from general to specific:
+
1. Global defaults
2. App-level config
3. Function-level config
@@ -123,6 +124,7 @@ def resolve_route_config(
Resolve complete route configuration for a function.
Precedence (highest to lowest):
+
1. route_config (function-specific)
2. app_config (app-level defaults)
3. DEFAULT_ROUTE_CONFIG (global defaults)
@@ -255,6 +257,7 @@ def normalize_funcs_input(
Normalize various input formats to Dict[Callable, RouteConfig].
Supports:
+
- Single callable
- List of callables
- Dict mapping callable to config dict
diff --git a/qh/conventions.py b/qh/conventions.py
index 93f4493..e965a44 100644
--- a/qh/conventions.py
+++ b/qh/conventions.py
@@ -4,6 +4,7 @@
Automatically infer HTTP paths and methods from function names and signatures.
Supports patterns like:
+
- get_user(user_id: str) → GET /users/{user_id}
- list_users(limit: int = 100) → GET /users?limit=100
- create_user(user: User) → POST /users
@@ -58,6 +59,7 @@ def parse_function_name(func_name: str) -> ParsedFunctionName:
Parse a function name to extract verb and resource.
Examples:
+
>>> parse_function_name('get_user')
ParsedFunctionName(verb='get', resource='user', is_plural=False, is_collection_operation=False)
@@ -112,6 +114,7 @@ def infer_http_method(
HTTP method ('GET', 'POST', 'PUT', 'PATCH', 'DELETE')
Examples:
+
>>> infer_http_method('get_user')
'GET'
>>> infer_http_method('create_user')
@@ -165,6 +168,7 @@ def get_id_params(func: Callable) -> List[str]:
Extract parameters that look like IDs from function signature.
ID parameters typically:
+
- End with '_id'
- Are named 'id'
- Are the first parameter (for item operations)
@@ -207,6 +211,7 @@ def infer_path_from_function(
Inferred path
Examples:
+
>>> def get_user(user_id: str): pass
>>> infer_path_from_function(get_user)
'/users/{user_id}'
diff --git a/qh/jsclient.py b/qh/jsclient.py
index 4d1fe76..747e662 100644
--- a/qh/jsclient.py
+++ b/qh/jsclient.py
@@ -289,6 +289,7 @@ def export_js_client(
JavaScript code as string
Example:
+
>>> from qh import mk_app, export_openapi # doctest: +SKIP
>>> from qh.jsclient import export_js_client # doctest: +SKIP
>>> app = mk_app([add, subtract]) # doctest: +SKIP
@@ -361,6 +362,7 @@ def export_ts_client(
TypeScript code as string
Example:
+
>>> from qh import mk_app, export_openapi # doctest: +SKIP
>>> from qh.jsclient import export_ts_client # doctest: +SKIP
>>> app = mk_app([add, subtract]) # doctest: +SKIP
diff --git a/qh/openapi.py b/qh/openapi.py
index 14fcb83..879cde4 100644
--- a/qh/openapi.py
+++ b/qh/openapi.py
@@ -495,6 +495,7 @@ def extract_function_signature(func: Callable) -> Dict[str, Any]:
Returns:
Dictionary with signature metadata:
+
- name: function name
- module: module path
- parameters: list of parameter info
@@ -758,6 +759,7 @@ def export_openapi(
the enhanced OpenAPI schema dictionary.
Example:
+
>>> from qh import mk_app # doctest: +SKIP
>>> from qh.openapi import export_openapi # doctest: +SKIP
>>> app = mk_app([my_func]) # doctest: +SKIP
diff --git a/qh/rules.py b/qh/rules.py
index 68c7b51..1007e45 100644
--- a/qh/rules.py
+++ b/qh/rules.py
@@ -2,6 +2,7 @@
Transformation rule system for qh.
Supports multi-dimensional matching:
+
- Type-based
- Argument name-based
- Function name-based
@@ -354,6 +355,7 @@ def resolve_transform(
Resolve transformation specification for a parameter.
Resolution order:
+
1. Rule chain (explicit rules)
2. Type registry (registered types)
3. Default fallback (JSON body, no transformation)
diff --git a/qh/stores_qh.py b/qh/stores_qh.py
index 22c7d33..926bca3 100644
--- a/qh/stores_qh.py
+++ b/qh/stores_qh.py
@@ -389,13 +389,16 @@ def add_store_access(
Args:
get_obj: Function that takes an identifier and returns a mapping object
app: Can be:
+
- None: creates a new FastAPI app with default settings
- FastAPI instance: uses this existing app
- str: creates a new FastAPI app with this title
- dict: creates a new FastAPI app with these kwargs
methods: Dictionary mapping method names to dispatch configuration
+
- Key is the mapping method name (e.g., '__iter__', '__getitem__')
- Value is None to use defaults or a dict with configuration
+
get_obj_dispatch: Configuration for how to dispatch the get_obj function
base_path: Base path for all endpoints
diff --git a/qh/testing.py b/qh/testing.py
index b97eee3..73a48a8 100644
--- a/qh/testing.py
+++ b/qh/testing.py
@@ -6,6 +6,7 @@
Related Tools in Other Packages
-------------------------------
This module provides testing utilities similar to those found in:
+
- `meshed.tools.launch_webservice`: Context manager for launching function-based web services
- `strand.taskrunning.utils.run_process`: Generic process runner with health checks
- `py2http`: Various service management utilities
@@ -108,6 +109,7 @@ def service_running(
Examples:
Test a qh app (will launch and tear down):
+
>>> from qh import mk_app
>>> def add(x: int, y: int) -> int:
... return x + y
@@ -118,11 +120,13 @@ def service_running(
... assert not info.was_already_running # doctest: +SKIP
Test an already-running service (won't tear down):
+
>>> with service_running(url='https://api.github.com') as info:
... response = requests.get(f'{info.url}/users/octocat')
... assert info.was_already_running # doctest: +SKIP
Use custom launcher:
+
>>> def my_launcher():
... # Custom service startup code
... pass # doctest: +SKIP
@@ -211,6 +215,7 @@ class AppRunner:
Examples:
Basic usage with TestClient:
+
>>> from qh import mk_app
>>> from qh.testing import AppRunner # doctest: +SKIP
>>> def add(x: int, y: int) -> int: # doctest: +SKIP
@@ -221,11 +226,13 @@ class AppRunner:
... assert response.json() == 8
With real server (integration testing):
+
>>> with AppRunner(app, use_server=True, port=8001) as base_url: # doctest: +SKIP
... response = requests.post(f'{base_url}/add', json={'x': 3, 'y': 5})
... assert response.json() == 8
Automatic cleanup on error:
+
>>> with AppRunner(app) as client: # doctest: +SKIP
... # Server automatically stops if exception occurs
... raise ValueError("Test error")
@@ -362,6 +369,7 @@ def run_app(app: FastAPI, *, use_server: bool = False, **kwargs):
TestClient or base URL string
Examples:
+
>>> from qh import mk_app # doctest: +SKIP
>>> from qh.testing import run_app # doctest: +SKIP
>>> def add(x: int, y: int) -> int: # doctest: +SKIP
@@ -395,6 +403,7 @@ def test_app(app: FastAPI):
TestClient instance
Examples:
+
>>> from qh import mk_app # doctest: +SKIP
>>> from qh.testing import test_app # doctest: +SKIP
>>> def hello(name: str = "World") -> str: # doctest: +SKIP
@@ -424,6 +433,7 @@ def serve_app(app: FastAPI, port: int = 8000, host: str = "127.0.0.1"):
Base URL string
Examples:
+
>>> from qh import mk_app # doctest: +SKIP
>>> from qh.testing import serve_app # doctest: +SKIP
>>> import requests # doctest: +SKIP
@@ -452,6 +462,7 @@ def quick_test(func, **kwargs):
Response from calling the function
Examples:
+
>>> from qh.testing import quick_test
>>>
>>> def add(x: int, y: int) -> int:
diff --git a/qh/types.py b/qh/types.py
index 7c74f9c..8214863 100644
--- a/qh/types.py
+++ b/qh/types.py
@@ -2,6 +2,7 @@
Type registry for qh - automatic serialization/deserialization for custom types.
Supports:
+
- NumPy arrays and dtypes
- Pandas DataFrames and Series
- Custom user types
@@ -165,6 +166,7 @@ def register_type(
content_type: Optional content type for binary data
Example:
+
>>> import numpy as np # doctest: +SKIP
>>> register_type( # doctest: +SKIP
... np.ndarray,
@@ -278,10 +280,12 @@ def register_json_type(
Decorator to register a custom type.
Can be used as:
+
1. Class decorator (auto-detect to_dict/from_dict methods)
2. With explicit serializers
Examples:
+
>>> @register_json_type
... class Point:
... def __init__(self, x, y):
From f9f9afc38b9c22fe1e5ee0f7c8b1ac2cd25519ca Mon Sep 17 00:00:00 2001
From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com>
Date: Tue, 15 Sep 2026 13:21:20 +0200
Subject: [PATCH 02/14] WIP: epythet docs sweep, partial (to be resumed by a
follow-up session; see i2mint/epythet#16 WP6)
---
docsrc/.gitignore | 7 +++
docsrc/api.rst | 8 +++
docsrc/conf.py | 8 +++
docsrc/index.md | 15 ++++++
qh/__init__.py | 28 ++++++++--
qh/app.py | 79 ++++++++++++++++++++++------
qh/base.py | 17 +++++-
qh/core.py | 14 ++++-
qh/testing.py | 129 ++++++++++++++++++++++++++++++++--------------
9 files changed, 244 insertions(+), 61 deletions(-)
create mode 100644 docsrc/.gitignore
create mode 100644 docsrc/api.rst
create mode 100644 docsrc/conf.py
create mode 100644 docsrc/index.md
diff --git a/docsrc/.gitignore b/docsrc/.gitignore
new file mode 100644
index 0000000..6fa88be
--- /dev/null
+++ b/docsrc/.gitignore
@@ -0,0 +1,7 @@
+# Generated by epythet at build time
+_build/
+api/
+_autosummary/
+_templates/
+_static/epythet.css
+about-this-build.md
diff --git a/docsrc/api.rst b/docsrc/api.rst
new file mode 100644
index 0000000..79cca6c
--- /dev/null
+++ b/docsrc/api.rst
@@ -0,0 +1,8 @@
+API reference
+=============
+
+.. autosummary::
+ :toctree: _autosummary
+ :recursive:
+
+ qh
diff --git a/docsrc/conf.py b/docsrc/conf.py
new file mode 100644
index 0000000..2517c43
--- /dev/null
+++ b/docsrc/conf.py
@@ -0,0 +1,8 @@
+"""Sphinx configuration, generated by epythet.
+
+Settings come from ``[tool.epythet]`` in pyproject.toml (or ``[metadata]`` in
+setup.cfg). Put project-specific overrides below the import; anything defined
+here wins over the generated value.
+"""
+
+from epythet.sphinx_conf import * # noqa: F401,F403
diff --git a/docsrc/index.md b/docsrc/index.md
new file mode 100644
index 0000000..9b33b26
--- /dev/null
+++ b/docsrc/index.md
@@ -0,0 +1,15 @@
+
+
+```{include} ../README.md
+:relative-images:
+:relative-docs: ..
+```
+
+
This documentation as a single file: qh.md (Markdown, for agents).
+
+```{toctree}
+:maxdepth: 3
+:hidden:
+
+api
+```
diff --git a/qh/__init__.py b/qh/__init__.py
index 57fc4c6..01bb304 100644
--- a/qh/__init__.py
+++ b/qh/__init__.py
@@ -1,7 +1,29 @@
-"""
-qh: Quick HTTP service for Python
+"""Quick HTTP: expose Python functions as a FastAPI web service with one call.
+
+Give ``mk_app`` a function, a list of functions, or a dict of functions to route
+configs, and get back a FastAPI app whose routes call those functions. Type
+hints drive request validation and the OpenAPI document; an optional
+convention layer infers RESTful paths and methods from function names; a rule
+chain and a type registry decide where each parameter lives in the HTTP
+request and how it is (de)serialized. The same app can be tested in-process,
+served, turned into a Python, JavaScript or TypeScript client, and given
+background-task endpoints for long-running functions.
+
+Main entry points:
+
+- ``mk_app``: functions in, FastAPI app out (``qh.app``)
+- ``RouteConfig`` / ``AppConfig``: per-route and app-wide configuration (``qh.config``)
+- ``test_app`` / ``quick_test`` / ``service_running``: in-process and live testing (``qh.testing``)
+- ``mk_client_from_app`` / ``export_openapi``: clients and the OpenAPI document (``qh.client``, ``qh.openapi``)
+- ``TaskConfig``: background execution for functions named in ``async_funcs`` (``qh.async_tasks``)
-Convention-over-configuration tool for exposing Python functions as HTTP services.
+>>> from qh import mk_app, test_app
+>>> def add(x: int, y: int) -> int:
+... return x + y
+>>> app = mk_app([add])
+>>> with test_app(app) as client:
+... client.post('/add', json={'x': 3, 'y': 5}).json()
+8
"""
# New primary API
diff --git a/qh/app.py b/qh/app.py
index e7c5a6f..713e506 100644
--- a/qh/app.py
+++ b/qh/app.py
@@ -1,12 +1,31 @@
-"""
-Core API for creating FastAPI applications from Python functions.
-
-This is the primary entry point for qh: mk_app()
+"""Build a FastAPI application from plain Python functions.
+
+``mk_app`` is the primary entry point of qh: it normalizes whatever you pass
+(a callable, a list, or a dict of callables to route configs), resolves the
+layered configuration from ``qh.config``, applies the naming conventions from
+``qh.conventions`` when asked, wraps each function into a FastAPI endpoint via
+``qh.endpoint``, and installs the type-hint-derived OpenAPI document from
+``qh.openapi``. Functions named in ``async_funcs`` also get the task endpoints
+of ``qh.async_endpoints``.
+
+Main entry points:
+
+- ``mk_app``: functions in, FastAPI app out
+- ``inspect_routes``: the registered routes as plain dicts
+- ``print_routes``: the same as a text table
+
+>>> from qh.app import mk_app, inspect_routes
+>>> def add(x: int, y: int) -> int:
+... return x + y
+>>> app = mk_app([add])
+>>> [r['path'] for r in inspect_routes(app) if r['name'] == 'add']
+['/add']
"""
from typing import Any, Callable, Dict, List, Optional, Union
from fastapi import FastAPI
+from qh.async_tasks import TaskConfig
from qh.config import (
AppConfig,
RouteConfig,
@@ -31,15 +50,18 @@ def mk_app(
config: Optional[Union[Dict[str, Any], AppConfig]] = None,
use_conventions: bool = False,
async_funcs: Optional[List[Union[str, Callable]]] = None,
- async_config: Optional[Union[Dict[str, Any], "TaskConfig"]] = None,
+ async_config: Optional[Union[Dict[str, Any], TaskConfig]] = None,
enhanced_openapi: bool = True,
**kwargs,
) -> FastAPI:
"""
- Create a FastAPI application from Python functions.
+ Create a FastAPI application whose routes call the given Python functions.
This is the primary API for qh. It supports multiple input formats for maximum
- flexibility while maintaining simplicity for common cases.
+ flexibility while maintaining simplicity for common cases. Each function gets
+ one route; by default a ``POST`` at ``/`` taking its arguments
+ as a JSON object (see ``RouteConfig`` and ``AppConfig`` in ``qh.config`` for
+ what can be changed, and ``qh.rules`` for how parameters are mapped to HTTP).
Args:
funcs: Functions to expose as HTTP endpoints. Can be:
@@ -83,7 +105,13 @@ def mk_app(
**kwargs: Additional FastAPI() constructor kwargs (if creating new app)
Returns:
- FastAPI application with routes added
+ The FastAPI application (the one passed as ``app``, or a new one) with
+ one route per function, in the order the functions were given.
+
+ See Also:
+ ``qh.testing.test_app``: call the resulting app in-process without a server.
+ ``qh.client.mk_client_from_app``: a Python client whose methods mirror the functions.
+ ``qh.base.mk_fastapi_app``: the older, config-free variant kept for its tests.
Examples:
Simple case - just functions:
@@ -119,16 +147,14 @@ def mk_app(
... return n * 2
>>> app = mk_app([expensive_task], async_funcs=['expensive_task'])
- # Now: POST /expensive_task?async=true returns {"task_id": "..."}
- # And: GET /tasks/{task_id}/result returns the result when ready
+ Now ``POST /expensive_task?async=true`` returns ``{"task_id": ...}`` and
+ ``GET /tasks/{task_id}/result`` returns the result when ready.
"""
# Normalize input formats
func_configs = normalize_funcs_input(funcs)
# Process async configuration
if async_funcs:
- from qh.async_tasks import TaskConfig
-
# Normalize async_config
if async_config is None:
# Use default config for all async functions
@@ -282,13 +308,27 @@ def mk_app(
def inspect_routes(app: FastAPI) -> List[Dict[str, Any]]:
"""
- Inspect routes in a FastAPI app.
+ List the routes of a FastAPI app as plain dicts.
Args:
app: FastAPI application
Returns:
- List of route information dicts
+ One dict per route that has HTTP methods (FastAPI's own ``/docs``,
+ ``/redoc`` and ``/openapi.json`` routes included), in registration
+ order, with keys ``path``, ``methods``, ``name`` and ``endpoint``.
+ Routes made by ``mk_app`` also carry ``function`` (the original
+ Python callable) and ``param_specs`` (the parameter-to-``TransformSpec``
+ map used to build the OpenAPI document).
+
+ Examples:
+ >>> from qh import mk_app, inspect_routes
+ >>> def add(x: int, y: int) -> int:
+ ... return x + y
+ >>> app = mk_app([add])
+ >>> route = [r for r in inspect_routes(app) if r['name'] == 'add'][0]
+ >>> route['path'], route['methods'], route['function'] is add
+ ('/add', ['POST'], True)
"""
routes = []
@@ -314,10 +354,19 @@ def inspect_routes(app: FastAPI) -> List[Dict[str, Any]]:
def print_routes(app: FastAPI) -> None:
"""
- Print formatted route table for a FastAPI app.
+ Print a text table of a FastAPI app's routes (methods, path, endpoint name).
Args:
app: FastAPI application
+
+ Examples:
+ >>> from qh import mk_app, print_routes
+ >>> def add(x: int, y: int) -> int:
+ ... return x + y
+ >>> print_routes(mk_app([add], config={'docs_url': None, 'redoc_url': None, 'openapi_url': None}))
+ METHODS PATH ENDPOINT
+ ----------------------------------------------------------
+ POST /add add
"""
routes = inspect_routes(app)
diff --git a/qh/base.py b/qh/base.py
index 488b176..773aabc 100644
--- a/qh/base.py
+++ b/qh/base.py
@@ -1,5 +1,18 @@
-"""
-qh.base - Core functionality for dispatching Python functions as HTTP endpoints using FastAPI
+"""Config-free dispatch of Python callables as FastAPI routes, plus a store dispatcher.
+
+A lighter predecessor of ``qh.app.mk_app`` with a dict-based route config
+(``path``, ``methods``, ``input_trans``, ``output_trans``, ``defaults``,
+``summary``, ``tags``). Every endpoint reads its arguments from the JSON body
+merged with path parameters, and answers ``422`` for a missing required
+argument and ``500`` for an exception raised by the function. Importing this
+module also patches ``fastapi.testclient.TestClient.get`` to accept a ``json``
+body. Not used by ``qh.app``; kept for its tests and for ``mk_store_dispatcher``.
+
+Main entry points:
+
+- ``mk_fastapi_app``: callables (or a dict of callable to config) in, FastAPI app out
+- ``mk_store_dispatcher``: key/value routes over a ``store_getter(store_id)`` mapping
+- ``mk_json_ingress`` / ``mk_json_egress``: per-key and per-type transforms for the above
"""
from typing import Any, Dict, List, Optional, Union, Tuple
diff --git a/qh/core.py b/qh/core.py
index fabd2c9..76ef2fe 100644
--- a/qh/core.py
+++ b/qh/core.py
@@ -1,4 +1,16 @@
-"""Core qh"""
+"""Minimal function-to-route dispatch built on ``i2.wrapper.Wrap``.
+
+An early, self-contained take on the qh idea: ``mk_fastapi_app`` turns a
+collection of callables into ``POST`` routes that read a JSON body as keyword
+arguments and return the JSON-encoded result. It is not used by ``qh.app.mk_app``,
+the maintained entry point, and it keeps a process-wide ``default_configs``
+dict that ``mk_fastapi_app`` mutates; prefer ``qh.app`` for new code.
+
+Main entry points:
+
+- ``mk_fastapi_app``: callables in, FastAPI app out
+- ``default_configs``: the mutable process-wide defaults (path, method, mappers)
+"""
from fastapi import FastAPI, Request, Response
import json
diff --git a/qh/testing.py b/qh/testing.py
index 73a48a8..15e0343 100644
--- a/qh/testing.py
+++ b/qh/testing.py
@@ -1,18 +1,28 @@
-"""
-Testing utilities for qh applications.
-
-Provides context managers and helpers for testing HTTP services created with qh.
-
-Related Tools in Other Packages
--------------------------------
-This module provides testing utilities similar to those found in:
-
-- `meshed.tools.launch_webservice`: Context manager for launching function-based web services
-- `strand.taskrunning.utils.run_process`: Generic process runner with health checks
-- `py2http`: Various service management utilities
-
-The tools here are specifically optimized for qh/FastAPI applications but can work
-with any HTTP service.
+"""Run a qh (or any FastAPI) app for a test: in-process, or on a real port.
+
+Two ways to exercise an app. ``test_app`` (and ``run_app``, ``AppRunner``) wrap
+FastAPI's ``TestClient`` so requests go straight to the app with no socket.
+``serve_app`` and ``service_running`` start uvicorn in a daemon thread and
+give you a base URL to hit with ``requests``; ``service_running`` can also
+notice a service that is already up and leave it alone. ``quick_test`` is the
+one-liner: build an app around one function, POST to it, return the JSON.
+
+Similar tools elsewhere: ``meshed.tools.launch_webservice``,
+``strand.taskrunning.utils.run_process``, and the service helpers in
+``py2http``.
+
+Main entry points:
+
+- ``test_app``: ``with test_app(app) as client:`` for in-process requests
+- ``quick_test``: call one function through HTTP and get its JSON back
+- ``serve_app`` / ``service_running``: a live server on a port, for integration tests
+
+>>> from qh import mk_app
+>>> from qh.testing import quick_test
+>>> def add(x: int, y: int) -> int:
+... return x + y
+>>> quick_test(add, x=3, y=5)
+8
"""
from typing import Optional, Any, Callable, Generator
@@ -24,6 +34,18 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
+__all__ = [
+ "ServiceInfo",
+ "service_running",
+ "AppRunner",
+ "run_app",
+ "test_app",
+ "serve_app",
+ "quick_test",
+ "app_runner",
+ "test_client",
+]
+
@dataclass
class ServiceInfo:
@@ -82,11 +104,13 @@ def service_running(
launcher) and tears it down on exit. If the service was already running, it leaves
it running on exit.
- Exactly one of `url`, `app`, or `launcher` must be provided.
+ Exactly one of ``url``, ``app``, or ``launcher`` must be provided.
Note:
- Services are launched in background threads (not processes) to avoid
- serialization issues with FastAPI apps on macOS.
+ Services are launched in daemon threads (not processes) to avoid
+ serialization issues with FastAPI apps on macOS. A service this
+ context manager launched is not stopped on exit: the thread ends
+ with the process.
Args:
url: URL of an existing service to check (e.g., 'http://localhost:8000').
@@ -104,11 +128,12 @@ def service_running(
ServiceInfo: Information about the running service including URL and status
Raises:
- ValueError: If invalid combination of arguments provided
- RuntimeError: If service fails to start within timeout
+ ValueError: If none, or more than one, of ``url``, ``app`` and ``launcher`` is given.
+ RuntimeError: If ``url`` alone was given and nothing answers there, or if a
+ launched service does not answer within ``readiness_timeout`` seconds.
Examples:
- Test a qh app (will launch and tear down):
+ Test a qh app (launches a server in a daemon thread):
>>> from qh import mk_app
>>> def add(x: int, y: int) -> int:
@@ -211,7 +236,11 @@ class AppRunner:
Context manager for running a FastAPI app in test mode or with a real server.
Supports both synchronous testing (using TestClient) and integration testing
- (using a real uvicorn server).
+ (using a real uvicorn server). With ``use_server=False`` (the default) the
+ ``with`` block receives a ``TestClient``; with ``use_server=True`` it
+ receives the base URL of a uvicorn server started in a daemon thread. On
+ exit the TestClient reference is dropped; a real server is not stopped, its
+ daemon thread ends with the process. ``run_app`` is the function form.
Examples:
Basic usage with TestClient:
@@ -231,10 +260,9 @@ class AppRunner:
... response = requests.post(f'{base_url}/add', json={'x': 3, 'y': 5})
... assert response.json() == 8
- Automatic cleanup on error:
+ An exception inside the block propagates; ``__exit__`` still runs:
>>> with AppRunner(app) as client: # doctest: +SKIP
- ... # Server automatically stops if exception occurs
... raise ValueError("Test error")
"""
@@ -280,9 +308,10 @@ def __enter__(self):
def __exit__(self, exc_type, exc_val, exc_tb):
"""
- Stop the app and clean up resources.
+ Drop the TestClient, or mark the server as no longer tracked.
- Automatically called even if an exception occurs.
+ Runs even if the block raised; never suppresses the exception. A real
+ server (``use_server=True``) keeps running in its daemon thread.
"""
if self.use_server:
self._stop_server()
@@ -309,6 +338,9 @@ def _start_server(self) -> str:
Returns:
Base URL string (e.g., "http://127.0.0.1:8000")
+
+ Raises:
+ RuntimeError: If ``/docs`` does not answer within ``server_timeout`` seconds.
"""
import uvicorn
@@ -345,7 +377,7 @@ def run_server():
)
def _stop_server(self):
- """Stop the uvicorn server."""
+ """Forget the server thread; the daemon thread itself keeps running until the process exits."""
if self._server_running:
# Server will stop when thread is terminated
# (daemon thread will automatically stop when main thread exits)
@@ -392,9 +424,11 @@ def run_app(app: FastAPI, *, use_server: bool = False, **kwargs):
@contextmanager
def test_app(app: FastAPI):
"""
- Simple context manager for testing with TestClient.
+ Call an app in-process through a ``TestClient``, no server, no port.
- Convenience wrapper for the most common case: testing with TestClient.
+ The most common case, and the fastest: requests are dispatched straight
+ to the ASGI app. Use ``serve_app`` when a real socket matters (another
+ process, a browser, a generated client pointed at a URL).
Args:
app: FastAPI application
@@ -404,14 +438,22 @@ def test_app(app: FastAPI):
Examples:
- >>> from qh import mk_app # doctest: +SKIP
- >>> from qh.testing import test_app # doctest: +SKIP
- >>> def hello(name: str = "World") -> str: # doctest: +SKIP
+ >>> from qh import mk_app
+ >>> from qh.testing import test_app
+ >>> def hello(name: str = "World") -> str:
... return f"Hello, {name}!"
- >>> app = mk_app([hello]) # doctest: +SKIP
- >>> with test_app(app) as client: # doctest: +SKIP
- ... response = client.post('/hello', json={'name': 'Alice'})
- ... assert response.json() == "Hello, Alice!"
+ >>> app = mk_app([hello])
+ >>> with test_app(app) as client:
+ ... client.post('/hello', json={'name': 'Alice'}).json()
+ 'Hello, Alice!'
+
+ A missing required argument is a ``422``, as in FastAPI:
+
+ >>> def add(x: int, y: int) -> int:
+ ... return x + y
+ >>> with test_app(mk_app([add])) as client:
+ ... client.post('/add', json={'x': 3}).status_code
+ 422
"""
with run_app(app, use_server=False) as client:
yield client
@@ -450,16 +492,23 @@ def serve_app(app: FastAPI, port: int = 8000, host: str = "127.0.0.1"):
def quick_test(func, **kwargs):
"""
- Quick test helper for a single function.
+ Call one function through HTTP and return the decoded JSON response.
- Creates an app, runs it with TestClient, and tests a single function call.
+ Builds ``mk_app([func])``, POSTs ``kwargs`` as the JSON body to
+ ``/``, and returns ``response.json()``. Meant for a one-line
+ smoke check of what a function looks like over HTTP.
Args:
func: Function to test
- **kwargs: Arguments to pass to the function
+ **kwargs: Arguments to pass to the function (sent as the JSON body)
Returns:
- Response from calling the function
+ The JSON-decoded response body, i.e. the function's return value after
+ JSON round-tripping.
+
+ Raises:
+ requests.HTTPError: If the response status is 4xx or 5xx (for example
+ a missing required argument, or an exception in ``func``).
Examples:
From fc4766ac3521d518a5eee6e591c4d347b4e33072 Mon Sep 17 00:00:00 2001
From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com>
Date: Tue, 15 Sep 2026 13:53:02 +0200
Subject: [PATCH 03/14] docs: remove committed docsrc scaffold, gitignore build
output
Regenerated on demand by epythet quickstart; a stale committed copy
predates the epythet 0.2.9+ repair fixes. Also gitignore the sweep venv.
---
.gitignore | 2 ++
docsrc/.gitignore | 7 -------
docsrc/api.rst | 8 --------
docsrc/conf.py | 8 --------
docsrc/index.md | 15 ---------------
5 files changed, 2 insertions(+), 38 deletions(-)
delete mode 100644 docsrc/.gitignore
delete mode 100644 docsrc/api.rst
delete mode 100644 docsrc/conf.py
delete mode 100644 docsrc/index.md
diff --git a/.gitignore b/.gitignore
index b7ce764..dad41be 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
docs/*
+docsrc/
+.venv-sweep/
wads_configs.json
data/wads_configs.json
wads/data/wads_configs.json
diff --git a/docsrc/.gitignore b/docsrc/.gitignore
deleted file mode 100644
index 6fa88be..0000000
--- a/docsrc/.gitignore
+++ /dev/null
@@ -1,7 +0,0 @@
-# Generated by epythet at build time
-_build/
-api/
-_autosummary/
-_templates/
-_static/epythet.css
-about-this-build.md
diff --git a/docsrc/api.rst b/docsrc/api.rst
deleted file mode 100644
index 79cca6c..0000000
--- a/docsrc/api.rst
+++ /dev/null
@@ -1,8 +0,0 @@
-API reference
-=============
-
-.. autosummary::
- :toctree: _autosummary
- :recursive:
-
- qh
diff --git a/docsrc/conf.py b/docsrc/conf.py
deleted file mode 100644
index 2517c43..0000000
--- a/docsrc/conf.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""Sphinx configuration, generated by epythet.
-
-Settings come from ``[tool.epythet]`` in pyproject.toml (or ``[metadata]`` in
-setup.cfg). Put project-specific overrides below the import; anything defined
-here wins over the generated value.
-"""
-
-from epythet.sphinx_conf import * # noqa: F401,F403
diff --git a/docsrc/index.md b/docsrc/index.md
deleted file mode 100644
index 9b33b26..0000000
--- a/docsrc/index.md
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-```{include} ../README.md
-:relative-images:
-:relative-docs: ..
-```
-
-This documentation as a single file: qh.md (Markdown, for agents).
-
-```{toctree}
-:maxdepth: 3
-:hidden:
-
-api
-```
From 3d95b628b2da00eda71178123d5e9fec817e976c Mon Sep 17 00:00:00 2001
From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com>
Date: Tue, 15 Sep 2026 13:53:07 +0200
Subject: [PATCH 04/14] docs: repair docstring rendering artifacts, fix wrong
exception claim
epythet repair --write: blank lines before doctests/lists in app.py and
stores_qh.py; raw-string the openapi.py docstring using backslash escapes
for RST cross-references (needed a hand, non-raw would have changed the
escapes).
testing.py: quick_test's Raises section claimed requests.HTTPError, but
fastapi.testclient.TestClient is httpx-based (confirmed via MRO), so the
raise_for_status() call actually raises httpx.HTTPStatusError. Left over
from a prior, unreviewed pass on this branch.
---
qh/app.py | 2 ++
qh/openapi.py | 6 +++---
qh/stores_qh.py | 1 +
qh/testing.py | 6 ++++--
4 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/qh/app.py b/qh/app.py
index 713e506..e91897c 100644
--- a/qh/app.py
+++ b/qh/app.py
@@ -322,6 +322,7 @@ def inspect_routes(app: FastAPI) -> List[Dict[str, Any]]:
map used to build the OpenAPI document).
Examples:
+
>>> from qh import mk_app, inspect_routes
>>> def add(x: int, y: int) -> int:
... return x + y
@@ -360,6 +361,7 @@ def print_routes(app: FastAPI) -> None:
app: FastAPI application
Examples:
+
>>> from qh import mk_app, print_routes
>>> def add(x: int, y: int) -> int:
... return x + y
diff --git a/qh/openapi.py b/qh/openapi.py
index 879cde4..88f3687 100644
--- a/qh/openapi.py
+++ b/qh/openapi.py
@@ -153,11 +153,11 @@ def python_type_to_json_schema(
*,
_stack: Optional[frozenset] = None,
) -> Dict[str, Any]:
- """Convert a Python type hint to a JSON Schema fragment.
+ r"""Convert a Python type hint to a JSON Schema fragment.
Primitives, containers and unions are inlined. Named composite types —
- dataclasses, ``TypedDict``\\ s, Pydantic models, ``NamedTuple``\\ s and
- ``Enum``\\ s — are registered in ``schemas`` (the OpenAPI
+ dataclasses, ``TypedDict``\ s, Pydantic models, ``NamedTuple``\ s and
+ ``Enum``\ s — are registered in ``schemas`` (the OpenAPI
``components.schemas`` table) and returned as a ``$ref``, so the same type
used in several places is described once.
diff --git a/qh/stores_qh.py b/qh/stores_qh.py
index 926bca3..7393a13 100644
--- a/qh/stores_qh.py
+++ b/qh/stores_qh.py
@@ -394,6 +394,7 @@ def add_store_access(
- FastAPI instance: uses this existing app
- str: creates a new FastAPI app with this title
- dict: creates a new FastAPI app with these kwargs
+
methods: Dictionary mapping method names to dispatch configuration
- Key is the mapping method name (e.g., '__iter__', '__getitem__')
diff --git a/qh/testing.py b/qh/testing.py
index 15e0343..4053dd3 100644
--- a/qh/testing.py
+++ b/qh/testing.py
@@ -507,8 +507,10 @@ def quick_test(func, **kwargs):
JSON round-tripping.
Raises:
- requests.HTTPError: If the response status is 4xx or 5xx (for example
- a missing required argument, or an exception in ``func``).
+ httpx.HTTPStatusError: If the response status is 4xx or 5xx (for
+ example a missing required argument, or an exception in
+ ``func``); ``fastapi.testclient.TestClient`` is httpx-based, not
+ requests-based.
Examples:
From 94c50e8025b2dee9cf5303ef8f550a6737227499 Mon Sep 17 00:00:00 2001
From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com>
Date: Tue, 15 Sep 2026 13:54:21 +0200
Subject: [PATCH 05/14] docs: add Raises sections for undocumented exceptions
(DOC501/503)
mk_app, get_task_result, use_au_backend, create_method_endpoint: the
docstring now names the exception the body actually raises, verified by
reading each function.
---
qh/app.py | 3 +++
qh/async_endpoints.py | 3 +++
qh/au_integration.py | 3 +++
qh/stores_qh.py | 4 ++++
4 files changed, 13 insertions(+)
diff --git a/qh/app.py b/qh/app.py
index e91897c..8962716 100644
--- a/qh/app.py
+++ b/qh/app.py
@@ -108,6 +108,9 @@ def mk_app(
The FastAPI application (the one passed as ``app``, or a new one) with
one route per function, in the order the functions were given.
+ Raises:
+ TypeError: If ``config`` is neither ``None``, an ``AppConfig``, nor a dict.
+
See Also:
``qh.testing.test_app``: call the resulting app in-process without a server.
``qh.client.mk_client_from_app``: a Python client whose methods mirror the functions.
diff --git a/qh/async_endpoints.py b/qh/async_endpoints.py
index a04a92c..311c881 100644
--- a/qh/async_endpoints.py
+++ b/qh/async_endpoints.py
@@ -70,6 +70,9 @@ async def get_task_result(
task_id: Task identifier
wait: Whether to block until task completes
timeout: Maximum time to wait in seconds
+
+ Raises:
+ HTTPException: 404 if ``task_id`` is unknown to this function's task manager.
"""
task_manager = get_task_manager(func_name)
diff --git a/qh/au_integration.py b/qh/au_integration.py
index c2bdb54..c294095 100644
--- a/qh/au_integration.py
+++ b/qh/au_integration.py
@@ -202,6 +202,9 @@ def use_au_backend(
Returns:
TaskConfig configured to use au
+ Raises:
+ ImportError: If the ``au`` package is not installed.
+
Example:
>>> from au import ThreadBackend, FileSystemStore # doctest: +SKIP
diff --git a/qh/stores_qh.py b/qh/stores_qh.py
index 7393a13..e9e9321 100644
--- a/qh/stores_qh.py
+++ b/qh/stores_qh.py
@@ -151,6 +151,10 @@ def create_method_endpoint(
Returns:
An async endpoint function compatible with FastAPI
+
+ Raises:
+ ValueError: If ``path_params`` has a length this method's branch does
+ not implement (currently 1 or 2 are supported).
"""
http_method = config.get("method", "get")
path_params = path_params or ["user_id"]
From cd0fe8ce546d0e924bb9615a5ac8334524842946 Mon Sep 17 00:00:00 2001
From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com>
Date: Tue, 15 Sep 2026 13:55:51 +0200
Subject: [PATCH 06/14] docs: add Returns sections, fix wrong
get_python_type_name examples
get_python_type_name's Examples claimed list[int] -> "list[int]" and
Optional[str] -> "Optional[str]"; verified against the running code
(Python 3.10+) both actually return the bare name ("list", "Optional")
because typing/builtin generic aliases now carry __name__, short-
circuiting the bracketed-args branch. Replaced with a runnable doctest
and an accurate Returns section. Also added Returns to
parse_function_name and get_task_result (DOC201).
---
qh/async_endpoints.py | 8 +++++++-
qh/conventions.py | 5 +++++
qh/openapi.py | 23 +++++++++++++++++++----
3 files changed, 31 insertions(+), 5 deletions(-)
diff --git a/qh/async_endpoints.py b/qh/async_endpoints.py
index 311c881..8d3f4d8 100644
--- a/qh/async_endpoints.py
+++ b/qh/async_endpoints.py
@@ -71,8 +71,14 @@ async def get_task_result(
wait: Whether to block until task completes
timeout: Maximum time to wait in seconds
+ Returns:
+ A dict with ``task_id`` and ``status``, plus ``result`` when
+ completed. A failed or still-pending/running task is returned as
+ a ``JSONResponse`` (500 or 202) instead of raising.
+
Raises:
- HTTPException: 404 if ``task_id`` is unknown to this function's task manager.
+ HTTPException: 404 if ``task_id`` is unknown to this function's
+ task manager; 408 if ``wait`` timed out.
"""
task_manager = get_task_manager(func_name)
diff --git a/qh/conventions.py b/qh/conventions.py
index e965a44..c711593 100644
--- a/qh/conventions.py
+++ b/qh/conventions.py
@@ -58,6 +58,11 @@ def parse_function_name(func_name: str) -> ParsedFunctionName:
"""
Parse a function name to extract verb and resource.
+ Returns:
+ A ``ParsedFunctionName``. When the name is ``_`` and
+ ``verb`` is a known CRUD verb, ``resource`` is ``rest``; otherwise
+ the whole name is the resource and ``verb`` is ``""``.
+
Examples:
>>> parse_function_name('get_user')
diff --git a/qh/openapi.py b/qh/openapi.py
index 88f3687..36a6b09 100644
--- a/qh/openapi.py
+++ b/qh/openapi.py
@@ -462,11 +462,26 @@ def get_python_type_name(type_hint: Any) -> str:
"""
Get a string representation of a Python type.
+ ``inspect.Parameter.empty`` and ``None`` map to ``"Any"``. Anything else
+ with a ``__name__`` uses that name alone, with no type arguments — on
+ Python 3.10+ this includes builtin generic aliases (``list[int]``) and
+ ``typing`` generics (``Optional[str]``, ``Dict[str, int]``), since they
+ all carry a ``__name__`` now. The bracketed-argument form only appears
+ for the rare origin type that lacks ``__name__``.
+
+ Returns:
+ The type's bare name, e.g. ``"int"`` or ``"list"``.
+
Examples:
- int → "int"
- str → "str"
- list[int] → "list[int]"
- Optional[str] → "Optional[str]"
+ >>> get_python_type_name(int)
+ 'int'
+ >>> get_python_type_name(str)
+ 'str'
+ >>> get_python_type_name(list[int])
+ 'list'
+ >>> from typing import Optional
+ >>> get_python_type_name(Optional[str])
+ 'Optional'
"""
if type_hint is inspect.Parameter.empty or type_hint is None:
return "Any"
From 0e19fece4acf3c2187a987792e344beff0b9274c Mon Sep 17 00:00:00 2001
From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com>
Date: Tue, 15 Sep 2026 13:59:00 +0200
Subject: [PATCH 07/14] docs: document the 9 undocumented
TaskStore/TaskExecutor implementations
InMemoryTaskStore, ThreadPoolTaskExecutor, ProcessPoolTaskExecutor override
abstract methods whose contract is on the base class; each override now
gets a one-line pointer plus what's implementation-specific (thread pool
vs process pool, in-memory storage), verified by reading the bodies.
---
qh/async_tasks.py | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/qh/async_tasks.py b/qh/async_tasks.py
index a6f2282..39cdabf 100644
--- a/qh/async_tasks.py
+++ b/qh/async_tasks.py
@@ -122,6 +122,7 @@ def _cleanup_expired(self):
del self._tasks[task_id]
def create_task(self, task_id: str, func_name: str) -> TaskInfo:
+ """``TaskStore.create_task``: record a new pending task in memory."""
with self._lock:
self._cleanup_expired()
task_info = TaskInfo(
@@ -133,15 +134,18 @@ def create_task(self, task_id: str, func_name: str) -> TaskInfo:
return task_info
def get_task(self, task_id: str) -> Optional[TaskInfo]:
+ """``TaskStore.get_task``: look up a task, or ``None`` if unknown."""
with self._lock:
self._cleanup_expired()
return self._tasks.get(task_id)
def update_task(self, task_info: TaskInfo) -> None:
+ """``TaskStore.update_task``: overwrite the stored record for its task ID."""
with self._lock:
self._tasks[task_info.task_id] = task_info
def delete_task(self, task_id: str) -> bool:
+ """``TaskStore.delete_task``: remove a task, returning whether it existed."""
with self._lock:
if task_id in self._tasks:
del self._tasks[task_id]
@@ -149,6 +153,7 @@ def delete_task(self, task_id: str) -> bool:
return False
def list_tasks(self, limit: int = 100) -> list[TaskInfo]:
+ """``TaskStore.list_tasks``: the ``limit`` most recently created tasks, newest first."""
with self._lock:
self._cleanup_expired()
# Return most recent tasks first
@@ -210,6 +215,9 @@ def submit_task(
kwargs: dict,
callback: Callable[[str, Any, Optional[Exception]], None],
) -> None:
+ """``TaskExecutor.submit_task``: run ``func`` on the thread pool, calling
+ ``callback`` with its result or exception when it finishes."""
+
def wrapper():
try:
result = func(*args, **kwargs)
@@ -220,6 +228,7 @@ def wrapper():
self._pool.submit(wrapper)
def shutdown(self, wait: bool = True) -> None:
+ """``TaskExecutor.shutdown``: shut down the underlying thread pool."""
self._pool.shutdown(wait=wait)
@@ -243,6 +252,9 @@ def submit_task(
kwargs: dict,
callback: Callable[[str, Any, Optional[Exception]], None],
) -> None:
+ """``TaskExecutor.submit_task``: run ``func`` in a worker process, calling
+ ``callback`` with its result or exception when the future resolves."""
+
def wrapper():
return func(*args, **kwargs)
@@ -258,6 +270,7 @@ def done_callback(fut):
future.add_done_callback(done_callback)
def shutdown(self, wait: bool = True) -> None:
+ """``TaskExecutor.shutdown``: shut down the underlying process pool."""
self._pool.shutdown(wait=wait)
From 46e78158faeb8030982de2e3aff89e9d5c818a58 Mon Sep 17 00:00:00 2001
From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com>
Date: Tue, 15 Sep 2026 14:00:01 +0200
Subject: [PATCH 08/14] docs: annotate context-manager return types (DOC403)
run_app, test_app, serve_app yield but lacked a Generator[...] return
annotation, which pydoclint needs to recognize the Yields section as
matching a real yield statement.
---
qh/testing.py | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/qh/testing.py b/qh/testing.py
index 4053dd3..a6c2bbe 100644
--- a/qh/testing.py
+++ b/qh/testing.py
@@ -25,7 +25,7 @@
8
"""
-from typing import Optional, Any, Callable, Generator
+from typing import Optional, Any, Callable, Generator, Union
from dataclasses import dataclass
import threading
import time
@@ -386,7 +386,9 @@ def _stop_server(self):
@contextmanager
-def run_app(app: FastAPI, *, use_server: bool = False, **kwargs):
+def run_app(
+ app: FastAPI, *, use_server: bool = False, **kwargs
+) -> Generator[Union[TestClient, str], None, None]:
"""
Context manager for running a FastAPI app.
@@ -422,7 +424,7 @@ def run_app(app: FastAPI, *, use_server: bool = False, **kwargs):
@contextmanager
-def test_app(app: FastAPI):
+def test_app(app: FastAPI) -> Generator[TestClient, None, None]:
"""
Call an app in-process through a ``TestClient``, no server, no port.
@@ -460,7 +462,9 @@ def test_app(app: FastAPI):
@contextmanager
-def serve_app(app: FastAPI, port: int = 8000, host: str = "127.0.0.1"):
+def serve_app(
+ app: FastAPI, port: int = 8000, host: str = "127.0.0.1"
+) -> Generator[str, None, None]:
"""
Context manager for running app with real server.
From 0af26d7844d45af4283449d51bfb102cb94cc784 Mon Sep 17 00:00:00 2001
From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com>
Date: Tue, 15 Sep 2026 14:01:04 +0200
Subject: [PATCH 09/14] docs: describe meaning instead of restating type, fix
trivial summary
client.py: session Args described how it's used (reuse vs create) rather
than repeating its Optional[requests.Session] annotation, verified against
`session or requests.Session()`.
au_integration.py delete_task: replaced the name-restating summary with
what it actually does, verified against the body.
---
qh/au_integration.py | 2 +-
qh/client.py | 9 ++++++---
qh/conventions.py | 2 +-
3 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/qh/au_integration.py b/qh/au_integration.py
index c294095..ca2e217 100644
--- a/qh/au_integration.py
+++ b/qh/au_integration.py
@@ -124,7 +124,7 @@ def update_task(self, task_info: TaskInfo) -> None:
pass
def delete_task(self, task_id: str) -> bool:
- """Delete a task."""
+ """Delete ``task_id`` from the au store, returning whether it was present."""
if task_id in self.au_store:
del self.au_store[task_id]
return True
diff --git a/qh/client.py b/qh/client.py
index fdafc8f..56e1dcd 100644
--- a/qh/client.py
+++ b/qh/client.py
@@ -145,9 +145,12 @@ def mk_client_from_openapi(
Create an HTTP client from an OpenAPI specification.
Args:
- openapi_spec: OpenAPI spec dictionary
+ openapi_spec: The parsed OpenAPI document (as from ``export_openapi``
+ or ``json.load`` on a spec file), used to build one client function
+ per operation.
base_url: Base URL for API requests
- session: Optional requests Session
+ session: An existing session to reuse (e.g. for shared auth/headers);
+ a new one is created if not given.
Returns:
HttpClient with functions for each endpoint
@@ -218,7 +221,7 @@ def mk_client_from_url(
Args:
openapi_url: URL to OpenAPI JSON spec (e.g., "http://localhost:8000/openapi.json")
base_url: Base URL for API requests (defaults to same as openapi_url)
- session: Optional requests Session
+ session: An existing session to reuse; a new one is created if not given.
Returns:
HttpClient with functions for each endpoint
diff --git a/qh/conventions.py b/qh/conventions.py
index c711593..0c025ee 100644
--- a/qh/conventions.py
+++ b/qh/conventions.py
@@ -374,7 +374,7 @@ def apply_conventions_to_funcs(
Apply conventions to a list of functions.
Args:
- funcs: List of functions
+ funcs: The functions to route.
use_conventions: Whether to use conventions
base_path: Base path to prepend to all routes
use_plurals: Whether to use plural resource names
From b476f1d87445c50bf0a2cac2b14a96e81ab81c7f Mon Sep 17 00:00:00 2001
From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com>
Date: Tue, 15 Sep 2026 14:02:51 +0200
Subject: [PATCH 10/14] docs: add verified doctests to the core
configuration/type/rule classes
TaskConfig, RouteConfig, AppConfig, TypeRegistry, RuleChain: these are the
concepts the package's own module docstring names as central ("a rule
chain and a type registry decide where each parameter lives"), so they
get a runnable example; each was executed first (DQ002). The other ~19
DQ002 entry-point findings (simple dataclasses, ABC method contracts
already documented on the base class, and internal building blocks) are
declined for this pass -- see PR report.
---
qh/async_tasks.py | 9 +++++++++
qh/config.py | 15 +++++++++++++--
qh/rules.py | 7 +++++++
qh/types.py | 13 +++++++++++++
4 files changed, 42 insertions(+), 2 deletions(-)
diff --git a/qh/async_tasks.py b/qh/async_tasks.py
index 39cdabf..853f8e9 100644
--- a/qh/async_tasks.py
+++ b/qh/async_tasks.py
@@ -280,6 +280,15 @@ class TaskConfig:
Configuration for async task processing.
This is the explicit configuration. The convention is to use sane defaults.
+ Pass an instance (or a dict) as ``async_config`` to ``qh.mk_app``; ``store``
+ and ``executor`` are built lazily by ``get_store``/``get_executor`` from
+ ``default_executor`` when left ``None``.
+
+ >>> tc = TaskConfig(ttl=60, default_executor='thread')
+ >>> tc.ttl, tc.async_mode
+ (60, 'query')
+ >>> type(tc.get_executor()).__name__
+ 'ThreadPoolTaskExecutor'
"""
# Storage backend for task state
diff --git a/qh/config.py b/qh/config.py
index 7b22fae..8c2b3af 100644
--- a/qh/config.py
+++ b/qh/config.py
@@ -19,7 +19,13 @@
@dataclass
class RouteConfig:
- """Configuration for a single route (function endpoint)."""
+ """Configuration for a single route (function endpoint).
+
+ >>> rc = RouteConfig(methods=['GET'])
+ >>> merged = rc.merge_with(RouteConfig(path='/x'))
+ >>> merged.methods, merged.path
+ (['GET'], '/x')
+ """
# Route path (None = auto-generate from function name)
path: Optional[str] = None
@@ -73,7 +79,12 @@ def merge_with(self, other: "RouteConfig") -> "RouteConfig":
@dataclass
class AppConfig:
- """Global configuration for the entire FastAPI app."""
+ """Global configuration for the entire FastAPI app.
+
+ >>> ac = AppConfig(path_prefix='/api')
+ >>> ac.default_methods, ac.path_prefix
+ (['POST'], '/api')
+ """
# Default HTTP methods for all routes
default_methods: List[str] = field(default_factory=lambda: ["POST"])
diff --git a/qh/rules.py b/qh/rules.py
index 1007e45..35fd301 100644
--- a/qh/rules.py
+++ b/qh/rules.py
@@ -248,6 +248,13 @@ class RuleChain:
Chain of rules evaluated in order with first-match semantics.
Rules are tried from most specific to most general.
+
+ >>> chain = RuleChain()
+ >>> chain.add_rule(TypeRule({int: TransformSpec(http_location=HttpLocation.QUERY)}))
+ >>> chain.match(param_name='x', param_type=int).http_location
+
+ >>> chain.match(param_name='y', param_type=str) is None
+ True
"""
def __init__(self, rules: Optional[list[Rule]] = None):
diff --git a/qh/types.py b/qh/types.py
index 8214863..df9bf42 100644
--- a/qh/types.py
+++ b/qh/types.py
@@ -55,6 +55,19 @@ class TypeRegistry:
Registry for type handlers.
Manages conversion between Python types and HTTP representations.
+ Comes pre-populated with pass-through handlers for the JSON-native
+ builtins (``str``, ``int``, ``float``, ``bool``, ``list``, ``dict``,
+ ``NoneType``); ``register_type`` adds more, including via
+ ``register_json_type``.
+
+ >>> reg = TypeRegistry()
+ >>> reg.get_handler(int).to_json(3)
+ 3
+ >>> reg.get_handler(str) is not None
+ True
+ >>> class Unregistered: pass
+ >>> reg.get_handler(Unregistered) is None
+ True
"""
def __init__(self):
From 8bee98f1374851d68238761bab00f30e50b118f2 Mon Sep 17 00:00:00 2001
From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com>
Date: Tue, 15 Sep 2026 14:03:13 +0200
Subject: [PATCH 11/14] docs: add For AI agents README section
epythet ai-readme-check --write, per the maintainer's local policy
(agentic_aspects=add, agents-first, humor on).
---
README.md | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/README.md b/README.md
index f0bfe1c..4d0b436 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,16 @@
pip install qh
```
+
+## For AI agents
+
+`qh` publishes its documentation in forms made for coding agents. If you are one, start here.
+
+**The documentation, machine-readable**: [`llms.txt`](https://i2mint.github.io/qh/llms.txt) indexes every page; [`qh.md`](https://i2mint.github.io/qh/qh.md) is the whole documentation in one file; every page has a `.md` twin; [`objects.inv`](https://i2mint.github.io/qh/objects.inv) maps symbols to URLs.
+
+If you can't let go of the old ways, the rest of this README is written for you, starting at [Quickstart: From Function to API in 3 Lines](#quickstart-from-function-to-api-in-3-lines).
+
+
## Quickstart: From Function to API in 3 Lines
```python
From b8e7946f6d20ca68d2ac95ec36be6ab95c0f5a14 Mon Sep 17 00:00:00 2001
From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com>
Date: Tue, 15 Sep 2026 14:06:18 +0200
Subject: [PATCH 12/14] docs: fill in missing Args entries (DOC101/DOC103)
TaskManager.cancel_task, parse_function_name, validate_route_config,
build_request_body_schema, get_python_type_name, extract_function_signature,
Rule.match, RuleChain.match, register_json_type: each parameter name
verified against the actual signature. AppRunner.__init__ is left as-is --
its Args are already complete, but pydoclint's convention expects them on
the class docstring (DOC301) while also flagging __init__ (D107/DOC101) if
they're not duplicated there; a genuine ledger rule conflict, noted in the
PR report rather than resolved by picking a side inside this repo.
---
qh/async_tasks.py | 3 +++
qh/conventions.py | 3 +++
qh/endpoint.py | 4 ++++
qh/openapi.py | 13 +++++++++++++
qh/rules.py | 14 ++++++++++++++
qh/types.py | 9 +++++++++
6 files changed, 46 insertions(+)
diff --git a/qh/async_tasks.py b/qh/async_tasks.py
index 853f8e9..57d12ef 100644
--- a/qh/async_tasks.py
+++ b/qh/async_tasks.py
@@ -466,6 +466,9 @@ def cancel_task(self, task_id: str) -> bool:
"""
Cancel a task (if possible).
+ Args:
+ task_id: Task identifier
+
Note:
Cancellation is best-effort and may not work for all executors.
diff --git a/qh/conventions.py b/qh/conventions.py
index 0c025ee..2884ff2 100644
--- a/qh/conventions.py
+++ b/qh/conventions.py
@@ -58,6 +58,9 @@ def parse_function_name(func_name: str) -> ParsedFunctionName:
"""
Parse a function name to extract verb and resource.
+ Args:
+ func_name: The function's name, e.g. ``'get_user'``.
+
Returns:
A ``ParsedFunctionName``. When the name is ``_`` and
``verb`` is a known CRUD verb, ``resource`` is ``rest``; otherwise
diff --git a/qh/endpoint.py b/qh/endpoint.py
index 9c6c71d..7ee5f99 100644
--- a/qh/endpoint.py
+++ b/qh/endpoint.py
@@ -326,6 +326,10 @@ def validate_route_config(func: Callable, config: RouteConfig) -> None:
"""
Validate that route configuration is compatible with function.
+ Args:
+ func: The function the route is for.
+ config: The route configuration to check against ``func``'s signature.
+
Raises:
ValueError: If configuration is invalid
"""
diff --git a/qh/openapi.py b/qh/openapi.py
index 36a6b09..4a2a9f5 100644
--- a/qh/openapi.py
+++ b/qh/openapi.py
@@ -385,6 +385,13 @@ def build_request_body_schema(
path/query/header parameters are emitted separately by
:func:`build_parameters`. A parameter with no default is ``required``.
+ Args:
+ func: The function whose parameters are being described.
+ param_specs: Parameter name to ``TransformSpec``, deciding each
+ parameter's HTTP location (see ``_param_location``).
+ schemas: The mutable ``components.schemas`` accumulator, passed
+ through to ``python_type_to_json_schema`` for composite types.
+
Returns:
an object JSON Schema, or ``None`` when the function has no body
parameters (e.g. a GET route whose arguments are all query parameters).
@@ -469,6 +476,9 @@ def get_python_type_name(type_hint: Any) -> str:
all carry a ``__name__`` now. The bracketed-argument form only appears
for the rare origin type that lacks ``__name__``.
+ Args:
+ type_hint: A type or type annotation, or ``inspect.Parameter.empty``.
+
Returns:
The type's bare name, e.g. ``"int"`` or ``"list"``.
@@ -508,6 +518,9 @@ def extract_function_signature(func: Callable) -> Dict[str, Any]:
"""
Extract detailed signature information from a function.
+ Args:
+ func: The function to inspect.
+
Returns:
Dictionary with signature metadata:
diff --git a/qh/rules.py b/qh/rules.py
index 35fd301..52cb4b3 100644
--- a/qh/rules.py
+++ b/qh/rules.py
@@ -78,6 +78,13 @@ def match(
"""
Check if this rule matches the given parameter context.
+ Args:
+ param_name: The parameter's name.
+ param_type: The parameter's type annotation.
+ param_default: The parameter's default, or ``inspect.Parameter.empty``.
+ func: The function the parameter belongs to.
+ func_name: ``func``'s name.
+
Returns:
TransformSpec if matched, None otherwise
"""
@@ -277,6 +284,13 @@ def match(
"""
Find first matching rule.
+ Args:
+ param_name: The parameter's name.
+ param_type: The parameter's type annotation.
+ param_default: The parameter's default, or ``inspect.Parameter.empty``.
+ func: The function the parameter belongs to, if known.
+ func_name: ``func``'s name.
+
Returns:
TransformSpec from first matching rule, or None if no match
"""
diff --git a/qh/types.py b/qh/types.py
index df9bf42..df29aad 100644
--- a/qh/types.py
+++ b/qh/types.py
@@ -297,6 +297,15 @@ def register_json_type(
1. Class decorator (auto-detect to_dict/from_dict methods)
2. With explicit serializers
+ Args:
+ cls: The class being decorated, when used as ``@register_json_type``
+ with no arguments; ``None`` when called as
+ ``@register_json_type(to_json=..., from_json=...)``.
+ to_json: Serializer; when omitted, falls back to ``cls.to_dict()``,
+ then ``obj.__dict__``.
+ from_json: Deserializer; when omitted, falls back to
+ ``cls.from_dict``, then ``cls(**data)``.
+
Examples:
>>> @register_json_type
From 5501cbfbbe13a3b8516325e616fa8083bdc0f70b Mon Sep 17 00:00:00 2001
From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com>
Date: Tue, 15 Sep 2026 14:11:35 +0200
Subject: [PATCH 13/14] docs: fix findings from adversarial review
- service_running: prose claimed the launched service is torn down on
exit; the Note two paragraphs below already correctly said the opposite
(the finally-block is a no-op). Made the prose match reality instead of
contradicting the Note.
- mk_app: Raises section only listed TypeError, but validate_route_config
can also raise ValueError for an invalid per-function route config.
- TypeRegistry: its own docstring attributed the module-level register_type
function to the instance's own registry; register_type (and
register_json_type) mutate the separate global registry, not the
instance -- verified against register_type's implementation.
---
qh/app.py | 3 +++
qh/testing.py | 3 +--
qh/types.py | 6 ++++--
3 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/qh/app.py b/qh/app.py
index 8962716..e354b15 100644
--- a/qh/app.py
+++ b/qh/app.py
@@ -110,6 +110,9 @@ def mk_app(
Raises:
TypeError: If ``config`` is neither ``None``, an ``AppConfig``, nor a dict.
+ ValueError: If a per-function route config is invalid for that function
+ (see ``qh.endpoint.validate_route_config``), e.g. a ``path`` whose
+ ``{param}`` placeholders don't match the function's parameters.
See Also:
``qh.testing.test_app``: call the resulting app in-process without a server.
diff --git a/qh/testing.py b/qh/testing.py
index a6c2bbe..7cfa182 100644
--- a/qh/testing.py
+++ b/qh/testing.py
@@ -101,8 +101,7 @@ def service_running(
This context manager checks if a service is already running at the specified URL.
If not running, it launches the service using one of the provided methods (app,
- launcher) and tears it down on exit. If the service was already running, it leaves
- it running on exit.
+ launcher). Either way, it leaves the service running on exit -- see the Note below.
Exactly one of ``url``, ``app``, or ``launcher`` must be provided.
diff --git a/qh/types.py b/qh/types.py
index df29aad..c2b2494 100644
--- a/qh/types.py
+++ b/qh/types.py
@@ -57,8 +57,10 @@ class TypeRegistry:
Manages conversion between Python types and HTTP representations.
Comes pre-populated with pass-through handlers for the JSON-native
builtins (``str``, ``int``, ``float``, ``bool``, ``list``, ``dict``,
- ``NoneType``); ``register_type`` adds more, including via
- ``register_json_type``.
+ ``NoneType``); its ``register`` method adds more. The module-level
+ ``register_type`` (and the ``register_json_type`` decorator built on it)
+ register into the separate, global registry used by the rest of ``qh``,
+ not into a particular ``TypeRegistry`` instance.
>>> reg = TypeRegistry()
>>> reg.get_handler(int).to_json(3)
From cd22a9f79497dbc6650f0c18acd2ba22d28011cb Mon Sep 17 00:00:00 2001
From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com>
Date: Tue, 15 Sep 2026 14:11:40 +0200
Subject: [PATCH 14/14] fix: add httpx2 to dependencies for starlette's
TestClient
Unrelated to the docs sweep, but blocks CI entirely as of this session:
starlette (now at 1.x) requires the separate httpx2 package for
TestClient, which qh.testing (test_app, quick_test, AppRunner) needs at
runtime, not just in tests. Verified: pytest collection fails with
"RuntimeError: The starlette.testclient module requires the httpx2
package" without this; passes with it. CI installs base dependencies
only (no [dev] extra), so this has to be a base dependency, not a dev one.
---
pyproject.toml | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index 32a5d59..c70e815 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,6 +16,7 @@ dependencies = [
"requests>=2.31.0",
"pydantic>=2.0.0",
"i2",
+ "httpx2",
]
[project.license]
@@ -25,7 +26,7 @@ text = "Apache-2.0"
Homepage = "https://github.com/i2mint/qh"
[project.optional-dependencies]
-dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.1.0", "httpx>=0.24.0"]
+dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.1.0", "httpx>=0.24.0", "httpx2"]
docs = ["sphinx>=6.0", "sphinx-rtd-theme>=1.0"]
[tool.ruff]