fastapi-runner 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_runner/__init__.py +4 -0
- fastapi_runner/entrypoint.py +192 -0
- fastapi_runner/error_handling.py +141 -0
- fastapi_runner/errors.py +27 -0
- fastapi_runner/lifespan.py +125 -0
- fastapi_runner/middleware.py +153 -0
- fastapi_runner/types.py +26 -0
- fastapi_runner-0.0.0.dist-info/METADATA +216 -0
- fastapi_runner-0.0.0.dist-info/RECORD +10 -0
- fastapi_runner-0.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import typing as t
|
|
3
|
+
from collections import abc
|
|
4
|
+
from importlib import metadata
|
|
5
|
+
|
|
6
|
+
import fastapi.exceptions
|
|
7
|
+
import fastapi.middleware.cors
|
|
8
|
+
import pydantic
|
|
9
|
+
import pydantic_settings
|
|
10
|
+
|
|
11
|
+
from fastapi_runner import error_handling, errors, lifespan, middleware, types
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApplicationSettings(pydantic_settings.BaseSettings):
|
|
15
|
+
model_config = {'env_prefix': 'FASTAPI_RUNNER_'}
|
|
16
|
+
application: str
|
|
17
|
+
access_log: bool = True
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CORSSettings(pydantic_settings.BaseSettings):
|
|
21
|
+
model_config = {'env_prefix': 'FASTAPI_RUNNER_CORS_'}
|
|
22
|
+
allow_credentials: bool = False
|
|
23
|
+
allow_headers: list[str] = []
|
|
24
|
+
allow_methods: list[str] = [
|
|
25
|
+
'DELETE',
|
|
26
|
+
'GET',
|
|
27
|
+
'HEAD',
|
|
28
|
+
'OPTIONS',
|
|
29
|
+
'POST',
|
|
30
|
+
'PUT',
|
|
31
|
+
]
|
|
32
|
+
allow_origins: list[str] = []
|
|
33
|
+
allow_private_network: bool = False
|
|
34
|
+
enabled: bool = True
|
|
35
|
+
expose_headers: list[str] = []
|
|
36
|
+
max_age: int = 600
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
ConfigHook = abc.Callable[[fastapi.FastAPI], None]
|
|
40
|
+
LifespanGenerator = abc.Callable[[], abc.Generator[lifespan.LifespanHook]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def app_factory() -> fastapi.FastAPI:
|
|
44
|
+
"""Configurable application factory.
|
|
45
|
+
|
|
46
|
+
Use this function with `uvicorn` as a factory to create an
|
|
47
|
+
application instance based on the `APPLICATION` environment
|
|
48
|
+
variable. It identifies the python distribution to import.
|
|
49
|
+
The distribution configures `fastapi_runner` entry points
|
|
50
|
+
named `configure` and (optionally) `lifespans` that match
|
|
51
|
+
the `ConfigHook` and `LifespanGenerator` types.
|
|
52
|
+
"""
|
|
53
|
+
settings = _get_application_settings()
|
|
54
|
+
try:
|
|
55
|
+
distribution = metadata.distribution(settings.application)
|
|
56
|
+
except metadata.PackageNotFoundError:
|
|
57
|
+
raise errors.ApplicationConfigurationError(
|
|
58
|
+
f'APPLICATION={settings.application!r} does not identify an '
|
|
59
|
+
'installed Python distribution'
|
|
60
|
+
) from None
|
|
61
|
+
|
|
62
|
+
configure, lifespan_generator = _load_hooks(distribution)
|
|
63
|
+
|
|
64
|
+
return create_app(
|
|
65
|
+
configure=configure,
|
|
66
|
+
lifespan_generator=lifespan_generator,
|
|
67
|
+
enable_access_log=settings.access_log,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def create_app(
|
|
72
|
+
*,
|
|
73
|
+
configure: ConfigHook | None = None,
|
|
74
|
+
lifespan_generator: LifespanGenerator | None = None,
|
|
75
|
+
enable_access_log: bool = True,
|
|
76
|
+
) -> fastapi.FastAPI:
|
|
77
|
+
"""Create a FastAPI application instance using hooks
|
|
78
|
+
|
|
79
|
+
This method creates an application instance using the supplied
|
|
80
|
+
hooks. In addition to setting up a composable lifespan, the
|
|
81
|
+
CORS and access logging middlewares are installed and several
|
|
82
|
+
error handlers are configured.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
configure: A hook that configures the application.
|
|
86
|
+
lifespan_generator: A generator that yields lifespan hooks.
|
|
87
|
+
enable_access_log: Enable the access logging middleware?
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
A FastAPI application instance.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
span = lifespan.Lifespan()
|
|
94
|
+
if lifespan_generator is not None:
|
|
95
|
+
for hook in lifespan_generator():
|
|
96
|
+
span.add_lifespan(hook)
|
|
97
|
+
|
|
98
|
+
app = fastapi.FastAPI(lifespan=span, telemetry={'auto_configure': False})
|
|
99
|
+
|
|
100
|
+
cors_settings = CORSSettings()
|
|
101
|
+
if cors_settings.enabled:
|
|
102
|
+
app.add_middleware(
|
|
103
|
+
fastapi.middleware.cors.CORSMiddleware,
|
|
104
|
+
allow_credentials=cors_settings.allow_credentials,
|
|
105
|
+
allow_headers=cors_settings.allow_headers,
|
|
106
|
+
allow_methods=cors_settings.allow_methods,
|
|
107
|
+
allow_origins=cors_settings.allow_origins,
|
|
108
|
+
allow_private_network=cors_settings.allow_private_network,
|
|
109
|
+
expose_headers=cors_settings.expose_headers,
|
|
110
|
+
max_age=cors_settings.max_age,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Overrideable to customize error document creation.
|
|
114
|
+
state = t.cast('types.AppStateShape', app.state)
|
|
115
|
+
state.fastapi_runner_error_updater = None
|
|
116
|
+
|
|
117
|
+
# Add custom error handlers for specific exception
|
|
118
|
+
# types BEFORE invoking the application level configure
|
|
119
|
+
# so that the app can replace, override, whatever.
|
|
120
|
+
app.add_exception_handler(
|
|
121
|
+
fastapi.exceptions.RequestValidationError,
|
|
122
|
+
error_handling.validation_error_handler,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Let the application configure its routes
|
|
126
|
+
if configure is not None:
|
|
127
|
+
configure(app)
|
|
128
|
+
|
|
129
|
+
# Add the problem details translator last to ensure that
|
|
130
|
+
# unexpected exceptions are translated.
|
|
131
|
+
app.add_exception_handler(
|
|
132
|
+
fastapi.exceptions.HTTPException,
|
|
133
|
+
error_handling.problem_details_translator,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# The AccessLogMiddleware should ALWAYS be added last
|
|
137
|
+
if enable_access_log:
|
|
138
|
+
app.add_middleware(
|
|
139
|
+
middleware.AccessLogMiddleware,
|
|
140
|
+
ignored_paths=('/docs', '/openapi.json', '/redoc'),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return app
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _get_application_settings() -> ApplicationSettings:
|
|
147
|
+
"""Retrieve the application settings or fail with a useful message."""
|
|
148
|
+
try:
|
|
149
|
+
return ApplicationSettings()
|
|
150
|
+
except pydantic.ValidationError as error:
|
|
151
|
+
prefix = ApplicationSettings.model_config['env_prefix']
|
|
152
|
+
missing = set[str]()
|
|
153
|
+
invalid = set[str]()
|
|
154
|
+
for e in error.errors():
|
|
155
|
+
if e['type'] == 'missing':
|
|
156
|
+
missing.add(str(e['loc'][0]))
|
|
157
|
+
else:
|
|
158
|
+
invalid.add(str(e['loc'][0]))
|
|
159
|
+
if missing:
|
|
160
|
+
names = ' '.join(f'{prefix}{name.upper()}' for name in missing)
|
|
161
|
+
raise errors.ApplicationConfigurationError(
|
|
162
|
+
f'Missing required environment variables: {names}'
|
|
163
|
+
) from None
|
|
164
|
+
if invalid:
|
|
165
|
+
names = ' '.join(f'{prefix}{name.upper()}' for name in invalid)
|
|
166
|
+
raise errors.ApplicationConfigurationError(
|
|
167
|
+
f'Invalid environment variables: {names}'
|
|
168
|
+
) from None
|
|
169
|
+
raise errors.ApplicationConfigurationError(
|
|
170
|
+
'Unknown error loading application settings'
|
|
171
|
+
) from None # pragma: no cover -- paranoia
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _load_hooks(
|
|
175
|
+
distribution: metadata.Distribution,
|
|
176
|
+
) -> tuple[ConfigHook, LifespanGenerator | None]:
|
|
177
|
+
entry_points = distribution.entry_points.select(group='fastapi_runner')
|
|
178
|
+
num_eps = len(entry_points.select(name='configure'))
|
|
179
|
+
if num_eps != 1:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f'Expected exactly one configure entrypoint in fastapi_runner'
|
|
182
|
+
f' group, but found {num_eps} in {distribution.name!r}'
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
configure = t.cast('ConfigHook', entry_points['configure'].load())
|
|
186
|
+
lifespan_generator: LifespanGenerator | None = None
|
|
187
|
+
with contextlib.suppress(KeyError):
|
|
188
|
+
lifespan_generator = t.cast(
|
|
189
|
+
'LifespanGenerator', entry_points['lifespans'].load()
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return configure, lifespan_generator
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import collections
|
|
2
|
+
import functools
|
|
3
|
+
import inspect
|
|
4
|
+
import typing as t
|
|
5
|
+
from collections import abc
|
|
6
|
+
|
|
7
|
+
import fastapi.exceptions
|
|
8
|
+
|
|
9
|
+
from fastapi_runner import errors, types
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ValidationErrorShape(t.TypedDict, total=False):
|
|
13
|
+
"""The shape of `fastapi.exceptions.ValidationError.errors()`"""
|
|
14
|
+
|
|
15
|
+
type: t.Required[str]
|
|
16
|
+
loc: t.Required[abc.Sequence[str]]
|
|
17
|
+
msg: t.Required[str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _ensure_declared_exception_type[T: Exception, R](
|
|
21
|
+
exc: Exception, handler: abc.Callable[[fastapi.Request, T], R]
|
|
22
|
+
) -> t.TypeGuard[T]:
|
|
23
|
+
"""Type guard that ensures the exc type matches the handler.
|
|
24
|
+
|
|
25
|
+
This is used in `typesafe_error_handler` to adapt the exception
|
|
26
|
+
parameter in the FastAPI error handler to the precise type.
|
|
27
|
+
"""
|
|
28
|
+
params = tuple(inspect.signature(handler).parameters)
|
|
29
|
+
exception_param = params[1]
|
|
30
|
+
expected = t.get_type_hints(handler)[exception_param]
|
|
31
|
+
return (
|
|
32
|
+
isinstance(expected, type)
|
|
33
|
+
and issubclass(expected, Exception)
|
|
34
|
+
and isinstance(exc, expected)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def typesafe_error_handler[T: Exception, RT](
|
|
39
|
+
handler: abc.Callable[[fastapi.Request, T], RT],
|
|
40
|
+
) -> abc.Callable[[fastapi.Request, Exception], RT]:
|
|
41
|
+
"""Adapt a type-safe error handler to a generic error hook.
|
|
42
|
+
|
|
43
|
+
FastAPI uses a generic `Exception` in exception handlers
|
|
44
|
+
even though the precise type is specified in the
|
|
45
|
+
`add_exception_handler` call. This decorator exposes the
|
|
46
|
+
more generic parameter type while ensuring that the precise
|
|
47
|
+
type is checked at runtime before calling the handler.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
handler: The error handler function to adapt.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
A wrapper function that accepts an `Exception` and
|
|
54
|
+
type narrows *before* calling `handler`.
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
@functools.wraps(handler)
|
|
59
|
+
def wrapper(request: fastapi.Request, exc: Exception) -> RT:
|
|
60
|
+
if _ensure_declared_exception_type(exc, handler):
|
|
61
|
+
return handler(request, exc)
|
|
62
|
+
raise TypeError(f'Expected {T.__name__}, got {exc.__class__.__name__}')
|
|
63
|
+
|
|
64
|
+
return wrapper
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@typesafe_error_handler
|
|
68
|
+
def problem_details_translator(
|
|
69
|
+
request: fastapi.Request, exc: fastapi.exceptions.HTTPException
|
|
70
|
+
) -> fastapi.Response:
|
|
71
|
+
"""Translate HTTP exceptions to Problem Details bodies.
|
|
72
|
+
|
|
73
|
+
The response shape matches `types.ProblemDetailBody` with the
|
|
74
|
+
default status and title coming from `exc` properties.
|
|
75
|
+
Applications should define the `fastapi_runner_error_updater`
|
|
76
|
+
attribute on the state as a callable that updates the error
|
|
77
|
+
body to match application expectations.
|
|
78
|
+
|
|
79
|
+
"""
|
|
80
|
+
error_body: types.ProblemDetailBody
|
|
81
|
+
if isinstance(exc, errors.ProblemDetailError):
|
|
82
|
+
error_body = exc.body
|
|
83
|
+
else:
|
|
84
|
+
error_body = types.ProblemDetailBody.model_validate(
|
|
85
|
+
{
|
|
86
|
+
'status': exc.status_code,
|
|
87
|
+
'title': exc.detail,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
app_state = t.cast('types.AppStateShape', request.app.state)
|
|
92
|
+
if app_state.fastapi_runner_error_updater is not None:
|
|
93
|
+
app_state.fastapi_runner_error_updater(request, exc, error_body)
|
|
94
|
+
|
|
95
|
+
return fastapi.Response(
|
|
96
|
+
media_type='application/problem+json',
|
|
97
|
+
status_code=exc.status_code,
|
|
98
|
+
content=error_body.model_dump_json(),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@typesafe_error_handler
|
|
103
|
+
def validation_error_handler(
|
|
104
|
+
request: fastapi.Request, exc: fastapi.exceptions.ValidationException
|
|
105
|
+
) -> fastapi.Response:
|
|
106
|
+
"""Customize validation error handling.
|
|
107
|
+
|
|
108
|
+
Maps validation errors to the `errors` response body field.
|
|
109
|
+
It is a mapping from the error type to a list of error locations
|
|
110
|
+
where the type can be any string. The standard values are
|
|
111
|
+
defined by the `pydantic_core.ErrorType` literal type. However,
|
|
112
|
+
a `pydantic_core.PydanticCustomError` exception can be raised
|
|
113
|
+
to set the error type to an arbitrary string.
|
|
114
|
+
|
|
115
|
+
The application error update hook can be used to further
|
|
116
|
+
customize the error response body.
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
error_body = types.ValidationErrorBody.model_validate(
|
|
120
|
+
{
|
|
121
|
+
'status': 422,
|
|
122
|
+
'title': 'Validation Error',
|
|
123
|
+
'errors': {},
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
errors: dict[str, list[str]] = collections.defaultdict(list[str])
|
|
128
|
+
err: ValidationErrorShape
|
|
129
|
+
for err in exc.errors():
|
|
130
|
+
errors[err['type']].append('.'.join(err['loc']))
|
|
131
|
+
error_body.errors.update(errors)
|
|
132
|
+
|
|
133
|
+
app_state = t.cast('types.AppStateShape', request.app.state)
|
|
134
|
+
if app_state.fastapi_runner_error_updater is not None:
|
|
135
|
+
app_state.fastapi_runner_error_updater(request, exc, error_body)
|
|
136
|
+
|
|
137
|
+
return fastapi.Response(
|
|
138
|
+
media_type='application/problem+json',
|
|
139
|
+
status_code=error_body.status,
|
|
140
|
+
content=error_body.model_dump_json(),
|
|
141
|
+
)
|
fastapi_runner/errors.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fastapi
|
|
2
|
+
|
|
3
|
+
from fastapi_runner import types
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ApplicationConfigurationError(RuntimeError):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProblemDetailError(fastapi.HTTPException):
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
status_code: int,
|
|
14
|
+
*,
|
|
15
|
+
title: str,
|
|
16
|
+
type: str, # noqa: A002 -- using type to ensure it is not in `body`
|
|
17
|
+
**body: object,
|
|
18
|
+
) -> None:
|
|
19
|
+
super().__init__(status_code=status_code, detail=title)
|
|
20
|
+
self.body = types.ProblemDetailBody.model_validate(
|
|
21
|
+
{
|
|
22
|
+
'type': type,
|
|
23
|
+
'title': title,
|
|
24
|
+
'status': status_code,
|
|
25
|
+
**body,
|
|
26
|
+
}
|
|
27
|
+
)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import http
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
import types
|
|
6
|
+
import typing as t
|
|
7
|
+
from collections import abc
|
|
8
|
+
|
|
9
|
+
import fastapi
|
|
10
|
+
|
|
11
|
+
ConfigHook = abc.Callable[[fastapi.FastAPI], None]
|
|
12
|
+
LifespanState = dict[str, object]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _CMReturning[T](t.Protocol):
|
|
16
|
+
async def __aenter__(self) -> T: ...
|
|
17
|
+
|
|
18
|
+
async def __aexit__(
|
|
19
|
+
self,
|
|
20
|
+
exc_type: type[BaseException] | None,
|
|
21
|
+
exc_value: BaseException | None,
|
|
22
|
+
exc_traceback: types.TracebackType | None,
|
|
23
|
+
/,
|
|
24
|
+
) -> bool | None: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
type LifespanHook = (
|
|
28
|
+
abc.Callable[[fastapi.FastAPI], _CMReturning[object | None]]
|
|
29
|
+
| abc.Callable[[], _CMReturning[object | None]]
|
|
30
|
+
)
|
|
31
|
+
type TypedLifespanHook[T] = (
|
|
32
|
+
abc.Callable[[fastapi.FastAPI], _CMReturning[T]]
|
|
33
|
+
| abc.Callable[[], _CMReturning[T]]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Lifespan(dict[LifespanHook, object | None]):
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
super().__init__()
|
|
40
|
+
self._called = False
|
|
41
|
+
self._lifespans: list[LifespanHook] = []
|
|
42
|
+
|
|
43
|
+
def add_lifespan(self, hook: LifespanHook) -> None:
|
|
44
|
+
if self._called:
|
|
45
|
+
raise RuntimeError(
|
|
46
|
+
'Cannot add lifespan after __call__ has been invoked'
|
|
47
|
+
)
|
|
48
|
+
self._lifespans.append(hook)
|
|
49
|
+
|
|
50
|
+
def get_state[T](self, hook: TypedLifespanHook[T]) -> T:
|
|
51
|
+
try:
|
|
52
|
+
return t.cast('T', self[hook])
|
|
53
|
+
except KeyError:
|
|
54
|
+
raise fastapi.HTTPException(
|
|
55
|
+
status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
56
|
+
detail=f'Unmet lifespan dependency: {hook!r}',
|
|
57
|
+
) from None
|
|
58
|
+
|
|
59
|
+
def __call__(
|
|
60
|
+
self, app: fastapi.FastAPI
|
|
61
|
+
) -> contextlib.AbstractAsyncContextManager[LifespanState]:
|
|
62
|
+
self._called = True
|
|
63
|
+
|
|
64
|
+
@contextlib.asynccontextmanager
|
|
65
|
+
async def cm() -> abc.AsyncGenerator[dict[str, object]]:
|
|
66
|
+
async with contextlib.AsyncExitStack() as stack:
|
|
67
|
+
for hook in self._lifespans:
|
|
68
|
+
if hook not in self:
|
|
69
|
+
self[hook] = await stack.enter_async_context(
|
|
70
|
+
_invoke_hook(hook, app)
|
|
71
|
+
)
|
|
72
|
+
yield {'lifespan_data': self}
|
|
73
|
+
|
|
74
|
+
return cm()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _get_lifespan(request: fastapi.Request) -> Lifespan:
|
|
78
|
+
try:
|
|
79
|
+
lifespan = request.state.lifespan_data
|
|
80
|
+
except (AttributeError, ValueError):
|
|
81
|
+
logger = logging.getLogger(__package__).getChild('_get_lifespan')
|
|
82
|
+
logger.exception('Lifespan is not available in %r', set(request.state))
|
|
83
|
+
raise fastapi.HTTPException(
|
|
84
|
+
status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
85
|
+
detail='Lifespan not available',
|
|
86
|
+
) from None
|
|
87
|
+
|
|
88
|
+
if not isinstance(lifespan, Lifespan):
|
|
89
|
+
raise fastapi.HTTPException(
|
|
90
|
+
status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
91
|
+
detail=f'Unexpected lifespan type: {type(lifespan)}',
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return lifespan
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
type LifespanMap = t.Annotated[Lifespan, fastapi.Depends(_get_lifespan)]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _HookWithoutApp[T](t.Protocol):
|
|
101
|
+
def __call__(self) -> _CMReturning[T]: ...
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _HookWithApp[T](t.Protocol):
|
|
105
|
+
def __call__(self, app: fastapi.FastAPI, /) -> _CMReturning[T]: ...
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _invoke_hook[T](
|
|
109
|
+
hook: _HookWithoutApp[T] | _HookWithApp[T],
|
|
110
|
+
app: fastapi.FastAPI,
|
|
111
|
+
) -> _CMReturning[T]:
|
|
112
|
+
params = tuple(
|
|
113
|
+
inspect.signature(
|
|
114
|
+
hook,
|
|
115
|
+
eval_str=False,
|
|
116
|
+
).parameters.values()
|
|
117
|
+
)
|
|
118
|
+
if not params:
|
|
119
|
+
return t.cast('_HookWithoutApp[T]', hook)()
|
|
120
|
+
if len(params) == 1 and params[0].kind in (
|
|
121
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
122
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
123
|
+
):
|
|
124
|
+
return t.cast('_HookWithApp[T]', hook)(app)
|
|
125
|
+
raise TypeError(f'Invalid lifespan hook signature: {hook!r}')
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
import typing as t
|
|
6
|
+
from collections import abc
|
|
7
|
+
|
|
8
|
+
import starlette.types
|
|
9
|
+
|
|
10
|
+
_DISABLE_ACCESS_LOG_ATTR = '_fastapi_runner_disable_access_log'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
type _Handler[**P, R] = abc.Callable[P, R]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def disable_access_log[**P, R](handler: _Handler[P, R]) -> _Handler[P, R]:
|
|
17
|
+
setattr(handler, _DISABLE_ACCESS_LOG_ATTR, True)
|
|
18
|
+
return handler
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _LogData(t.TypedDict):
|
|
22
|
+
status_code: int | str
|
|
23
|
+
bytes_sent: int
|
|
24
|
+
user_agent: str
|
|
25
|
+
client: str
|
|
26
|
+
method: str
|
|
27
|
+
path: str
|
|
28
|
+
http_version: str
|
|
29
|
+
duration: t.NotRequired[str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _AccessLogState:
|
|
33
|
+
def __init__(self, scope: starlette.types.Scope) -> None:
|
|
34
|
+
self.start_time = time.monotonic_ns()
|
|
35
|
+
self.access_log_disabled = (
|
|
36
|
+
_is_access_log_disabled(scope) if 'endpoint' in scope else False
|
|
37
|
+
)
|
|
38
|
+
self._resolved_access_log_disabled = 'endpoint' in scope
|
|
39
|
+
self.data: _LogData = {
|
|
40
|
+
'status_code': '-',
|
|
41
|
+
'bytes_sent': 0,
|
|
42
|
+
'user_agent': '-',
|
|
43
|
+
'client': '-',
|
|
44
|
+
'method': scope['method'],
|
|
45
|
+
'path': scope['path'],
|
|
46
|
+
'http_version': scope['http_version'],
|
|
47
|
+
}
|
|
48
|
+
if client := scope.get('client', None):
|
|
49
|
+
host: str = client[0]
|
|
50
|
+
port: int = client[1]
|
|
51
|
+
if ':' in host:
|
|
52
|
+
host = f'[{host}]'
|
|
53
|
+
|
|
54
|
+
self.data['client'] = f'{host}:{port}'
|
|
55
|
+
|
|
56
|
+
if headers := scope.get('headers', None):
|
|
57
|
+
for name, value in headers:
|
|
58
|
+
if name.lower() == b'user-agent':
|
|
59
|
+
self.data['user_agent'] = value.decode()
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def status_code(self) -> int | str:
|
|
63
|
+
return self.data['status_code']
|
|
64
|
+
|
|
65
|
+
def set_status_code(self, status: int) -> None:
|
|
66
|
+
self.data['status_code'] = status
|
|
67
|
+
|
|
68
|
+
def increment_bytes_sent(self, value: int) -> None:
|
|
69
|
+
self.data['bytes_sent'] += value
|
|
70
|
+
|
|
71
|
+
def finish(self, scope: starlette.types.Scope) -> None:
|
|
72
|
+
time_ms = (time.monotonic_ns() - self.start_time) * 1e-6
|
|
73
|
+
self.data['duration'] = f'{time_ms:.3f} ms'
|
|
74
|
+
if not self._resolved_access_log_disabled:
|
|
75
|
+
self.access_log_disabled = _is_access_log_disabled(scope)
|
|
76
|
+
self._resolved_access_log_disabled = True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AccessLogMiddleware:
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
app: starlette.types.ASGIApp,
|
|
83
|
+
*,
|
|
84
|
+
ignored_paths: abc.Collection[str] = (),
|
|
85
|
+
) -> None:
|
|
86
|
+
self.app = app
|
|
87
|
+
self.ignored_paths = frozenset(ignored_paths)
|
|
88
|
+
|
|
89
|
+
async def __call__(
|
|
90
|
+
self,
|
|
91
|
+
scope: starlette.types.Scope,
|
|
92
|
+
receive: starlette.types.Receive,
|
|
93
|
+
send: starlette.types.Send,
|
|
94
|
+
) -> None:
|
|
95
|
+
if scope['type'] != 'http' or scope['path'] in self.ignored_paths:
|
|
96
|
+
await self.app(scope, receive, send)
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
async with _message_logger(scope) as state:
|
|
100
|
+
|
|
101
|
+
async def _send(message: starlette.types.Message) -> None:
|
|
102
|
+
if message['type'] == 'http.response.start':
|
|
103
|
+
state.set_status_code(message['status'])
|
|
104
|
+
elif message['type'] == 'http.response.body':
|
|
105
|
+
state.increment_bytes_sent(len(message.get('body', b'')))
|
|
106
|
+
await send(message)
|
|
107
|
+
|
|
108
|
+
await self.app(scope, receive, _send)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _is_access_log_disabled(scope: starlette.types.Scope) -> bool:
|
|
112
|
+
endpoint = scope.get('endpoint', None)
|
|
113
|
+
if endpoint is None:
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
candidates = [endpoint]
|
|
117
|
+
with contextlib.suppress(AttributeError):
|
|
118
|
+
candidates.append(endpoint.__func__)
|
|
119
|
+
|
|
120
|
+
for candidate in candidates:
|
|
121
|
+
if getattr(candidate, _DISABLE_ACCESS_LOG_ATTR, False):
|
|
122
|
+
return True
|
|
123
|
+
if getattr(inspect.unwrap(candidate), _DISABLE_ACCESS_LOG_ATTR, False):
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@contextlib.asynccontextmanager
|
|
130
|
+
async def _message_logger(
|
|
131
|
+
scope: starlette.types.Scope,
|
|
132
|
+
) -> abc.AsyncGenerator[_AccessLogState]:
|
|
133
|
+
state = _AccessLogState(scope)
|
|
134
|
+
try:
|
|
135
|
+
yield state
|
|
136
|
+
finally:
|
|
137
|
+
state.finish(scope)
|
|
138
|
+
if not state.access_log_disabled:
|
|
139
|
+
match state.status_code:
|
|
140
|
+
case int():
|
|
141
|
+
if state.status_code >= 500: # noqa: PLR2004
|
|
142
|
+
log_level = logging.ERROR
|
|
143
|
+
elif state.status_code >= 400: # noqa: PLR2004
|
|
144
|
+
log_level = logging.WARNING
|
|
145
|
+
else:
|
|
146
|
+
log_level = logging.INFO
|
|
147
|
+
case str():
|
|
148
|
+
log_level = logging.ERROR
|
|
149
|
+
case _:
|
|
150
|
+
t.assert_never(state.status_code)
|
|
151
|
+
|
|
152
|
+
logger = logging.getLogger('api-runner.access')
|
|
153
|
+
logger.log(log_level, '', extra=state.data)
|
fastapi_runner/types.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import typing as t
|
|
2
|
+
from collections import abc
|
|
3
|
+
|
|
4
|
+
import fastapi
|
|
5
|
+
import pydantic
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProblemDetailBody(pydantic.BaseModel):
|
|
9
|
+
model_config = pydantic.ConfigDict(extra='allow')
|
|
10
|
+
type: str = 'about:blank'
|
|
11
|
+
title: str
|
|
12
|
+
status: int
|
|
13
|
+
detail: t.Annotated[
|
|
14
|
+
str | None, pydantic.Field(exclude_if=lambda x: x is None)
|
|
15
|
+
] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ValidationErrorBody(ProblemDetailBody):
|
|
19
|
+
errors: dict[str, list[str]] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AppStateShape(t.Protocol):
|
|
23
|
+
fastapi_runner_error_updater: (
|
|
24
|
+
abc.Callable[[fastapi.Request, Exception, ProblemDetailBody], None]
|
|
25
|
+
| None
|
|
26
|
+
)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fastapi-runner
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Production-ready uvicorn runner for FastAPI applications
|
|
5
|
+
Author-email: Dave Shawley <daveshawley@gmail.com>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
8
|
+
Classifier: Framework :: FastAPI
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Natural Language :: English
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: fastapi~=0.143.0
|
|
21
|
+
Requires-Dist: pydantic-settings~=2.14
|
|
22
|
+
Requires-Dist: pydantic~=2.13
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# fastapi-runner
|
|
26
|
+
|
|
27
|
+
`fastapi-runner` provides shared FastAPI application setup and a Uvicorn
|
|
28
|
+
application factory. It keeps cross-cutting behavior in a reusable library
|
|
29
|
+
while the application package owns its routes and application-specific
|
|
30
|
+
lifecycle hooks.
|
|
31
|
+
|
|
32
|
+
This package is a member of the repository's uv workspace, but it is otherwise
|
|
33
|
+
freestanding: its package metadata, source tree, tests, and README are all
|
|
34
|
+
under this directory. It can be built as its own source distribution and wheel
|
|
35
|
+
for a future PyPI release.
|
|
36
|
+
|
|
37
|
+
## Application discovery
|
|
38
|
+
|
|
39
|
+
`app_factory()` selects an installed application using the `FASTAPI_RUNNER_APPLICATION`
|
|
40
|
+
environment variable. The value is a Python distribution name, not a module
|
|
41
|
+
path. That distribution must define exactly one `configure` entry point in the
|
|
42
|
+
`fastapi_runner` group and may define a `lifespans` entry point.
|
|
43
|
+
|
|
44
|
+
For example, an application package can declare:
|
|
45
|
+
|
|
46
|
+
```toml
|
|
47
|
+
[project.entry-points.fastapi_runner]
|
|
48
|
+
configure = "my_app.app:configure"
|
|
49
|
+
lifespans = "my_app.app:lifespans"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The hooks are called as follows:
|
|
53
|
+
|
|
54
|
+
- `configure(app)` registers routes and other application-specific behavior.
|
|
55
|
+
- `lifespans()` yields async context-manager hooks for startup and shutdown.
|
|
56
|
+
- Each lifespan hook may accept no arguments or the `FastAPI` application.
|
|
57
|
+
|
|
58
|
+
Run the generic factory with Uvicorn:
|
|
59
|
+
|
|
60
|
+
```console
|
|
61
|
+
FASTAPI_RUNNER_APPLICATION=my-app uvicorn \
|
|
62
|
+
--factory fastapi_runner.entrypoint:app_factory
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The distribution named by `FASTAPI_RUNNER_APPLICATION` and `fastapi-runner` must both be
|
|
66
|
+
installed in the environment.
|
|
67
|
+
|
|
68
|
+
## Creating an application directly
|
|
69
|
+
|
|
70
|
+
Applications and tests that do not need distribution-based discovery can use
|
|
71
|
+
`create_app()` directly:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from collections import abc
|
|
75
|
+
|
|
76
|
+
import fastapi
|
|
77
|
+
import fastapi_runner.entrypoint
|
|
78
|
+
import fastapi_runner.lifespan
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def configure(app: fastapi.FastAPI) -> None:
|
|
82
|
+
"""Add application-specific behavior."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def lifespans() -> abc.Generator[fastapi_runner.lifespan.LifespanHook]:
|
|
86
|
+
"""Yield the application lifespan hooks."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
app = fastapi_runner.entrypoint.create_app(
|
|
90
|
+
configure=configure,
|
|
91
|
+
lifespan_generator=lifespans,
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Both entry points install the same shared application behavior:
|
|
96
|
+
|
|
97
|
+
- CORS middleware configured through the `FASTAPI_RUNNER_CORS_` environment prefix;
|
|
98
|
+
- problem-detail responses for HTTP and validation errors;
|
|
99
|
+
- an application state hook for customizing problem-detail bodies;
|
|
100
|
+
- access logging, with the documentation routes excluded by default; and
|
|
101
|
+
- a composable lifespan for application startup and shutdown hooks.
|
|
102
|
+
|
|
103
|
+
Applications can use `fastapi_runner.lifespan.LifespanMap` as a FastAPI
|
|
104
|
+
dependency to retrieve the value returned by a registered lifespan hook:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
import contextlib
|
|
108
|
+
import typing as t
|
|
109
|
+
|
|
110
|
+
import fastapi
|
|
111
|
+
|
|
112
|
+
from fastapi_runner.lifespan import LifespanMap
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class State:
|
|
116
|
+
"""State that is maintained for the application lifetime."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@contextlib.asynccontextmanager
|
|
120
|
+
async def state_lifespan() -> t.AsyncGenerator[State]:
|
|
121
|
+
yield State()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_state(*, lifespan: LifespanMap) -> t.Any:
|
|
125
|
+
return lifespan.get_state(state_lifespan)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def status(*, state: t.Annotated[State, fastapi.Depends(get_state)]) -> State:
|
|
129
|
+
return state
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The application may raise `fastapi_runner.errors.ProblemDetailError` when it
|
|
133
|
+
needs to provide a complete problem-detail document. The default error
|
|
134
|
+
handlers also allow an application to update the generated body through the
|
|
135
|
+
`fastapi_runner_error_updater` callback on `app.state`.
|
|
136
|
+
|
|
137
|
+
Use `fastapi_runner.middleware.disable_access_log` on an endpoint when its
|
|
138
|
+
requests should not produce an access-log record.
|
|
139
|
+
|
|
140
|
+
## Deployment
|
|
141
|
+
|
|
142
|
+
The `dave-shawley/fastapi-builder` image is a suitable base image for deploying
|
|
143
|
+
applications built using `fastapi-runner`. The _packaging/Dockerfile_ contains
|
|
144
|
+
a multi-stage build that uses `fastapi-runner` to build the application and then
|
|
145
|
+
copies the resulting artifacts into a minimal image. You should use a similar
|
|
146
|
+
approach when deploying your application.
|
|
147
|
+
|
|
148
|
+
The included Dockerfile also includes support for running the application with
|
|
149
|
+
opentelemetry enabled though you will need to configure the exporter. I use
|
|
150
|
+
opentelemetry-instrument to automatically instrument the application process.
|
|
151
|
+
You simply need to include additional instrumentation libraries like
|
|
152
|
+
[opentelemetry-instrumentation-psycopg] and the instrument command will pick it
|
|
153
|
+
up and instrument the process.
|
|
154
|
+
|
|
155
|
+
[opentelemetry-instrumentation-psycopg]: https://pypi.org/project/opentelemetry-instrumentation-psycopg/
|
|
156
|
+
|
|
157
|
+
### Logging
|
|
158
|
+
|
|
159
|
+
The default logging configuration is based on the [Common Logging Format](https://en.wikipedia.org/wiki/Common_Log_Format).
|
|
160
|
+
The `fastapi_runner.middleware.AccessLogMiddleware` uses the logger named
|
|
161
|
+
`api-runner.access` to log access records. I decided to push the field configuration
|
|
162
|
+
into the logging configuration (see packaging/log-config.json) though I may change
|
|
163
|
+
that in the future depending on ergonomics. The current implemention creates a log
|
|
164
|
+
record with he following custom fields that are accessed using `%(name)s` in the
|
|
165
|
+
format record.
|
|
166
|
+
|
|
167
|
+
| Field | Description |
|
|
168
|
+
| ------------------ | ------------------------------------------------- |
|
|
169
|
+
| `%(bytes_sent)s` | Number of bytes sent in the response |
|
|
170
|
+
| `%(client)s` | Formatted client address |
|
|
171
|
+
| `%(duration)s` | Formatted duration of the request in milliseconds |
|
|
172
|
+
| `%(http_version)s` | HTTP version |
|
|
173
|
+
| `%(method)s` | HTTP request method |
|
|
174
|
+
| `%(path)s` | Request path |
|
|
175
|
+
| `%(status_code)s` | Numeric status code or `-` |
|
|
176
|
+
| `%(user_agent)s` | User agent string or `-` |
|
|
177
|
+
|
|
178
|
+
The formatted lines as configured by _packaging/log-config.json_ look like:
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
2026-10-08 11:44:26,328 WARNING api-runner.access: 192.168.215.1:52540 - "GET /missing HTTP/1.1" 404 22
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
I moved the date out of the log message so that it fits into the general log format.
|
|
185
|
+
You can customize the message format to fit your needs by adjusting the formatter
|
|
186
|
+
attached to the `api-runner.access` logger.
|
|
187
|
+
|
|
188
|
+
The `FASTAPI_RUNNER_ACCESS_LOG` environment variable can be set to `no` to disable
|
|
189
|
+
access logging. There is currently no way to change the access logger name.
|
|
190
|
+
|
|
191
|
+
## Development
|
|
192
|
+
|
|
193
|
+
From the repository root, the shared justfile provides the normal development
|
|
194
|
+
commands:
|
|
195
|
+
|
|
196
|
+
```console
|
|
197
|
+
just test
|
|
198
|
+
just analyze
|
|
199
|
+
just format
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Run only this library's tests directly when iterating on the package:
|
|
203
|
+
|
|
204
|
+
```console
|
|
205
|
+
uv run --package fastapi-runner pytest libraries/fastapi-runner/tests -q
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Build its release artifacts from the repository root:
|
|
209
|
+
|
|
210
|
+
```console
|
|
211
|
+
uv build --package=fastapi-runner --sdist --wheel --clear
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The artifacts are placed in the repository's `dist/` directory. Publishing is
|
|
215
|
+
intentionally separate from the development recipes so each library can adopt
|
|
216
|
+
its own release and PyPI workflow.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
fastapi_runner/__init__.py,sha256=87LBqRGgVHzvmVx2uJgk56ZhaBOzvDSBRfEt4nuOVoM,90
|
|
2
|
+
fastapi_runner/entrypoint.py,sha256=gNYELyrhB25xUEsmLZYJ0Z1eMgSUUfYzHYA6PzmNGos,6567
|
|
3
|
+
fastapi_runner/error_handling.py,sha256=8oqmo47Dbd5VkV4Mn2d_o3goMbJui0Bt-mww155msaY,4673
|
|
4
|
+
fastapi_runner/errors.py,sha256=K8H6vU64rs5Ik4hKEdrA-5XH_FPC1tcnCFjdjbP1arE,654
|
|
5
|
+
fastapi_runner/lifespan.py,sha256=S3XQmih7kg44du5zDa3A9fXxclsCFQd3fHzt7U4cLq0,3731
|
|
6
|
+
fastapi_runner/middleware.py,sha256=BeocDHSgR3rF8TUs5qQ3Ru_hSinVGkvnib_4ry9oUcQ,4760
|
|
7
|
+
fastapi_runner/types.py,sha256=L3KJ_XCffLztpM3G_oCYcCCI1xLo5qj9tVO6MkwtybA,600
|
|
8
|
+
fastapi_runner-0.0.0.dist-info/METADATA,sha256=x7LYRa-WOCkTF39M5ah3E9EwWziUOZ_GAO4-wv2ro7I,7942
|
|
9
|
+
fastapi_runner-0.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
fastapi_runner-0.0.0.dist-info/RECORD,,
|