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/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 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] 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 d3305dc..e354b15 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,18 +50,22 @@ 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: + - A single callable - A list of callables - A dict mapping callables to their route configurations @@ -51,12 +74,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 +91,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 @@ -79,47 +105,62 @@ 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. + + 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. + ``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: + >>> 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) ... 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 @@ -273,13 +314,28 @@ 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 = [] @@ -305,10 +361,20 @@ 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/async_endpoints.py b/qh/async_endpoints.py index f7c09ae..8d3f4d8 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 @@ -69,6 +70,15 @@ async def get_task_result( task_id: Task identifier 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; 408 if ``wait`` timed out. """ task_manager = get_task_manager(func_name) @@ -145,6 +155,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..57d12ef 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) @@ -120,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( @@ -131,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] @@ -147,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 @@ -208,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) @@ -218,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) @@ -241,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) @@ -256,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) @@ -265,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 @@ -442,7 +466,11 @@ 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. + Args: + task_id: Task identifier + + 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..ca2e217 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,12 +118,13 @@ 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 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 @@ -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 @@ -199,7 +202,11 @@ 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 >>> from qh import mk_app # doctest: +SKIP >>> from qh.au_integration import use_au_backend # doctest: +SKIP @@ -218,6 +225,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..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 @@ -112,6 +125,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..56e1dcd 100644 --- a/qh/client.py +++ b/qh/client.py @@ -145,14 +145,18 @@ 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 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 @@ -217,12 +221,13 @@ 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 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 +260,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..8c2b3af 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 @@ -18,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 @@ -72,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"]) @@ -123,6 +135,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 +268,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..2884ff2 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 @@ -57,7 +58,16 @@ 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 + the whole name is the resource and ``verb`` is ``""``. + Examples: + >>> parse_function_name('get_user') ParsedFunctionName(verb='get', resource='user', is_plural=False, is_collection_operation=False) @@ -112,6 +122,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 +176,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 +219,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}' @@ -364,7 +377,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 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/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/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..4a2a9f5 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. @@ -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). @@ -462,11 +469,29 @@ 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__``. + + Args: + type_hint: A type or type annotation, or ``inspect.Parameter.empty``. + + 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" @@ -493,8 +518,12 @@ 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: + - name: function name - module: module path - parameters: list of parameter info @@ -758,6 +787,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..52cb4b3 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 @@ -77,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 """ @@ -247,6 +255,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): @@ -269,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 """ @@ -354,6 +376,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..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"] @@ -389,13 +393,17 @@ 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..7cfa182 100644 --- a/qh/testing.py +++ b/qh/testing.py @@ -1,20 +1,31 @@ +"""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 """ -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. -""" - -from typing import Optional, Any, Callable, Generator +from typing import Optional, Any, Callable, Generator, Union from dataclasses import dataclass import threading import time @@ -23,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: @@ -78,14 +101,15 @@ 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. + 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'). @@ -103,11 +127,13 @@ 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: ... return x + y @@ -118,11 +144,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 @@ -207,10 +235,15 @@ 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: + >>> from qh import mk_app >>> from qh.testing import AppRunner # doctest: +SKIP >>> def add(x: int, y: int) -> int: # doctest: +SKIP @@ -221,13 +254,14 @@ 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: + 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") """ @@ -273,9 +307,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() @@ -302,6 +337,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 @@ -338,7 +376,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) @@ -347,7 +385,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. @@ -362,6 +402,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 @@ -382,11 +423,13 @@ 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]: """ - 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 @@ -395,21 +438,32 @@ 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 + + >>> 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 @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. @@ -424,6 +478,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 @@ -440,18 +495,28 @@ 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: + 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 qh.testing import quick_test >>> >>> def add(x: int, y: int) -> int: diff --git a/qh/types.py b/qh/types.py index 7c74f9c..c2b2494 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 @@ -54,6 +55,21 @@ 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``); 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) + 3 + >>> reg.get_handler(str) is not None + True + >>> class Unregistered: pass + >>> reg.get_handler(Unregistered) is None + True """ def __init__(self): @@ -165,6 +181,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 +295,21 @@ 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 + 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 ... class Point: ... def __init__(self, x, y):