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]