Skip to content

Commit 342d27f

Browse files
patrick91tiangolo
andauthored
✨ Add support for passing apps as fastapi run --entrypoint some.importable_module:app_name (#199)
Co-authored-by: Sebastián Ramírez <[email protected]>
1 parent 2631cdd commit 342d27f

File tree

4 files changed

+151
-5
lines changed

4 files changed

+151
-5
lines changed

src/fastapi_cli/cli.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from rich.tree import Tree
88
from typing_extensions import Annotated
99

10-
from fastapi_cli.discover import get_import_data
10+
from fastapi_cli.discover import get_import_data, get_import_data_from_import_string
1111
from fastapi_cli.exceptions import FastAPICLIException
1212

1313
from . import __version__
@@ -95,6 +95,7 @@ def _run(
9595
root_path: str = "",
9696
command: str,
9797
app: Union[str, None] = None,
98+
entrypoint: Union[str, None] = None,
9899
proxy_headers: bool = False,
99100
forwarded_allow_ips: Union[str, None] = None,
100101
) -> None:
@@ -109,7 +110,10 @@ def _run(
109110
)
110111

111112
try:
112-
import_data = get_import_data(path=path, app_name=app)
113+
if entrypoint:
114+
import_data = get_import_data_from_import_string(entrypoint)
115+
else:
116+
import_data = get_import_data(path=path, app_name=app)
113117
except FastAPICLIException as e:
114118
toolkit.print_line()
115119
toolkit.print(f"[error]{e}")
@@ -124,10 +128,11 @@ def _run(
124128
toolkit.print(f"Importing from {module_data.extra_sys_path}")
125129
toolkit.print_line()
126130

127-
root_tree = _get_module_tree(module_data.module_paths)
131+
if module_data.module_paths:
132+
root_tree = _get_module_tree(module_data.module_paths)
128133

129-
toolkit.print(root_tree, tag="module")
130-
toolkit.print_line()
134+
toolkit.print(root_tree, tag="module")
135+
toolkit.print_line()
131136

132137
toolkit.print(
133138
"Importing the FastAPI app object from the module with the following code:",
@@ -222,6 +227,14 @@ def dev(
222227
help="The name of the variable that contains the [bold]FastAPI[/bold] app in the imported module or package. If not provided, it is detected automatically."
223228
),
224229
] = None,
230+
entrypoint: Annotated[
231+
Union[str, None],
232+
typer.Option(
233+
"--entrypoint",
234+
"-e",
235+
help="The FastAPI app import string in the format 'some.importable_module:app_name'.",
236+
),
237+
] = None,
225238
proxy_headers: Annotated[
226239
bool,
227240
typer.Option(
@@ -267,6 +280,7 @@ def dev(
267280
reload=reload,
268281
root_path=root_path,
269282
app=app,
283+
entrypoint=entrypoint,
270284
command="dev",
271285
proxy_headers=proxy_headers,
272286
forwarded_allow_ips=forwarded_allow_ips,
@@ -318,6 +332,14 @@ def run(
318332
help="The name of the variable that contains the [bold]FastAPI[/bold] app in the imported module or package. If not provided, it is detected automatically."
319333
),
320334
] = None,
335+
entrypoint: Annotated[
336+
Union[str, None],
337+
typer.Option(
338+
"--entrypoint",
339+
"-e",
340+
help="The FastAPI app import string in the format 'some.importable_module:app_name'.",
341+
),
342+
] = None,
321343
proxy_headers: Annotated[
322344
bool,
323345
typer.Option(
@@ -364,6 +386,7 @@ def run(
364386
workers=workers,
365387
root_path=root_path,
366388
app=app,
389+
entrypoint=entrypoint,
367390
command="run",
368391
proxy_headers=proxy_headers,
369392
forwarded_allow_ips=forwarded_allow_ips,

src/fastapi_cli/discover.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,26 @@ def get_import_data(
130130
return ImportData(
131131
app_name=use_app_name, module_data=mod_data, import_string=import_string
132132
)
133+
134+
135+
def get_import_data_from_import_string(import_string: str) -> ImportData:
136+
module_str, _, app_name = import_string.partition(":")
137+
138+
if not module_str or not app_name:
139+
raise FastAPICLIException(
140+
"Import string must be in the format module.submodule:app_name"
141+
)
142+
143+
here = Path(".").resolve()
144+
145+
sys.path.insert(0, str(here))
146+
147+
return ImportData(
148+
app_name=app_name,
149+
module_data=ModuleData(
150+
module_import_str=module_str,
151+
extra_sys_path=here,
152+
module_paths=[],
153+
),
154+
import_string=import_string,
155+
)

tests/test_cli.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,48 @@ def test_version() -> None:
285285
assert "FastAPI CLI version:" in result.output
286286

287287

288+
def test_dev_with_import_string() -> None:
289+
with changing_dir(assets_path):
290+
with patch.object(uvicorn, "run") as mock_run:
291+
result = runner.invoke(app, ["dev", "--entrypoint", "single_file_app:api"])
292+
assert result.exit_code == 0, result.output
293+
assert mock_run.called
294+
assert mock_run.call_args
295+
assert mock_run.call_args.kwargs == {
296+
"app": "single_file_app:api",
297+
"forwarded_allow_ips": None,
298+
"host": "127.0.0.1",
299+
"port": 8000,
300+
"reload": True,
301+
"workers": None,
302+
"root_path": "",
303+
"proxy_headers": True,
304+
"log_config": get_uvicorn_log_config(),
305+
}
306+
assert "Using import string: single_file_app:api" in result.output
307+
308+
309+
def test_run_with_import_string() -> None:
310+
with changing_dir(assets_path):
311+
with patch.object(uvicorn, "run") as mock_run:
312+
result = runner.invoke(app, ["run", "--entrypoint", "single_file_app:app"])
313+
assert result.exit_code == 0, result.output
314+
assert mock_run.called
315+
assert mock_run.call_args
316+
assert mock_run.call_args.kwargs == {
317+
"app": "single_file_app:app",
318+
"forwarded_allow_ips": None,
319+
"host": "0.0.0.0",
320+
"port": 8000,
321+
"reload": False,
322+
"workers": None,
323+
"root_path": "",
324+
"proxy_headers": True,
325+
"log_config": get_uvicorn_log_config(),
326+
}
327+
assert "Using import string: single_file_app:app" in result.output
328+
329+
288330
def test_script() -> None:
289331
result = subprocess.run(
290332
[sys.executable, "-m", "coverage", "run", "-m", "fastapi_cli", "--help"],

tests/test_discover.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
from fastapi_cli.discover import (
6+
ImportData,
7+
get_import_data_from_import_string,
8+
)
9+
from fastapi_cli.exceptions import FastAPICLIException
10+
11+
assets_path = Path(__file__).parent / "assets"
12+
13+
14+
def test_get_import_data_from_import_string_valid() -> None:
15+
result = get_import_data_from_import_string("module.submodule:app")
16+
17+
assert isinstance(result, ImportData)
18+
assert result.app_name == "app"
19+
assert result.import_string == "module.submodule:app"
20+
assert result.module_data.module_import_str == "module.submodule"
21+
assert result.module_data.extra_sys_path == Path(".").resolve()
22+
assert result.module_data.module_paths == []
23+
24+
25+
def test_get_import_data_from_import_string_missing_colon() -> None:
26+
with pytest.raises(FastAPICLIException) as exc_info:
27+
get_import_data_from_import_string("module.submodule")
28+
29+
assert "Import string must be in the format module.submodule:app_name" in str(
30+
exc_info.value
31+
)
32+
33+
34+
def test_get_import_data_from_import_string_missing_app() -> None:
35+
with pytest.raises(FastAPICLIException) as exc_info:
36+
get_import_data_from_import_string("module.submodule:")
37+
38+
assert "Import string must be in the format module.submodule:app_name" in str(
39+
exc_info.value
40+
)
41+
42+
43+
def test_get_import_data_from_import_string_missing_module() -> None:
44+
with pytest.raises(FastAPICLIException) as exc_info:
45+
get_import_data_from_import_string(":app")
46+
47+
assert "Import string must be in the format module.submodule:app_name" in str(
48+
exc_info.value
49+
)
50+
51+
52+
def test_get_import_data_from_import_string_empty() -> None:
53+
with pytest.raises(FastAPICLIException) as exc_info:
54+
get_import_data_from_import_string("")
55+
56+
assert "Import string must be in the format module.submodule:app_name" in str(
57+
exc_info.value
58+
)

0 commit comments

Comments
 (0)