Skip to content

Commit e051e63

Browse files
committed
✨ Add support for passing apps as module:app
1 parent 77e6d1f commit e051e63

File tree

4 files changed

+149
-5
lines changed

4 files changed

+149
-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
) -> None:
100101
with get_rich_toolkit() as toolkit:
@@ -108,7 +109,10 @@ def _run(
108109
)
109110

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

126-
root_tree = _get_module_tree(module_data.module_paths)
130+
if module_data.module_paths:
131+
root_tree = _get_module_tree(module_data.module_paths)
127132

128-
toolkit.print(root_tree, tag="module")
129-
toolkit.print_line()
133+
toolkit.print(root_tree, tag="module")
134+
toolkit.print_line()
130135

131136
toolkit.print(
132137
"Importing the FastAPI app object from the module with the following code:",
@@ -220,6 +225,14 @@ def dev(
220225
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."
221226
),
222227
] = None,
228+
entrypoint: Annotated[
229+
Union[str, None],
230+
typer.Option(
231+
"--entrypoint",
232+
"-e",
233+
help="The FastAPI app import string in the format 'module:app_name'.",
234+
),
235+
] = None,
223236
proxy_headers: Annotated[
224237
bool,
225238
typer.Option(
@@ -259,6 +272,7 @@ def dev(
259272
reload=reload,
260273
root_path=root_path,
261274
app=app,
275+
entrypoint=entrypoint,
262276
command="dev",
263277
proxy_headers=proxy_headers,
264278
)
@@ -309,6 +323,14 @@ def run(
309323
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."
310324
),
311325
] = None,
326+
entrypoint: Annotated[
327+
Union[str, None],
328+
typer.Option(
329+
"--entrypoint",
330+
"-e",
331+
help="The FastAPI app import string in the format 'module:app_name'.",
332+
),
333+
] = None,
312334
proxy_headers: Annotated[
313335
bool,
314336
typer.Option(
@@ -349,6 +371,7 @@ def run(
349371
workers=workers,
350372
root_path=root_path,
351373
app=app,
374+
entrypoint=entrypoint,
352375
command="run",
353376
proxy_headers=proxy_headers,
354377
)

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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,46 @@ def test_version() -> None:
254254
assert "FastAPI CLI version:" in result.output
255255

256256

257+
def test_dev_with_import_string() -> None:
258+
with changing_dir(assets_path):
259+
with patch.object(uvicorn, "run") as mock_run:
260+
result = runner.invoke(app, ["dev", "--entrypoint", "single_file_app:api"])
261+
assert result.exit_code == 0, result.output
262+
assert mock_run.called
263+
assert mock_run.call_args
264+
assert mock_run.call_args.kwargs == {
265+
"app": "single_file_app:api",
266+
"host": "127.0.0.1",
267+
"port": 8000,
268+
"reload": True,
269+
"workers": None,
270+
"root_path": "",
271+
"proxy_headers": True,
272+
"log_config": get_uvicorn_log_config(),
273+
}
274+
assert "Using import string: single_file_app:api" in result.output
275+
276+
277+
def test_run_with_import_string() -> None:
278+
with changing_dir(assets_path):
279+
with patch.object(uvicorn, "run") as mock_run:
280+
result = runner.invoke(app, ["run", "--entrypoint", "single_file_app:app"])
281+
assert result.exit_code == 0, result.output
282+
assert mock_run.called
283+
assert mock_run.call_args
284+
assert mock_run.call_args.kwargs == {
285+
"app": "single_file_app:app",
286+
"host": "0.0.0.0",
287+
"port": 8000,
288+
"reload": False,
289+
"workers": None,
290+
"root_path": "",
291+
"proxy_headers": True,
292+
"log_config": get_uvicorn_log_config(),
293+
}
294+
assert "Using import string: single_file_app:app" in result.output
295+
296+
257297
def test_script() -> None:
258298
result = subprocess.run(
259299
[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)