From ee2e7c3c4265875e2dd92cd60589e5d8b4cfc918 Mon Sep 17 00:00:00 2001 From: Flavien Date: Mon, 16 Dec 2024 20:11:50 +0100 Subject: [PATCH 1/4] feat: update discover functions to return fastapi app --- src/fastapi_cli/discover.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index 43d0e9c..536c145 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -45,12 +45,16 @@ class ModuleData: def get_module_data_from_path(path: Path) -> ModuleData: use_path = path.resolve() module_path = use_path + if use_path.is_file() and use_path.stem == "__init__": module_path = use_path.parent + module_paths = [module_path] extra_sys_path = module_path.parent + for parent in module_path.parents: init_path = parent / "__init__.py" + if init_path.is_file(): module_paths.insert(0, parent) extra_sys_path = parent.parent @@ -58,6 +62,7 @@ def get_module_data_from_path(path: Path) -> ModuleData: break module_str = ".".join(p.stem for p in module_paths) + return ModuleData( module_import_str=module_str, extra_sys_path=extra_sys_path.resolve(), @@ -65,7 +70,9 @@ def get_module_data_from_path(path: Path) -> ModuleData: ) -def get_app_name(*, mod_data: ModuleData, app_name: Union[str, None] = None) -> str: +def get_app_name( + *, mod_data: ModuleData, app_name: Union[str, None] = None +) -> tuple[str, FastAPI]: try: mod = importlib.import_module(mod_data.module_import_str) except (ImportError, ValueError) as e: @@ -74,32 +81,41 @@ def get_app_name(*, mod_data: ModuleData, app_name: Union[str, None] = None) -> "Ensure all the package directories have an [blue]__init__.py[/blue] file" ) raise + if not FastAPI: # type: ignore[truthy-function] raise FastAPICLIException( "Could not import FastAPI, try running 'pip install fastapi'" ) from None + object_names = dir(mod) object_names_set = set(object_names) + if app_name: if app_name not in object_names_set: raise FastAPICLIException( f"Could not find app name {app_name} in {mod_data.module_import_str}" ) + app = getattr(mod, app_name) + if not isinstance(app, FastAPI): raise FastAPICLIException( f"The app name {app_name} in {mod_data.module_import_str} doesn't seem to be a FastAPI app" ) - return app_name + + return app_name, app + for preferred_name in ["app", "api"]: if preferred_name in object_names_set: obj = getattr(mod, preferred_name) if isinstance(obj, FastAPI): - return preferred_name + return preferred_name, obj + for name in object_names: obj = getattr(mod, name) if isinstance(obj, FastAPI): - return name + return name, obj + raise FastAPICLIException("Could not find FastAPI app in module, try using --app") @@ -108,6 +124,7 @@ class ImportData: app_name: str module_data: ModuleData import_string: str + fastapi_app: FastAPI def get_import_data( @@ -121,12 +138,16 @@ def get_import_data( if not path.exists(): raise FastAPICLIException(f"Path does not exist {path}") + mod_data = get_module_data_from_path(path) sys.path.insert(0, str(mod_data.extra_sys_path)) - use_app_name = get_app_name(mod_data=mod_data, app_name=app_name) + use_app_name, app = get_app_name(mod_data=mod_data, app_name=app_name) import_string = f"{mod_data.module_import_str}:{use_app_name}" return ImportData( - app_name=use_app_name, module_data=mod_data, import_string=import_string + app_name=use_app_name, + module_data=mod_data, + import_string=import_string, + fastapi_app=app, ) From df3fd5d0437d99345a573982587a783ffd9768fc Mon Sep 17 00:00:00 2001 From: Flavien Date: Mon, 16 Dec 2024 20:12:42 +0100 Subject: [PATCH 2/4] feat: refactor conditional printing api docs urls --- src/fastapi_cli/cli.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index c983b5c..e209753 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -109,6 +109,7 @@ def _run( module_data = import_data.module_data import_string = import_data.import_string + fastapi_app = import_data.fastapi_app toolkit.print(f"Importing from {module_data.extra_sys_path}") toolkit.print_line() @@ -134,15 +135,32 @@ def _run( ) url = f"http://{host}:{port}" - url_docs = f"{url}/docs" + docs_url = "" + + if fastapi_app.openapi_url and (fastapi_app.docs_url or fastapi_app.redoc_url): + if fastapi_app.docs_url: + docs_url += ( + f"[link={url}{fastapi_app.docs_url}]{url}{fastapi_app.docs_url}[/]" + ) + + if fastapi_app.docs_url and fastapi_app.redoc_url: + docs_url += " or " + + if fastapi_app.redoc_url: + docs_url += f"[link={url}{fastapi_app.redoc_url}]{url}{fastapi_app.redoc_url}[/]" toolkit.print_line() toolkit.print( f"Server started at [link={url}]{url}[/]", - f"Documentation at [link={url_docs}]{url_docs}[/]", tag="server", ) + if docs_url: + toolkit.print( + f"Documentation at {docs_url}", + tag="server", + ) + if command == "dev": toolkit.print_line() toolkit.print( From 9f95bd6964a792010fd8d05701de58f216773d02 Mon Sep 17 00:00:00 2001 From: Flavien Date: Mon, 16 Dec 2024 20:13:25 +0100 Subject: [PATCH 3/4] tests: conditional printing api docs urls --- tests/assets/single_file_docs.py | 48 ++++++++++++++++++++ tests/test_cli.py | 78 ++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 tests/assets/single_file_docs.py diff --git a/tests/assets/single_file_docs.py b/tests/assets/single_file_docs.py new file mode 100644 index 0000000..d074804 --- /dev/null +++ b/tests/assets/single_file_docs.py @@ -0,0 +1,48 @@ +from fastapi import FastAPI + +no_openapi = FastAPI(openapi_url=None) + + +@no_openapi.get("/") +def no_openapi_root(): + return {"message": "single file no_openapi"} + + +none_docs = FastAPI(docs_url=None, redoc_url=None) + + +@none_docs.get("/") +def none_docs_root(): + return {"message": "single file none_docs"} + + +no_docs = FastAPI(docs_url=None) + + +@no_docs.get("/") +def no_docs_root(): + return {"message": "single file no_docs"} + + +no_redoc = FastAPI(redoc_url=None) + + +@no_redoc.get("/") +def no_redoc_root(): + return {"message": "single file no_redoc"} + + +full_docs = FastAPI() + + +@full_docs.get("/") +def full_docs_root(): + return {"message": "single file full_docs"} + + +custom_docs = FastAPI(docs_url="/custom-docs-url", redoc_url="/custom-redoc-url") + + +@custom_docs.get("/") +def custom_docs_root(): + return {"message": "single file custom_docs"} diff --git a/tests/test_cli.py b/tests/test_cli.py index 8bdba1c..1e1ae14 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -192,6 +192,84 @@ def test_run_args() -> None: ) +def test_no_openapi() -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, ["dev", "single_file_docs.py", "--app", "no_openapi"] + ) + assert result.exit_code == 0, result.output + assert mock_run.called + + assert "http://127.0.0.1:8000/docs" not in result.output + assert "http://127.0.0.1:8000/redoc" not in result.output + + +def test_none_docs() -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, ["dev", "single_file_docs.py", "--app", "none_docs"] + ) + assert result.exit_code == 0, result.output + assert mock_run.called + + assert "http://127.0.0.1:8000/docs" not in result.output + assert "http://127.0.0.1:8000/redoc" not in result.output + + +def test_no_docs() -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, ["dev", "single_file_docs.py", "--app", "no_docs"] + ) + assert result.exit_code == 0, result.output + assert mock_run.called + + assert "http://127.0.0.1:8000/redoc" in result.output + assert "http://127.0.0.1:8000/docs" not in result.output + + +def test_no_redoc() -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, ["dev", "single_file_docs.py", "--app", "no_redoc"] + ) + assert result.exit_code == 0, result.output + assert mock_run.called + + assert "http://127.0.0.1:8000/docs" in result.output + assert "http://127.0.0.1:8000/redocs" not in result.output + + +def test_full_docs() -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, ["dev", "single_file_docs.py", "--app", "full_docs"] + ) + assert result.exit_code == 0, result.output + assert mock_run.called + + assert "http://127.0.0.1:8000/docs" in result.output + assert "http://127.0.0.1:8000/redoc" in result.output + + +def test_custom_docs() -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, ["dev", "single_file_docs.py", "--app", "custom_docs"] + ) + assert result.exit_code == 0, result.output + assert mock_run.called + + assert "http://127.0.0.1:8000/custom-docs-url" in result.output + assert "http://127.0.0.1:8000/custom-redoc-url" in result.output + + def test_run_error() -> None: with changing_dir(assets_path): result = runner.invoke(app, ["run", "non_existing_file.py"]) From 1322b6b9e596539274e505e453db24a7fe6e40d1 Mon Sep 17 00:00:00 2001 From: Flavien Date: Mon, 16 Dec 2024 20:18:07 +0100 Subject: [PATCH 4/4] fix: python 3.8 mypy error --- src/fastapi_cli/discover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index 536c145..cb94434 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from logging import getLogger from pathlib import Path -from typing import List, Union +from typing import List, Tuple, Union from fastapi_cli.exceptions import FastAPICLIException @@ -72,7 +72,7 @@ def get_module_data_from_path(path: Path) -> ModuleData: def get_app_name( *, mod_data: ModuleData, app_name: Union[str, None] = None -) -> tuple[str, FastAPI]: +) -> Tuple[str, FastAPI]: try: mod = importlib.import_module(mod_data.module_import_str) except (ImportError, ValueError) as e: