fastapi-oneshot 0.1.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,3 @@
1
+ from fastapi_oneshot.runner import get_argv
2
+
3
+ __all__ = ["get_argv"]
fastapi_oneshot/cli.py ADDED
@@ -0,0 +1,50 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from fastapi_oneshot.runner import ScriptExit, ScriptLoadError, load_app, run
7
+
8
+
9
+ def split_at_double_dash(args: list[str]) -> tuple[list[str], list[str]]:
10
+ if "--" not in args:
11
+ return args, []
12
+ index = args.index("--")
13
+ return args[:index], args[index + 1 :]
14
+
15
+
16
+ def main() -> None:
17
+ cli_args, script_argv = split_at_double_dash(sys.argv[1:])
18
+ parser = argparse.ArgumentParser(
19
+ prog="fastapi-oneshot",
20
+ usage="%(prog)s SCRIPT --app MODULE:ATTR [-- SCRIPT_ARG ...]",
21
+ description="Run a script's main function with a FastAPI app's dependencies.",
22
+ )
23
+ parser.add_argument(
24
+ "script", type=Path, metavar="SCRIPT", help="script file that defines main"
25
+ )
26
+ parser.add_argument(
27
+ "--app",
28
+ required=True,
29
+ metavar="MODULE:ATTR",
30
+ help="import string of the FastAPI instance, e.g. myapp.main:app",
31
+ )
32
+ args = parser.parse_args(cli_args)
33
+
34
+ sys.path.insert(0, os.getcwd())
35
+ try:
36
+ app = load_app(args.app)
37
+ response = run(app, args.script, script_argv)
38
+ except ScriptLoadError as error:
39
+ parser.error(str(error))
40
+ except ScriptExit as error:
41
+ sys.exit(error.code)
42
+
43
+ if not response.is_success:
44
+ print(
45
+ f"fastapi-oneshot: request failed with status {response.status_code}",
46
+ file=sys.stderr,
47
+ )
48
+ if response.text:
49
+ print(response.text, file=sys.stderr)
50
+ sys.exit(1)
@@ -0,0 +1,153 @@
1
+ import asyncio
2
+ import importlib
3
+ import importlib.util
4
+ import inspect
5
+ import sys
6
+ from collections.abc import Callable, Mapping
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import httpx
11
+ from fastapi import FastAPI
12
+ from starlette.types import Receive, Scope, Send
13
+
14
+ ROUTE_PATH = "/__fastapi_oneshot__"
15
+ SCRIPT_MODULE_NAME = "__oneshot_script__"
16
+
17
+
18
+ class ScriptLoadError(Exception):
19
+ """The --app target or the script cannot be loaded; the CLI maps it to exit 2."""
20
+
21
+
22
+ class ScriptExit(Exception):
23
+ """SystemExit raised by main, carried through the app as an Exception."""
24
+
25
+ def __init__(self, code: str | int | None) -> None:
26
+ super().__init__(code)
27
+ self.code = code
28
+
29
+
30
+ def get_argv() -> list[str]:
31
+ raise RuntimeError(
32
+ "get_argv is provided by fastapi-oneshot; it is only available "
33
+ "inside a script run with the fastapi-oneshot command"
34
+ )
35
+
36
+
37
+ def load_app(app_spec: str) -> FastAPI:
38
+ module_name, _, attr = app_spec.rpartition(":")
39
+ if not module_name or not attr:
40
+ raise ScriptLoadError("--app must be in MODULE:ATTR form")
41
+
42
+ try:
43
+ module = importlib.import_module(module_name)
44
+ except ModuleNotFoundError as error:
45
+ if error.name is not None and (
46
+ module_name == error.name or module_name.startswith(error.name + ".")
47
+ ):
48
+ raise ScriptLoadError(f"cannot import module {module_name!r}") from error
49
+ raise
50
+
51
+ if not hasattr(module, attr):
52
+ raise ScriptLoadError(f"module {module_name!r} has no attribute {attr!r}")
53
+ app = getattr(module, attr)
54
+ if not isinstance(app, FastAPI):
55
+ raise ScriptLoadError("--app must point to a FastAPI instance")
56
+ return app
57
+
58
+
59
+ def load_main(script_path: Path) -> Callable[..., Any]:
60
+ if not script_path.is_file():
61
+ raise ScriptLoadError(f"script {script_path} is not a file")
62
+ spec = importlib.util.spec_from_file_location(SCRIPT_MODULE_NAME, script_path)
63
+ if spec is None or spec.loader is None:
64
+ raise ScriptLoadError("script must be a .py file")
65
+
66
+ sys.path.insert(0, str(script_path.resolve().parent))
67
+ module = importlib.util.module_from_spec(spec)
68
+ sys.modules[SCRIPT_MODULE_NAME] = module
69
+ spec.loader.exec_module(module)
70
+
71
+ if not hasattr(module, "main"):
72
+ raise ScriptLoadError(f"script {script_path} defines no main function")
73
+ main = module.main
74
+ if not inspect.isfunction(main):
75
+ raise ScriptLoadError("main must be a function defined with def or async def")
76
+ if inspect.isgeneratorfunction(main) or inspect.isasyncgenfunction(main):
77
+ raise ScriptLoadError("main must not be a generator function")
78
+ return main
79
+
80
+
81
+ def build_endpoint(main: Callable[..., Any]) -> Callable[..., Any]:
82
+ signature = inspect.signature(main, eval_str=True)
83
+ endpoint: Callable[..., Any]
84
+
85
+ if inspect.iscoroutinefunction(main):
86
+
87
+ async def call_async(**values: Any) -> None:
88
+ try:
89
+ await main(**values)
90
+ except SystemExit as error:
91
+ raise ScriptExit(error.code) from error
92
+
93
+ endpoint = call_async
94
+
95
+ else:
96
+
97
+ def call_sync(**values: Any) -> None:
98
+ try:
99
+ result = main(**values)
100
+ except SystemExit as error:
101
+ raise ScriptExit(error.code) from error
102
+ if inspect.isawaitable(result):
103
+ raise TypeError(
104
+ "main returned an awaitable; define main with async def"
105
+ )
106
+
107
+ endpoint = call_sync
108
+
109
+ setattr(endpoint, "__signature__", signature.replace(return_annotation=None)) # noqa: B010
110
+ return endpoint
111
+
112
+
113
+ def run(app: FastAPI, script_path: Path, argv: list[str]) -> httpx.Response:
114
+ main = load_main(script_path)
115
+
116
+ def provide_argv() -> list[str]:
117
+ return argv
118
+
119
+ app.dependency_overrides[get_argv] = provide_argv
120
+ app.add_api_route(
121
+ ROUTE_PATH,
122
+ build_endpoint(main),
123
+ methods=["POST"],
124
+ include_in_schema=False,
125
+ response_model=None,
126
+ )
127
+ app.router.routes.insert(0, app.router.routes.pop())
128
+ return asyncio.run(execute(app))
129
+
130
+
131
+ async def execute(app: FastAPI) -> httpx.Response:
132
+ failure: Exception | None = None
133
+ async with app.router.lifespan_context(app) as lifespan_state:
134
+ try:
135
+ return await post_once(app, lifespan_state)
136
+ except Exception as error:
137
+ failure = error
138
+ assert failure is not None
139
+ raise failure
140
+
141
+
142
+ async def post_once(
143
+ app: FastAPI, lifespan_state: Mapping[str, Any] | None
144
+ ) -> httpx.Response:
145
+ async def app_with_state(scope: Scope, receive: Receive, send: Send) -> None:
146
+ scope["state"] = dict(lifespan_state or {})
147
+ await app(scope, receive, send)
148
+
149
+ transport = httpx.ASGITransport(app=app_with_state)
150
+ async with httpx.AsyncClient(
151
+ transport=transport, base_url="http://localhost"
152
+ ) as client:
153
+ return await client.post(ROUTE_PATH)
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.5
2
+ Name: fastapi-oneshot
3
+ Version: 0.1.0
4
+ Summary: Run one-shot scripts with a FastAPI app's dependencies
5
+ Project-URL: Repository, https://github.com/himkt/fastapi-oneshot
6
+ Author: himkt
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: fastapi<1.0.0,>=0.115.0
11
+ Requires-Dist: httpx<1.0.0,>=0.23.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # fastapi-oneshot
15
+
16
+ Run a one-shot Python script against an existing FastAPI app, so that the script's `main` function receives the app's dependencies — DB sessions, settings, clients — through ordinary `Depends`.
17
+
18
+ `fastapi-oneshot` registers `main` as a temporary path operation, enters the app's lifespan, and sends a single in-process request to it through `httpx.ASGITransport`. Dependency resolution, `yield` cleanup, `dependency_overrides` and the lifespan all work exactly as they do for a request handler, and everything runs on one event loop in the main thread.
19
+
20
+ ## Installation
21
+
22
+ Install `fastapi-oneshot` into the same environment as your app, because it imports the app:
23
+
24
+ ```console
25
+ uv add --dev fastapi-oneshot
26
+ ```
27
+
28
+ or
29
+
30
+ ```console
31
+ pip install fastapi-oneshot
32
+ ```
33
+
34
+ Python 3.10 or later and FastAPI 0.115.0 or later (below 1.0) are required.
35
+
36
+ ## Usage
37
+
38
+ ```console
39
+ fastapi-oneshot SCRIPT --app MODULE:ATTR [-- SCRIPT_ARG ...]
40
+ ```
41
+
42
+ | Argument | Meaning |
43
+ |----------|---------|
44
+ | `SCRIPT` | Path to the script file that defines `main`. |
45
+ | `--app MODULE:ATTR` | Import string of the `FastAPI` instance, e.g. `myapp.main:app`. |
46
+ | `--` | Everything after the first `--` is passed to the script verbatim. |
47
+
48
+ Run the command from your project root: the current working directory is put on `sys.path` before `--app` is imported, as with `uvicorn` and `fastapi run`.
49
+
50
+ ## Writing a script
51
+
52
+ A script is a Python file that defines a module-level function named `main`, with `def` or `async def`. Every parameter of `main` is a dependency, declared with `Depends(...)` exactly as in a path operation:
53
+
54
+ ```python
55
+ # scripts/backfill.py
56
+ import argparse
57
+ import sqlite3
58
+ from typing import Annotated
59
+
60
+ from fastapi import Depends
61
+ from fastapi_oneshot import get_argv
62
+
63
+ from myapp.dependencies import get_db
64
+
65
+
66
+ def main(
67
+ db: Annotated[sqlite3.Connection, Depends(get_db)],
68
+ argv: Annotated[list[str], Depends(get_argv)],
69
+ ) -> None:
70
+ parser = argparse.ArgumentParser(prog="backfill")
71
+ parser.add_argument("--dry-run", action="store_true")
72
+ args = parser.parse_args(argv)
73
+ ...
74
+ ```
75
+
76
+ ```console
77
+ fastapi-oneshot scripts/backfill.py --app myapp.main:app -- --dry-run
78
+ ```
79
+
80
+ - The app's lifespan runs around `main`, so dependencies can read lifespan-initialized state through `request.app.state` and lifespan-yielded state through `request.state`.
81
+ - A sync `main` runs in FastAPI's threadpool; an async `main` runs on the main-thread event loop shared with the lifespan.
82
+ - The return value of `main` is discarded. Signal failure by raising an exception.
83
+ - The script's directory is put on `sys.path`, so the script can import sibling modules, as with `python script.py`.
84
+ - The script runs as a module named `__oneshot_script__`, so an `if __name__ == "__main__":` block does not run.
85
+
86
+ ### Script arguments: `get_argv`
87
+
88
+ `fastapi_oneshot.get_argv` is a dependency that returns the arguments after `--` as `list[str]` (`[]` when there is no `--`). Parse them with `argparse` or any other parser, as in the example above.
89
+
90
+ Script options always come from `get_argv`. A plain parameter of `main` that is not a dependency is not a CLI option: a plain parameter with a default (`verbose: bool = False`) always receives its default, and a required plain parameter fails the run with a 422 validation error.
91
+
92
+ Calling `get_argv` outside a `fastapi-oneshot` run, e.g. from a normal app route, raises `RuntimeError`.
93
+
94
+ ### Overriding dependencies
95
+
96
+ The request that runs `main` carries no headers, cookies, query or body. Override every dependency that reads them — such as "current user from a bearer token" — with `app.dependency_overrides` at module level in the script:
97
+
98
+ ```python
99
+ from myapp.auth import get_current_user
100
+ from myapp.main import app
101
+ from myapp.models import User
102
+
103
+ app.dependency_overrides[get_current_user] = lambda: User(id=0, name="oneshot")
104
+ ```
105
+
106
+ The same applies to app-level dependencies given to `FastAPI(dependencies=[...])`, such as a global authentication check: they run before `main`, so override them in the script.
107
+
108
+ Import the app through the same module path as `--app` (`from myapp.main import app` for `--app myapp.main:app`). Importing it through another path, such as `src.myapp.main`, creates a second app instance, and the override applies to that instance instead of the one that runs. Set overrides at module level: overrides set inside `main` are too late to affect `main`'s own parameters.
109
+
110
+ ## Exit codes
111
+
112
+ | Outcome | Exit code |
113
+ |---------|-----------|
114
+ | `main` returns and the response is 2xx | 0 |
115
+ | An exception is raised in `main`, a dependency, the lifespan, the app import or the script import (the traceback is printed) | 1 |
116
+ | The response is not 2xx — e.g. `HTTPException`, a 422 validation error, a rejecting app-level dependency or middleware | 1 |
117
+ | `main` raises `SystemExit` (e.g. `sys.exit(3)`, `argparse` errors) | the requested status |
118
+ | The script or `--app` cannot be loaded, or `main` is missing or not a plain function | 2 |
119
+ | Missing `SCRIPT` or `--app` | 2 |
120
+
121
+ A non-2xx response is reported on stderr with its status and body:
122
+
123
+ ```text
124
+ fastapi-oneshot: request failed with status 401
125
+ {"detail":"missing Authorization"}
126
+ ```
127
+
128
+ Once the lifespan has started, it is shut down normally before `fastapi-oneshot` exits, whatever the outcome.
129
+
130
+ ## Known limitations
131
+
132
+ - **Middleware runs.** Every app middleware processes the request, which is sent to `http://localhost/__fastapi_oneshot__`. A middleware that rejects it (e.g. authentication, `TrustedHostMiddleware` without `localhost` in `allowed_hosts`, `HTTPSRedirectMiddleware` answering 307 — redirects are not followed) results in a non-2xx exit.
133
+ - **App-level dependencies run.** Dependencies given to `FastAPI(dependencies=[...])` apply to the oneshot route, so a global authentication or rate-limit dependency runs before `main` and typically rejects the request (401/403 → exit 1). Override such a dependency in the script with `app.dependency_overrides`. Dependencies attached to an `APIRouter` do not apply, because the route is added to the app directly.
134
+ - **Request-derived dependencies.** The request carries no headers, cookies, query or body beyond httpx defaults; dependencies that read them must be overridden in the script.
135
+ - **Lifespan entered directly.** The lifespan runs through `app.router.lifespan_context(app)`, not through an ASGI `lifespan` scope, so ASGI middleware that intercepts the `lifespan` scope does not see startup and shutdown.
136
+ - **Ctrl-C.** `fastapi-oneshot` installs no signal handling of its own. On Python 3.11+, the first Ctrl-C cancels the run: `CancelledError` is raised at the current `await`, propagates through `yield`-dependency cleanup and into the lifespan, and the process ends with `KeyboardInterrupt`; a second Ctrl-C raises `KeyboardInterrupt` immediately. On Python 3.10, `KeyboardInterrupt` is raised wherever the main thread is, and the run is cancelled the same way. Lifespan shutdown code runs on this path only when it is in `try/finally`. A sync `main` running in a worker thread cannot be interrupted, and the process exits only after it returns. The exit status is Python's default for an uncaught `KeyboardInterrupt` (130 in POSIX shells). Make scripts that must stay consistent when interrupted rely on DB transactions.
137
+ - **`sys.exit()` in `main` is a failed request to the app.** `sys.exit(...)` — including `sys.exit(0)` and `argparse`'s `--help` — travels through the app as an exception: it is thrown into `yield` dependencies at their `yield` (post-`yield` success code such as `db.commit()` is skipped and `except Exception` rollback branches run) and reaches the app's `Exception` / 500 handler and error-reporting middleware as a server error. The process still exits with the requested status. End a successful run by returning from `main`; use `sys.exit` only for usage errors and failures. `SystemExit` raised by a dependency rather than by `main` is unsupported: lifespan shutdown is then best-effort, as on Ctrl-C.
138
+
139
+ ## License
140
+
141
+ MIT
@@ -0,0 +1,8 @@
1
+ fastapi_oneshot/__init__.py,sha256=PRE6spw-uWRO5jSXvqGfdEesNUqmjpeEb0urvS2cpGc,68
2
+ fastapi_oneshot/cli.py,sha256=h3CR5LoyyUwlhHKcC_OLdr0o8qNCVM-fDvj8J44Ds0M,1518
3
+ fastapi_oneshot/runner.py,sha256=F8Xs0YRwo9IQlbI82alIZK7BiReQ-tMusI82NaBf7Y4,4939
4
+ fastapi_oneshot-0.1.0.dist-info/METADATA,sha256=paVozoEPObwjGJY_j8g-qXJP9vWr5CYN3TrYwjuYqQg,8412
5
+ fastapi_oneshot-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ fastapi_oneshot-0.1.0.dist-info/entry_points.txt,sha256=eTPp_dbWWf4JmdlxMGLVi3JirOPR_srq7tYc0dq5_cg,61
7
+ fastapi_oneshot-0.1.0.dist-info/licenses/LICENSE,sha256=QRCxO0skUXxUfiJVgU81NO667j7pVUnmPI_Q5BDphwI,1062
8
+ fastapi_oneshot-0.1.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
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fastapi-oneshot = fastapi_oneshot.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 himkt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.