fastapi-runner 0.0.0__tar.gz
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-0.0.0/.gitignore +7 -0
- fastapi_runner-0.0.0/PKG-INFO +216 -0
- fastapi_runner-0.0.0/README.md +192 -0
- fastapi_runner-0.0.0/pyproject.toml +27 -0
- fastapi_runner-0.0.0/src/fastapi_runner/__init__.py +4 -0
- fastapi_runner-0.0.0/src/fastapi_runner/entrypoint.py +192 -0
- fastapi_runner-0.0.0/src/fastapi_runner/error_handling.py +141 -0
- fastapi_runner-0.0.0/src/fastapi_runner/errors.py +27 -0
- fastapi_runner-0.0.0/src/fastapi_runner/lifespan.py +125 -0
- fastapi_runner-0.0.0/src/fastapi_runner/middleware.py +153 -0
- fastapi_runner-0.0.0/src/fastapi_runner/types.py +26 -0
- fastapi_runner-0.0.0/tests/test_entrypoint.py +346 -0
- fastapi_runner-0.0.0/tests/test_error_handling.py +118 -0
- fastapi_runner-0.0.0/tests/test_lifespan.py +257 -0
- fastapi_runner-0.0.0/tests/test_middleware.py +331 -0
|
@@ -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,192 @@
|
|
|
1
|
+
# fastapi-runner
|
|
2
|
+
|
|
3
|
+
`fastapi-runner` provides shared FastAPI application setup and a Uvicorn
|
|
4
|
+
application factory. It keeps cross-cutting behavior in a reusable library
|
|
5
|
+
while the application package owns its routes and application-specific
|
|
6
|
+
lifecycle hooks.
|
|
7
|
+
|
|
8
|
+
This package is a member of the repository's uv workspace, but it is otherwise
|
|
9
|
+
freestanding: its package metadata, source tree, tests, and README are all
|
|
10
|
+
under this directory. It can be built as its own source distribution and wheel
|
|
11
|
+
for a future PyPI release.
|
|
12
|
+
|
|
13
|
+
## Application discovery
|
|
14
|
+
|
|
15
|
+
`app_factory()` selects an installed application using the `FASTAPI_RUNNER_APPLICATION`
|
|
16
|
+
environment variable. The value is a Python distribution name, not a module
|
|
17
|
+
path. That distribution must define exactly one `configure` entry point in the
|
|
18
|
+
`fastapi_runner` group and may define a `lifespans` entry point.
|
|
19
|
+
|
|
20
|
+
For example, an application package can declare:
|
|
21
|
+
|
|
22
|
+
```toml
|
|
23
|
+
[project.entry-points.fastapi_runner]
|
|
24
|
+
configure = "my_app.app:configure"
|
|
25
|
+
lifespans = "my_app.app:lifespans"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The hooks are called as follows:
|
|
29
|
+
|
|
30
|
+
- `configure(app)` registers routes and other application-specific behavior.
|
|
31
|
+
- `lifespans()` yields async context-manager hooks for startup and shutdown.
|
|
32
|
+
- Each lifespan hook may accept no arguments or the `FastAPI` application.
|
|
33
|
+
|
|
34
|
+
Run the generic factory with Uvicorn:
|
|
35
|
+
|
|
36
|
+
```console
|
|
37
|
+
FASTAPI_RUNNER_APPLICATION=my-app uvicorn \
|
|
38
|
+
--factory fastapi_runner.entrypoint:app_factory
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The distribution named by `FASTAPI_RUNNER_APPLICATION` and `fastapi-runner` must both be
|
|
42
|
+
installed in the environment.
|
|
43
|
+
|
|
44
|
+
## Creating an application directly
|
|
45
|
+
|
|
46
|
+
Applications and tests that do not need distribution-based discovery can use
|
|
47
|
+
`create_app()` directly:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from collections import abc
|
|
51
|
+
|
|
52
|
+
import fastapi
|
|
53
|
+
import fastapi_runner.entrypoint
|
|
54
|
+
import fastapi_runner.lifespan
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def configure(app: fastapi.FastAPI) -> None:
|
|
58
|
+
"""Add application-specific behavior."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def lifespans() -> abc.Generator[fastapi_runner.lifespan.LifespanHook]:
|
|
62
|
+
"""Yield the application lifespan hooks."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
app = fastapi_runner.entrypoint.create_app(
|
|
66
|
+
configure=configure,
|
|
67
|
+
lifespan_generator=lifespans,
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Both entry points install the same shared application behavior:
|
|
72
|
+
|
|
73
|
+
- CORS middleware configured through the `FASTAPI_RUNNER_CORS_` environment prefix;
|
|
74
|
+
- problem-detail responses for HTTP and validation errors;
|
|
75
|
+
- an application state hook for customizing problem-detail bodies;
|
|
76
|
+
- access logging, with the documentation routes excluded by default; and
|
|
77
|
+
- a composable lifespan for application startup and shutdown hooks.
|
|
78
|
+
|
|
79
|
+
Applications can use `fastapi_runner.lifespan.LifespanMap` as a FastAPI
|
|
80
|
+
dependency to retrieve the value returned by a registered lifespan hook:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import contextlib
|
|
84
|
+
import typing as t
|
|
85
|
+
|
|
86
|
+
import fastapi
|
|
87
|
+
|
|
88
|
+
from fastapi_runner.lifespan import LifespanMap
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class State:
|
|
92
|
+
"""State that is maintained for the application lifetime."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@contextlib.asynccontextmanager
|
|
96
|
+
async def state_lifespan() -> t.AsyncGenerator[State]:
|
|
97
|
+
yield State()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_state(*, lifespan: LifespanMap) -> t.Any:
|
|
101
|
+
return lifespan.get_state(state_lifespan)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def status(*, state: t.Annotated[State, fastapi.Depends(get_state)]) -> State:
|
|
105
|
+
return state
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The application may raise `fastapi_runner.errors.ProblemDetailError` when it
|
|
109
|
+
needs to provide a complete problem-detail document. The default error
|
|
110
|
+
handlers also allow an application to update the generated body through the
|
|
111
|
+
`fastapi_runner_error_updater` callback on `app.state`.
|
|
112
|
+
|
|
113
|
+
Use `fastapi_runner.middleware.disable_access_log` on an endpoint when its
|
|
114
|
+
requests should not produce an access-log record.
|
|
115
|
+
|
|
116
|
+
## Deployment
|
|
117
|
+
|
|
118
|
+
The `dave-shawley/fastapi-builder` image is a suitable base image for deploying
|
|
119
|
+
applications built using `fastapi-runner`. The _packaging/Dockerfile_ contains
|
|
120
|
+
a multi-stage build that uses `fastapi-runner` to build the application and then
|
|
121
|
+
copies the resulting artifacts into a minimal image. You should use a similar
|
|
122
|
+
approach when deploying your application.
|
|
123
|
+
|
|
124
|
+
The included Dockerfile also includes support for running the application with
|
|
125
|
+
opentelemetry enabled though you will need to configure the exporter. I use
|
|
126
|
+
opentelemetry-instrument to automatically instrument the application process.
|
|
127
|
+
You simply need to include additional instrumentation libraries like
|
|
128
|
+
[opentelemetry-instrumentation-psycopg] and the instrument command will pick it
|
|
129
|
+
up and instrument the process.
|
|
130
|
+
|
|
131
|
+
[opentelemetry-instrumentation-psycopg]: https://pypi.org/project/opentelemetry-instrumentation-psycopg/
|
|
132
|
+
|
|
133
|
+
### Logging
|
|
134
|
+
|
|
135
|
+
The default logging configuration is based on the [Common Logging Format](https://en.wikipedia.org/wiki/Common_Log_Format).
|
|
136
|
+
The `fastapi_runner.middleware.AccessLogMiddleware` uses the logger named
|
|
137
|
+
`api-runner.access` to log access records. I decided to push the field configuration
|
|
138
|
+
into the logging configuration (see packaging/log-config.json) though I may change
|
|
139
|
+
that in the future depending on ergonomics. The current implemention creates a log
|
|
140
|
+
record with he following custom fields that are accessed using `%(name)s` in the
|
|
141
|
+
format record.
|
|
142
|
+
|
|
143
|
+
| Field | Description |
|
|
144
|
+
| ------------------ | ------------------------------------------------- |
|
|
145
|
+
| `%(bytes_sent)s` | Number of bytes sent in the response |
|
|
146
|
+
| `%(client)s` | Formatted client address |
|
|
147
|
+
| `%(duration)s` | Formatted duration of the request in milliseconds |
|
|
148
|
+
| `%(http_version)s` | HTTP version |
|
|
149
|
+
| `%(method)s` | HTTP request method |
|
|
150
|
+
| `%(path)s` | Request path |
|
|
151
|
+
| `%(status_code)s` | Numeric status code or `-` |
|
|
152
|
+
| `%(user_agent)s` | User agent string or `-` |
|
|
153
|
+
|
|
154
|
+
The formatted lines as configured by _packaging/log-config.json_ look like:
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
2026-10-08 11:44:26,328 WARNING api-runner.access: 192.168.215.1:52540 - "GET /missing HTTP/1.1" 404 22
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
I moved the date out of the log message so that it fits into the general log format.
|
|
161
|
+
You can customize the message format to fit your needs by adjusting the formatter
|
|
162
|
+
attached to the `api-runner.access` logger.
|
|
163
|
+
|
|
164
|
+
The `FASTAPI_RUNNER_ACCESS_LOG` environment variable can be set to `no` to disable
|
|
165
|
+
access logging. There is currently no way to change the access logger name.
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
From the repository root, the shared justfile provides the normal development
|
|
170
|
+
commands:
|
|
171
|
+
|
|
172
|
+
```console
|
|
173
|
+
just test
|
|
174
|
+
just analyze
|
|
175
|
+
just format
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Run only this library's tests directly when iterating on the package:
|
|
179
|
+
|
|
180
|
+
```console
|
|
181
|
+
uv run --package fastapi-runner pytest libraries/fastapi-runner/tests -q
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Build its release artifacts from the repository root:
|
|
185
|
+
|
|
186
|
+
```console
|
|
187
|
+
uv build --package=fastapi-runner --sdist --wheel --clear
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The artifacts are placed in the repository's `dist/` directory. Publishing is
|
|
191
|
+
intentionally separate from the development recipes so each library can adopt
|
|
192
|
+
its own release and PyPI workflow.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fastapi-runner"
|
|
3
|
+
version = "0.0.0"
|
|
4
|
+
description = "Production-ready uvicorn runner for FastAPI applications"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "BSD-3-Clause"
|
|
8
|
+
authors = [{ name = "Dave Shawley", email = "daveshawley@gmail.com" }]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
11
|
+
"Framework :: FastAPI",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Natural Language :: English",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Programming Language :: Python :: 3.15",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["fastapi~=0.143.0", "pydantic~=2.13", "pydantic-settings~=2.14"]
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["hatchling~=1.32"]
|
|
27
|
+
build-backend = "hatchling.build"
|
|
@@ -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
|