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.
@@ -0,0 +1,4 @@
1
+ from importlib import metadata
2
+
3
+ version = metadata.version('fastapi-runner')
4
+ del metadata
@@ -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
+ )
@@ -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)
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any