Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
docs/*
docsrc/
.venv-sweep/
wads_configs.json
data/wads_configs.json
wads/data/wads_configs.json
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@
pip install qh
```

<!-- epythet:agentic-readme:start -->
## 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).
<!-- epythet:agentic-readme:end -->

## Quickstart: From Function to API in 3 Lines

```python
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies = [
"requests>=2.31.0",
"pydantic>=2.0.0",
"i2",
"httpx2",
]

[project.license]
Expand All @@ -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]
Expand Down
28 changes: 25 additions & 3 deletions qh/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
96 changes: 81 additions & 15 deletions qh/app.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 ``/<function name>`` 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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 = []

Expand All @@ -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)

Expand Down
11 changes: 11 additions & 0 deletions qh/async_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading