From 1280603748e7ae81e4217e7f5b5d29fc3bda5b27 Mon Sep 17 00:00:00 2001 From: Flavien Date: Mon, 16 Dec 2024 20:11:50 +0100 Subject: [PATCH 1/9] feat: update discover functions to return fastapi app --- src/fastapi_cli/discover.py | 60 +++++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index b174f8fb..d35be360 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 @@ -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_infos( + *, mod_data: ModuleData, app_name: Union[str, None] = None +) -> Tuple[str, str | None, str | None, str | None]: 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.openapi_url, app.docs_url, app.redoc_url + 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.openapi_url, obj.docs_url, obj.redoc_url + for name in object_names: obj = getattr(mod, name) if isinstance(obj, FastAPI): - return name + return name, obj.openapi_url, obj.docs_url, obj.redoc_url + raise FastAPICLIException("Could not find FastAPI app in module, try using --app") @@ -108,6 +124,9 @@ class ImportData: app_name: str module_data: ModuleData import_string: str + openapi_url: str | None = None + docs_url: str | None = None + redoc_url: str | None = None def get_import_data( @@ -121,14 +140,22 @@ 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, openapi_url, docs_url, redoc_url = get_app_infos( + 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, + openapi_url=openapi_url, + docs_url=docs_url, + redoc_url=redoc_url, ) @@ -144,12 +171,21 @@ def get_import_data_from_import_string(import_string: str) -> ImportData: sys.path.insert(0, str(here)) + module_data = ModuleData( + module_import_str=module_str, + extra_sys_path=here, + module_paths=[], + ) + + _, openapi_url, docs_url, redoc_url = get_app_infos( + mod_data=module_data, app_name=app_name + ) + return ImportData( app_name=app_name, - module_data=ModuleData( - module_import_str=module_str, - extra_sys_path=here, - module_paths=[], - ), + module_data=module_data, import_string=import_string, + openapi_url=openapi_url, + docs_url=docs_url, + redoc_url=redoc_url, ) From a23816f425cd4fe6d3019d111535aadf79a3fc4a Mon Sep 17 00:00:00 2001 From: Flavien Date: Mon, 16 Dec 2024 20:12:42 +0100 Subject: [PATCH 2/9] 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 28afa297..73a94f61 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -126,6 +126,9 @@ def _run( module_data = import_data.module_data import_string = import_data.import_string + openapi_url = import_data.openapi_url + docs_url = import_data.docs_url + redoc_url = import_data.redoc_url toolkit.print(f"Importing from {module_data.extra_sys_path}") toolkit.print_line() @@ -152,15 +155,30 @@ def _run( ) url = f"http://{host}:{port}" - url_docs = f"{url}/docs" + docs_str = "" + + if openapi_url and (docs_url or redoc_url): + if docs_url: + docs_str += f"[link={url}{docs_url}]{url}{docs_url}[/]" + + if docs_url and redoc_url: + docs_str += " or " + + if redoc_url: + docs_str += f"[link={url}{redoc_url}]{url}{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_str: + toolkit.print( + f"Documentation at {docs_str}", + tag="server", + ) + if command == "dev": toolkit.print_line() toolkit.print( From 5e6e42872cd90e562de93178fbd129cc1a833d3f Mon Sep 17 00:00:00 2001 From: Flavien Date: Mon, 16 Dec 2024 20:13:25 +0100 Subject: [PATCH 3/9] tests: conditional printing api docs urls --- tests/assets/single_file_docs.py | 48 ++++++++++++++++++++ tests/test_cli.py | 78 ++++++++++++++++++++++++++++++++ tests/test_discover.py | 10 ++-- 3 files changed, 132 insertions(+), 4 deletions(-) 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 00000000..d074804a --- /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 b87a811a..c457c602 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -349,6 +349,84 @@ def test_run_env_vars_and_args() -> None: assert "Documentation at http://0.0.0.0:8080/docs" in result.output +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"]) diff --git a/tests/test_discover.py b/tests/test_discover.py index b1052050..99b5a4fe 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -7,18 +7,20 @@ get_import_data_from_import_string, ) from fastapi_cli.exceptions import FastAPICLIException +from tests.utils import changing_dir assets_path = Path(__file__).parent / "assets" def test_get_import_data_from_import_string_valid() -> None: - result = get_import_data_from_import_string("module.submodule:app") + with changing_dir(assets_path): + result = get_import_data_from_import_string("package.mod.app:app") assert isinstance(result, ImportData) assert result.app_name == "app" - assert result.import_string == "module.submodule:app" - assert result.module_data.module_import_str == "module.submodule" - assert result.module_data.extra_sys_path == Path(".").resolve() + assert result.import_string == "package.mod.app:app" + assert result.module_data.module_import_str == "package.mod.app" + assert result.module_data.extra_sys_path == Path(assets_path).resolve() assert result.module_data.module_paths == [] From dda7dc65b7bfaf391ba755214c9b0251bf692e4c Mon Sep 17 00:00:00 2001 From: Flavien Date: Wed, 5 Nov 2025 17:33:38 +0100 Subject: [PATCH 4/9] fix: python 3.8 and 3.9 mypy error --- src/fastapi_cli/discover.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index d35be360..bb7d2f2b 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -72,7 +72,7 @@ def get_module_data_from_path(path: Path) -> ModuleData: def get_app_infos( *, mod_data: ModuleData, app_name: Union[str, None] = None -) -> Tuple[str, str | None, str | None, str | None]: +) -> Tuple[str, Union[str, None], Union[str, None], Union[str, None]]: try: mod = importlib.import_module(mod_data.module_import_str) except (ImportError, ValueError) as e: @@ -124,9 +124,9 @@ class ImportData: app_name: str module_data: ModuleData import_string: str - openapi_url: str | None = None - docs_url: str | None = None - redoc_url: str | None = None + openapi_url: Union[str, None] = None + docs_url: Union[str, None] = None + redoc_url: Union[str, None] = None def get_import_data( From 03ea860fbc361036cf5272adf9ea14a2d53087b1 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Tue, 28 Apr 2026 07:29:25 +0200 Subject: [PATCH 5/9] Split docs_url discovery from import_data discovery --- src/fastapi_cli/cli.py | 35 ++++++++------- src/fastapi_cli/discover.py | 76 ++++++++++++++------------------ tests/assets/single_file_docs.py | 10 ++++- tests/test_cli.py | 13 +++++- tests/test_discover.py | 10 ++--- tests/utils.py | 3 +- 6 files changed, 79 insertions(+), 68 deletions(-) diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index 1a466bf6..675d933e 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -8,7 +8,11 @@ from rich.tree import Tree from fastapi_cli.config import FastAPIConfig -from fastapi_cli.discover import get_import_data, get_import_data_from_import_string +from fastapi_cli.discover import ( + get_docs_urls, + get_import_data, + get_import_data_from_import_string, +) from fastapi_cli.exceptions import FastAPICLIException from . import __version__ @@ -164,9 +168,6 @@ def _run( module_data = import_data.module_data import_string = import_data.import_string - openapi_url = import_data.openapi_url - docs_url = import_data.docs_url - redoc_url = import_data.redoc_url toolkit.print(f"Importing from {module_data.extra_sys_path}") toolkit.print_line() @@ -192,18 +193,20 @@ def _run( tag="app", ) - url = f"http://{host}:{port}" - docs_str = "" - - if openapi_url and (docs_url or redoc_url): - if docs_url: - docs_str += f"[link={url}{docs_url}]{url}{docs_url}[/]" + docs_urls = get_docs_urls(import_data) - if docs_url and redoc_url: - docs_str += " or " + url = f"http://{host}:{port}" + docs_url = "" - if redoc_url: - docs_str += f"[link={url}{redoc_url}]{url}{redoc_url}[/]" + if docs_urls.openapi_url and (docs_urls.docs_url or docs_urls.redoc_url): + if docs_urls.docs_url: + docs_url = ( + f"[link={url}{docs_urls.docs_url}]{url}{docs_urls.docs_url}[/]" + ) + else: + docs_url = ( + f"[link={url}{docs_urls.redoc_url}]{url}{docs_urls.redoc_url}[/]" + ) toolkit.print_line() toolkit.print( @@ -211,9 +214,9 @@ def _run( tag="server", ) - if docs_str: + if docs_url: toolkit.print( - f"Documentation at {docs_str}", + f"Documentation at {docs_url}", tag="server", ) diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index d423bbb7..a938b720 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -69,9 +69,7 @@ def get_module_data_from_path(path: Path) -> ModuleData: ) -def get_app_infos( - *, mod_data: ModuleData, app_name: str | None = None -) -> tuple[str, str | None, str | None, str | None]: +def get_app_name(*, mod_data: ModuleData, app_name: str | None = None) -> str: try: mod = importlib.import_module(mod_data.module_import_str) except (ImportError, ValueError) as e: @@ -80,41 +78,32 @@ def get_app_infos( "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, app.openapi_url, app.docs_url, app.redoc_url - + return app_name 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, obj.openapi_url, obj.docs_url, obj.redoc_url - + return preferred_name for name in object_names: obj = getattr(mod, name) if isinstance(obj, FastAPI): - return name, obj.openapi_url, obj.docs_url, obj.redoc_url - + return name raise FastAPICLIException("Could not find FastAPI app in module, try using --app") @@ -123,9 +112,6 @@ class ImportData: app_name: str module_data: ModuleData import_string: str - openapi_url: str | None = None - docs_url: str | None = None - redoc_url: str | None = None def get_import_data( @@ -139,22 +125,14 @@ 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, openapi_url, docs_url, redoc_url = get_app_infos( - mod_data=mod_data, app_name=app_name - ) + use_app_name = 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, - openapi_url=openapi_url, - docs_url=docs_url, - redoc_url=redoc_url, + app_name=use_app_name, module_data=mod_data, import_string=import_string ) @@ -170,21 +148,35 @@ def get_import_data_from_import_string(import_string: str) -> ImportData: sys.path.insert(0, str(here)) - module_data = ModuleData( - module_import_str=module_str, - extra_sys_path=here, - module_paths=[], - ) - - _, openapi_url, docs_url, redoc_url = get_app_infos( - mod_data=module_data, app_name=app_name - ) - return ImportData( app_name=app_name, - module_data=module_data, + module_data=ModuleData( + module_import_str=module_str, + extra_sys_path=here, + module_paths=[], + ), import_string=import_string, - openapi_url=openapi_url, - docs_url=docs_url, - redoc_url=redoc_url, + ) + + +@dataclass +class DocsURLs: + openapi_url: str | None + docs_url: str | None + redoc_url: str | None + + +def get_docs_urls(import_data: ImportData) -> DocsURLs: + module = importlib.import_module(import_data.module_data.module_import_str) + app_name = import_data.app_name + app = getattr(module, app_name) + + # Just for type checking, it's already checked in get_app_name + assert FastAPI is not None + assert isinstance(app, FastAPI) + + return DocsURLs( + openapi_url=app.openapi_url, + docs_url=app.docs_url, + redoc_url=app.redoc_url, ) diff --git a/tests/assets/single_file_docs.py b/tests/assets/single_file_docs.py index d074804a..d560283a 100644 --- a/tests/assets/single_file_docs.py +++ b/tests/assets/single_file_docs.py @@ -40,9 +40,17 @@ 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 = FastAPI(docs_url="/custom-docs-url") @custom_docs.get("/") def custom_docs_root(): return {"message": "single file custom_docs"} + + +custom_redoc = FastAPI(docs_url=None, redoc_url="/custom-redoc-url") + + +@custom_redoc.get("/") +def custom_redoc_root(): + return {"message": "single file custom_redoc"} diff --git a/tests/test_cli.py b/tests/test_cli.py index 8264baaf..ddc35e17 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -453,7 +453,7 @@ def test_full_docs() -> None: 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 + assert "http://127.0.0.1:8000/redoc" not in result.output # docs has precedence def test_custom_docs() -> None: @@ -466,6 +466,17 @@ def test_custom_docs() -> None: assert mock_run.called assert "http://127.0.0.1:8000/custom-docs-url" in result.output + + +def test_custom_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", "custom_redoc"] + ) + assert result.exit_code == 0, result.output + assert mock_run.called + assert "http://127.0.0.1:8000/custom-redoc-url" in result.output diff --git a/tests/test_discover.py b/tests/test_discover.py index 99b5a4fe..b1052050 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -7,20 +7,18 @@ get_import_data_from_import_string, ) from fastapi_cli.exceptions import FastAPICLIException -from tests.utils import changing_dir assets_path = Path(__file__).parent / "assets" def test_get_import_data_from_import_string_valid() -> None: - with changing_dir(assets_path): - result = get_import_data_from_import_string("package.mod.app:app") + result = get_import_data_from_import_string("module.submodule:app") assert isinstance(result, ImportData) assert result.app_name == "app" - assert result.import_string == "package.mod.app:app" - assert result.module_data.module_import_str == "package.mod.app" - assert result.module_data.extra_sys_path == Path(assets_path).resolve() + assert result.import_string == "module.submodule:app" + assert result.module_data.module_import_str == "module.submodule" + assert result.module_data.extra_sys_path == Path(".").resolve() assert result.module_data.module_paths == [] diff --git a/tests/utils.py b/tests/utils.py index c15ed32f..a8d4274f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -2,11 +2,10 @@ from collections.abc import Generator from contextlib import contextmanager from pathlib import Path -from typing import Union @contextmanager -def changing_dir(directory: Union[str, Path]) -> Generator[None, None, None]: +def changing_dir(directory: str | Path) -> Generator[None, None, None]: initial_dir = os.getcwd() os.chdir(directory) try: From 4a7ea663263280e99c8cbe836fe682c5d5dbbca0 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Tue, 28 Apr 2026 10:55:54 +0200 Subject: [PATCH 6/9] Refactor `docs_urls`-related tests --- tests/assets/single_file_docs.py | 53 ++++++++++++++++++++++---------- tests/test_cli.py | 41 +++++++++++------------- 2 files changed, 55 insertions(+), 39 deletions(-) diff --git a/tests/assets/single_file_docs.py b/tests/assets/single_file_docs.py index d560283a..e1ab94a3 100644 --- a/tests/assets/single_file_docs.py +++ b/tests/assets/single_file_docs.py @@ -1,36 +1,51 @@ from fastapi import FastAPI -no_openapi = FastAPI(openapi_url=None) +# App 1: API documentation disabled via `openapi_url=None` +# ------------------------------------------------------------------------------------ +openapi_none = FastAPI(openapi_url=None) -@no_openapi.get("/") -def no_openapi_root(): - return {"message": "single file no_openapi"} +@openapi_none.get("/") +def openapi_none_root(): + return {"message": "single file openapi_none"} -none_docs = FastAPI(docs_url=None, redoc_url=None) +# App 2: Both docs and redoc disabled via `docs_url=None` and `redoc_url=None` +# ------------------------------------------------------------------------------------ -@none_docs.get("/") -def none_docs_root(): - return {"message": "single file none_docs"} +docs_none_redoc_none = FastAPI(docs_url=None, redoc_url=None) -no_docs = FastAPI(docs_url=None) +@docs_none_redoc_none.get("/") +def docs_none_redoc_none_root(): + return {"message": "single file docs_none_redoc_none"} -@no_docs.get("/") -def no_docs_root(): - return {"message": "single file no_docs"} +# App 3: Only ReDoc. Swagger docs disabled via `docs_url=None` +# ------------------------------------------------------------------------------------ +only_redoc = FastAPI(docs_url=None) -no_redoc = FastAPI(redoc_url=None) +@only_redoc.get("/") +def only_redoc_root(): + return {"message": "single file only_redoc"} -@no_redoc.get("/") -def no_redoc_root(): - return {"message": "single file no_redoc"} +# App 4: Only Swagger docs. ReDoc disabled via `redoc_url=None` +# ------------------------------------------------------------------------------------ + +only_docs = FastAPI(redoc_url=None) + + +@only_docs.get("/") +def only_docs_root(): + return {"message": "single file only_docs"} + + +# App 5: Both docs and redoc enabled with default URLs +# ------------------------------------------------------------------------------------ full_docs = FastAPI() @@ -40,6 +55,9 @@ def full_docs_root(): return {"message": "single file full_docs"} +# App 6: Swagger docs with custom URL. ReDoc with default URL. +# ------------------------------------------------------------------------------------ + custom_docs = FastAPI(docs_url="/custom-docs-url") @@ -48,6 +66,9 @@ def custom_docs_root(): return {"message": "single file custom_docs"} +# App 7: ReDoc with custom URL. Swagger docs with default URL. +# ------------------------------------------------------------------------------------ + custom_redoc = FastAPI(docs_url=None, redoc_url="/custom-redoc-url") diff --git a/tests/test_cli.py b/tests/test_cli.py index ddc35e17..de5c91fe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,7 @@ from pathlib import Path from unittest.mock import patch +import pytest import uvicorn from typer.testing import CliRunner @@ -391,11 +392,18 @@ def test_run_env_vars_and_args() -> None: assert "Documentation at http://0.0.0.0:8080/docs" in result.output -def test_no_openapi() -> None: +@pytest.mark.parametrize( + "app_name", + [ + "openapi_none", + "docs_none_redoc_none", + ], +) +def test_docs_urls_disabled(app_name: str) -> 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"] + app, ["dev", "single_file_docs.py", "--app", app_name] ) assert result.exit_code == 0, result.output assert mock_run.called @@ -404,24 +412,24 @@ def test_no_openapi() -> None: assert "http://127.0.0.1:8000/redoc" not in result.output -def test_none_docs() -> None: +def test_docs_urls_only_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"] + app, ["dev", "single_file_docs.py", "--app", "only_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/docs" in result.output assert "http://127.0.0.1:8000/redoc" not in result.output -def test_no_docs() -> None: +def test_docs_urls_only_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_docs"] + app, ["dev", "single_file_docs.py", "--app", "only_redoc"] ) assert result.exit_code == 0, result.output assert mock_run.called @@ -430,20 +438,7 @@ def test_no_docs() -> None: 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: +def test_docs_urls_full_docs() -> None: with changing_dir(assets_path): with patch.object(uvicorn, "run") as mock_run: result = runner.invoke( @@ -456,7 +451,7 @@ def test_full_docs() -> None: assert "http://127.0.0.1:8000/redoc" not in result.output # docs has precedence -def test_custom_docs() -> None: +def test_docs_urls_custom_docs() -> None: with changing_dir(assets_path): with patch.object(uvicorn, "run") as mock_run: result = runner.invoke( @@ -468,7 +463,7 @@ def test_custom_docs() -> None: assert "http://127.0.0.1:8000/custom-docs-url" in result.output -def test_custom_redoc() -> None: +def test_docs_urls_custom_redoc() -> None: with changing_dir(assets_path): with patch.object(uvicorn, "run") as mock_run: result = runner.invoke( From 59fc428f1beedae88893dbf64fea72dfe5ed67a2 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Tue, 28 Apr 2026 11:03:19 +0200 Subject: [PATCH 7/9] Respect `root_path` in URLs --- src/fastapi_cli/cli.py | 6 ++++ src/fastapi_cli/discover.py | 12 +++++++ tests/assets/single_file_docs.py | 22 ++++++++++++ tests/test_cli.py | 60 +++++++++++++++++++++++++++++--- 4 files changed, 96 insertions(+), 4 deletions(-) diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index 675d933e..c2783425 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -12,6 +12,7 @@ get_docs_urls, get_import_data, get_import_data_from_import_string, + get_root_path, ) from fastapi_cli.exceptions import FastAPICLIException @@ -196,6 +197,11 @@ def _run( docs_urls = get_docs_urls(import_data) url = f"http://{host}:{port}" + + use_root_path = root_path or get_root_path(import_data) + if use_root_path: + url += use_root_path + docs_url = "" if docs_urls.openapi_url and (docs_urls.docs_url or docs_urls.redoc_url): diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index a938b720..41eee268 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -180,3 +180,15 @@ def get_docs_urls(import_data: ImportData) -> DocsURLs: docs_url=app.docs_url, redoc_url=app.redoc_url, ) + + +def get_root_path(import_data: ImportData) -> str: + module = importlib.import_module(import_data.module_data.module_import_str) + app_name = import_data.app_name + app = getattr(module, app_name) + + # Just for type checking, it's already checked in get_app_name + assert FastAPI is not None + assert isinstance(app, FastAPI) + + return app.root_path diff --git a/tests/assets/single_file_docs.py b/tests/assets/single_file_docs.py index e1ab94a3..6686b8cd 100644 --- a/tests/assets/single_file_docs.py +++ b/tests/assets/single_file_docs.py @@ -75,3 +75,25 @@ def custom_docs_root(): @custom_redoc.get("/") def custom_redoc_root(): return {"message": "single file custom_redoc"} + + +# App 8: Swagger docs enabled, root_path set to "/api". ReDoc disabled. +# ------------------------------------------------------------------------------------ + +docs_root_path = FastAPI(redoc_url=None, root_path="/api") + + +@docs_root_path.get("/") +def docs_root_path_root(): + return {"message": "single file docs_root_path"} + + +# App 9: ReDoc enabled, root_path set to "/api". Swagger docs disabled. +# ------------------------------------------------------------------------------------ + +redoc_root_path = FastAPI(docs_url=None, root_path="/api") + + +@redoc_root_path.get("/") +def redoc_root_path_root(): + return {"message": "single file redoc_root_path"} diff --git a/tests/test_cli.py b/tests/test_cli.py index de5c91fe..d710a3dd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -133,8 +133,8 @@ def test_dev_args() -> None: } assert "Using import string: single_file_app:api" in result.output assert "Starting development server 🚀" in result.output - assert "Server started at http://192.168.0.2:8080" in result.output - assert "Documentation at http://192.168.0.2:8080/docs" in result.output + assert "Server started at http://192.168.0.2:8080/api" in result.output + assert "Documentation at http://192.168.0.2:8080/api/docs" in result.output assert ( "Running in development mode, for production use: fastapi run" in result.output @@ -323,8 +323,8 @@ def test_run_args() -> None: assert "Using import string: single_file_app:api" in result.output assert "Starting production server 🚀" in result.output - assert "Server started at http://192.168.0.2:8080" in result.output - assert "Documentation at http://192.168.0.2:8080/docs" in result.output + assert "Server started at http://192.168.0.2:8080/api" in result.output + assert "Documentation at http://192.168.0.2:8080/api/docs" in result.output assert ( "Running in development mode, for production use: fastapi run" not in result.output @@ -475,6 +475,58 @@ def test_docs_urls_custom_redoc() -> None: assert "http://127.0.0.1:8000/custom-redoc-url" in result.output +@pytest.mark.parametrize( + ("app_name", "expected_url"), + [ + ("only_docs", "http://127.0.0.1:8000/api/docs"), + ("only_redoc", "http://127.0.0.1:8000/api/redoc"), + ], +) +def test_docs_urls_root_path_option(app_name: str, expected_url: str) -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, + [ + "dev", + "single_file_docs.py", + "--app", + app_name, + "--root-path", + "/api", + ], + ) + assert result.exit_code == 0, result.output + assert mock_run.called + + assert expected_url in result.output + + +@pytest.mark.parametrize( + ("app_name", "expected_url"), + [ + ("docs_root_path", "http://127.0.0.1:8000/api/docs"), + ("redoc_root_path", "http://127.0.0.1:8000/api/redoc"), + ], +) +def test_docs_urls_root_path_param(app_name: str, expected_url: str) -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, + [ + "dev", + "single_file_docs.py", + "--app", + app_name, + ], + ) + assert result.exit_code == 0, result.output + assert mock_run.called + + assert expected_url in result.output + + def test_run_error() -> None: with changing_dir(assets_path): result = runner.invoke(app, ["run", "non_existing_file.py"]) From bfdfa6235475cd6109c157405611d36edbf7d5c9 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Tue, 28 Apr 2026 11:18:33 +0200 Subject: [PATCH 8/9] Refactor `get_docs_urls` to return list of URLs --- src/fastapi_cli/cli.py | 22 ++++++---------------- src/fastapi_cli/discover.py | 21 ++++++++------------- 2 files changed, 14 insertions(+), 29 deletions(-) diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index c2783425..8602d269 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -194,35 +194,25 @@ def _run( tag="app", ) - docs_urls = get_docs_urls(import_data) - url = f"http://{host}:{port}" use_root_path = root_path or get_root_path(import_data) if use_root_path: url += use_root_path - docs_url = "" - - if docs_urls.openapi_url and (docs_urls.docs_url or docs_urls.redoc_url): - if docs_urls.docs_url: - docs_url = ( - f"[link={url}{docs_urls.docs_url}]{url}{docs_urls.docs_url}[/]" - ) - else: - docs_url = ( - f"[link={url}{docs_urls.redoc_url}]{url}{docs_urls.redoc_url}[/]" - ) - toolkit.print_line() toolkit.print( f"Server started at [link={url}]{url}[/]", tag="server", ) - if docs_url: + docs_urls = get_docs_urls(import_data) + docs_links = [ + f"[link={url}{docs_url}]{url}{docs_url}[/]" for docs_url in docs_urls + ] + if docs_links: toolkit.print( - f"Documentation at {docs_url}", + f"Documentation at {docs_links[0]}", tag="server", ) diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index 41eee268..3646ab92 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -159,14 +159,7 @@ def get_import_data_from_import_string(import_string: str) -> ImportData: ) -@dataclass -class DocsURLs: - openapi_url: str | None - docs_url: str | None - redoc_url: str | None - - -def get_docs_urls(import_data: ImportData) -> DocsURLs: +def get_docs_urls(import_data: ImportData) -> list[str]: module = importlib.import_module(import_data.module_data.module_import_str) app_name = import_data.app_name app = getattr(module, app_name) @@ -175,11 +168,13 @@ def get_docs_urls(import_data: ImportData) -> DocsURLs: assert FastAPI is not None assert isinstance(app, FastAPI) - return DocsURLs( - openapi_url=app.openapi_url, - docs_url=app.docs_url, - redoc_url=app.redoc_url, - ) + docs_urls: list[str] = [] + if app.openapi_url and app.docs_url: + docs_urls.append(app.docs_url) + if app.openapi_url and app.redoc_url: + docs_urls.append(app.redoc_url) + + return docs_urls def get_root_path(import_data: ImportData) -> str: From a320f5d38825787eaad639a8e63e47c77ca8bd11 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Tue, 28 Apr 2026 11:53:15 +0200 Subject: [PATCH 9/9] Revert adding empty lines --- src/fastapi_cli/discover.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index 3646ab92..6fbb8d95 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -44,16 +44,12 @@ 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 @@ -61,7 +57,6 @@ 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(),