Skip to content

Commit b68acd4

Browse files
committed
Refactor to reuse code and increase test coverage
1 parent 3904b87 commit b68acd4

File tree

3 files changed

+75
-51
lines changed

3 files changed

+75
-51
lines changed

src/fastapi_cli/discover.py

Lines changed: 19 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from dataclasses import dataclass
55
from logging import getLogger
66
from pathlib import Path
7-
from typing import Iterator, Union
7+
from typing import Iterator, Tuple, Union
88

99
from rich import print
1010
from rich.padding import Padding
@@ -149,9 +149,9 @@ def get_app_name(*, mod_data: ModuleData, app_name: Union[str, None] = None) ->
149149
raise FastAPICLIException("Could not find FastAPI app in module, try using --app")
150150

151151

152-
def get_import_string(
152+
def get_import_string_parts(
153153
*, path: Union[Path, None] = None, app_name: Union[str, None] = None
154-
) -> str:
154+
) -> Tuple[ModuleData, str]:
155155
if not path:
156156
path = get_default_path()
157157
logger.info(f"Using path [blue]{path}[/blue]")
@@ -161,6 +161,15 @@ def get_import_string(
161161
mod_data = get_module_data_from_path(path)
162162
sys.path.insert(0, str(mod_data.extra_sys_path))
163163
use_app_name = get_app_name(mod_data=mod_data, app_name=app_name)
164+
165+
return mod_data, use_app_name
166+
167+
168+
def get_import_string(
169+
*, path: Union[Path, None] = None, app_name: Union[str, None] = None
170+
) -> str:
171+
mod_data, use_app_name = get_import_string_parts(path=path, app_name=app_name)
172+
import_string = f"{mod_data.module_import_str}:{use_app_name}"
164173
import_example = Syntax(
165174
f"from {mod_data.module_import_str} import {use_app_name}", "python"
166175
)
@@ -175,57 +184,17 @@ def get_import_string(
175184
)
176185
logger.info("Found importable FastAPI app")
177186
print(import_panel)
178-
import_string = f"{mod_data.module_import_str}:{use_app_name}"
187+
179188
logger.info(f"Using import string [b green]{import_string}[/b green]")
180189
return import_string
181190

182191

183192
def get_app(
184193
*, path: Union[Path, None] = None, app_name: Union[str, None] = None
185194
) -> FastAPI:
186-
if not path:
187-
path = get_default_path()
188-
logger.debug(f"Using path [blue]{path}[/blue]")
189-
logger.debug(f"Resolved absolute path {path.resolve()}")
190-
if not path.exists():
191-
raise FastAPICLIException(f"Path does not exist {path}")
192-
mod_data = get_module_data_from_path(path)
193-
try:
194-
with mod_data.sys_path():
195-
mod = importlib.import_module(mod_data.module_import_str)
196-
except (ImportError, ValueError) as e:
197-
logger.error(f"Import error: {e}")
198-
logger.warning(
199-
"Ensure all the package directories have an [blue]__init__.py["
200-
"/blue] file"
201-
)
202-
raise
203-
if not FastAPI: # type: ignore[truthy-function]
204-
raise FastAPICLIException(
205-
"Could not import FastAPI, try running 'pip install fastapi'"
206-
) from None
207-
object_names = dir(mod)
208-
object_names_set = set(object_names)
209-
if app_name:
210-
if app_name not in object_names_set:
211-
raise FastAPICLIException(
212-
f"Could not find app name {app_name} in "
213-
f"{mod_data.module_import_str}"
214-
)
215-
app = getattr(mod, app_name)
216-
if not isinstance(app, FastAPI):
217-
raise FastAPICLIException(
218-
f"The app name {app_name} in {mod_data.module_import_str} "
219-
f"doesn't seem to be a FastAPI app"
220-
)
221-
return app
222-
for preferred_name in ["app", "api"]:
223-
if preferred_name in object_names_set:
224-
obj = getattr(mod, preferred_name)
225-
if isinstance(obj, FastAPI):
226-
return obj
227-
for name in object_names:
228-
obj = getattr(mod, name)
229-
if isinstance(obj, FastAPI):
230-
return obj
231-
raise FastAPICLIException("Could not find FastAPI app in module, try using --app")
195+
mod_data, use_app_name = get_import_string_parts(path=path, app_name=app_name)
196+
with mod_data.sys_path():
197+
mod = importlib.import_module(mod_data.module_import_str)
198+
app = getattr(mod, use_app_name)
199+
## get_import_string_parts guarantees app is FastAPI object
200+
return app # type: ignore[no-any-return]

tests/test_cli.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import os
12
import subprocess
23
import sys
34
from pathlib import Path
45
from unittest.mock import patch
56

67
import uvicorn
78
from fastapi_cli.cli import app
9+
from fastapi_cli.exceptions import FastAPICLIException
810
from typer.testing import CliRunner
911

1012
from tests.utils import changing_dir
@@ -14,6 +16,13 @@
1416
assets_path = Path(__file__).parent / "assets"
1517

1618

19+
def read_file(filename: str, strip: bool = True) -> str:
20+
"""Read file and return content as string"""
21+
with open("openapi.json") as stream:
22+
data = stream.read()
23+
return data.strip() if data and strip else data
24+
25+
1726
def test_dev() -> None:
1827
with changing_dir(assets_path):
1928
with patch.object(uvicorn, "run") as mock_run:
@@ -190,6 +199,41 @@ def test_schema() -> None:
190199
assert expected in result.output, result.output
191200

192201

202+
def test_schema_file() -> None:
203+
with changing_dir(assets_path):
204+
filename = "unit-test.json"
205+
expected = read_file("openapi.json", strip=True)
206+
assert expected != "", "Failed to read expected result"
207+
result = runner.invoke(
208+
app, ["schema", "single_file_app.py", "--output", filename]
209+
)
210+
assert os.path.isfile(filename)
211+
actual = read_file(filename, strip=True)
212+
os.remove(filename)
213+
assert result.exit_code == 0, result.output
214+
assert expected == actual
215+
216+
217+
def test_schema_invalid_path() -> None:
218+
with changing_dir(assets_path):
219+
result = runner.invoke(app, ["schema", "invalid/single_file_app.py"])
220+
assert result.exit_code == 1, result.output
221+
assert isinstance(result.exception, FastAPICLIException)
222+
assert "Path does not exist invalid/single_file_app.py" in str(result.exception)
223+
224+
225+
#
226+
#
227+
# def test_schema_invalid_package() -> None:
228+
# with changing_dir(assets_path):
229+
# result = runner.invoke(
230+
# app, ["schema", "broken_package/mod/app.py"]
231+
# )
232+
# assert result.exit_code == 1, result.output
233+
# assert isinstance(result.exception, ImportError)
234+
# assert "attempted relative import beyond top-level package" in str(result.exception)
235+
236+
193237
def test_run_help() -> None:
194238
result = runner.invoke(app, ["run", "--help"])
195239
assert result.exit_code == 0, result.output

tests/test_utils_package.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from pathlib import Path
22

33
import pytest
4-
from fastapi_cli.discover import get_import_string
4+
from fastapi_cli.discover import get_app, get_import_string
55
from fastapi_cli.exceptions import FastAPICLIException
66
from pytest import CaptureFixture
77

@@ -427,6 +427,17 @@ def test_broken_package_dir(capsys: CaptureFixture[str]) -> None:
427427
assert "Ensure all the package directories have an __init__.py file" in captured.out
428428

429429

430+
def test_get_app_broken_package_dir(capsys: CaptureFixture[str]) -> None:
431+
with changing_dir(assets_path):
432+
# TODO (when deprecating Python 3.8): remove ValueError
433+
with pytest.raises((ImportError, ValueError)):
434+
get_app(path=Path("broken_package/mod/app.py"))
435+
436+
captured = capsys.readouterr()
437+
assert "Import error:" in captured.out
438+
assert "Ensure all the package directories have an __init__.py file" in captured.out
439+
440+
430441
def test_package_dir_no_app() -> None:
431442
with changing_dir(assets_path):
432443
with pytest.raises(FastAPICLIException) as e:

0 commit comments

Comments
 (0)