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.
@@ -0,0 +1,7 @@
1
+ .*
2
+ /build
3
+ /dist
4
+ !/.github
5
+ !/.gitignore
6
+ !/.pre-commit-config.yaml
7
+ !/.python-version
@@ -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,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