Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🧑‍💻 Add conditional printing API docs URLs in panel #119

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions src/fastapi_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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(
Expand Down
35 changes: 28 additions & 7 deletions src/fastapi_cli/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -45,27 +45,34 @@ 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
else:
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(),
module_paths=module_paths,
)


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:
Expand All @@ -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")


Expand All @@ -108,6 +124,7 @@ class ImportData:
app_name: str
module_data: ModuleData
import_string: str
fastapi_app: FastAPI


def get_import_data(
Expand All @@ -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,
)
48 changes: 48 additions & 0 deletions tests/assets/single_file_docs.py
Original file line number Diff line number Diff line change
@@ -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"}
78 changes: 78 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
Loading