From 574a1ed7bf3bd406fd610e55dbaf70c5d131ae52 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Wed, 4 Sep 2024 12:18:36 +0200 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=A8=20Nicer=20dev=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 1 + src/fastapi_cli/cli.py | 151 ++++++--- src/fastapi_cli/discover.py | 83 ++--- src/fastapi_cli/logging.py | 6 +- src/fastapi_cli/utils/__init__.py | 0 src/fastapi_cli/utils/cli.py | 74 +++++ tests/test_cli.py | 54 ++-- tests/test_requirements.py | 4 +- tests/test_utils_default_dir.py | 102 ++---- tests/test_utils_default_file.py | 76 ++--- tests/test_utils_package.py | 500 ++++++++---------------------- tests/test_utils_single_file.py | 108 ++----- 12 files changed, 447 insertions(+), 712 deletions(-) create mode 100644 src/fastapi_cli/utils/__init__.py create mode 100644 src/fastapi_cli/utils/cli.py diff --git a/pyproject.toml b/pyproject.toml index 4a684fe..58c475a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ classifiers = [ dependencies = [ "typer >= 0.12.3", "uvicorn[standard] >= 0.15.0", + "rich-toolkit >= 0.7.0", ] [project.optional-dependencies] diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index d5bcb8e..12cebb7 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -1,23 +1,23 @@ -from logging import getLogger +import logging from pathlib import Path -from typing import Any, Union +from typing import Any, List, Union import typer from rich import print -from rich.padding import Padding -from rich.panel import Panel +from rich.tree import Tree from typing_extensions import Annotated -from fastapi_cli.discover import get_import_string +from fastapi_cli.discover import get_import_data from fastapi_cli.exceptions import FastAPICLIException from . import __version__ from .logging import setup_logging +from .utils.cli import get_rich_toolkit, get_uvicorn_log_config app = typer.Typer(rich_markup_mode="rich") -setup_logging() -logger = getLogger(__name__) +logger = logging.getLogger(__name__) + try: import uvicorn @@ -39,6 +39,7 @@ def callback( "--version", help="Show the version and exit.", callback=version_callback ), ] = None, + verbose: bool = typer.Option(False, help="Enable verbose output"), ) -> None: """ FastAPI CLI - The [bold]fastapi[/bold] command line app. 😎 @@ -48,6 +49,31 @@ def callback( Read more in the docs: [link]https://fastapi.tiangolo.com/fastapi-cli/[/link]. """ + log_level = logging.DEBUG if verbose else logging.INFO + + setup_logging(level=log_level) + + +def _get_module_tree(module_paths: List[Path]) -> Tree: + root = module_paths[0] + name = f"🐍 {root.name}" if root.is_file() else f"📁 {root.name}" + + root_tree = Tree(name) + + if root.is_dir(): + root_tree.add("[dim]🐍 __init__.py[/dim]") + + tree = root_tree + for sub_path in module_paths[1:]: + sub_name = ( + f"🐍 {sub_path.name}" if sub_path.is_file() else f"📁 {sub_path.name}" + ) + tree = tree.add(sub_name) + if sub_path.is_dir(): + tree.add("[dim]🐍 __init__.py[/dim]") + + return root_tree + def _run( path: Union[Path, None] = None, @@ -61,43 +87,84 @@ def _run( app: Union[str, None] = None, proxy_headers: bool = False, ) -> None: - try: - use_uvicorn_app = get_import_string(path=path, app_name=app) - except FastAPICLIException as e: - logger.error(str(e)) - raise typer.Exit(code=1) from None - serving_str = f"[dim]Serving at:[/dim] [link]http://{host}:{port}[/link]\n\n[dim]API docs:[/dim] [link]http://{host}:{port}/docs[/link]" - - if command == "dev": - panel = Panel( - f"{serving_str}\n\n[dim]Running in development mode, for production use:[/dim] \n\n[b]fastapi run[/b]", - title="FastAPI CLI - Development mode", - expand=False, - padding=(1, 2), - style="black on yellow", + with get_rich_toolkit() as toolkit: + server_type = "development" if command == "dev" else "production" + + toolkit.print_title(f"Starting {server_type} server 🚀", tag="FastAPI") + toolkit.print_line() + + toolkit.print( + "Searching for package file structure from directories with [blue]__init__.py[/blue] files" ) - else: - panel = Panel( - f"{serving_str}\n\n[dim]Running in production mode, for development use:[/dim] \n\n[b]fastapi dev[/b]", - title="FastAPI CLI - Production mode", - expand=False, - padding=(1, 2), - style="green", + + try: + import_data = get_import_data(path=path, app_name=app) + except FastAPICLIException as e: + logger.error(str(e)) + raise typer.Exit(code=1) from None + + logger.debug(f"Importing from {import_data.module_data.extra_sys_path}") + logger.debug(f"Importing module {import_data.module_data.module_import_str}") + + module_data = import_data.module_data + import_string = import_data.import_string + + toolkit.print(f"Importing from {module_data.extra_sys_path}") + toolkit.print_line() + + root_tree = _get_module_tree(module_data.module_paths) + + toolkit.print(root_tree, tag="module") + toolkit.print_line() + + toolkit.print( + "Importing the FastAPI app object from the module with the following code:", + tag="code", + ) + toolkit.print_line() + toolkit.print( + f"[underline]from [bold]{module_data.module_import_str}[/bold] import [bold]{import_data.app_name}[/bold]" + ) + toolkit.print_line() + + toolkit.print( + f"Using import string: [blue]{import_string}[/]", + tag="app", + ) + + toolkit.print_line() + toolkit.print( + f"Server started at [link=http://{host}:{port}]https://{host}:{port}[/]\n\n" + f"Documentation at [link=https://{host}:{port}/docs]https://{host}:{port}/docs[/]", + tag="server", + ) + + if command == "dev": + toolkit.print_line() + toolkit.print( + "Running in development mode, for production use: [bold]fastapi run[/]", + tag="tip", + ) + + if not uvicorn: + raise FastAPICLIException( + "Could not import Uvicorn, try running 'pip install uvicorn'" + ) from None + + toolkit.print_line() + toolkit.print("Logs:") + toolkit.print_line() + + uvicorn.run( + app=import_string, + host=host, + port=port, + reload=reload, + workers=workers, + root_path=root_path, + proxy_headers=proxy_headers, + log_config=get_uvicorn_log_config(), ) - print(Padding(panel, 1)) - if not uvicorn: - raise FastAPICLIException( - "Could not import Uvicorn, try running 'pip install uvicorn'" - ) from None - uvicorn.run( - app=use_uvicorn_app, - host=host, - port=port, - reload=reload, - workers=workers, - root_path=root_path, - proxy_headers=proxy_headers, - ) @app.command() diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index f442438..43d0e9c 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -3,13 +3,7 @@ from dataclasses import dataclass from logging import getLogger from pathlib import Path -from typing import Union - -from rich import print -from rich.padding import Padding -from rich.panel import Panel -from rich.syntax import Syntax -from rich.tree import Tree +from typing import List, Union from fastapi_cli.exceptions import FastAPICLIException @@ -45,12 +39,10 @@ def get_default_path() -> Path: class ModuleData: module_import_str: str extra_sys_path: Path + module_paths: List[Path] def get_module_data_from_path(path: Path) -> ModuleData: - logger.info( - "Searching for package file structure from directories with [blue]__init__.py[/blue] files" - ) use_path = path.resolve() module_path = use_path if use_path.is_file() and use_path.stem == "__init__": @@ -64,37 +56,12 @@ def get_module_data_from_path(path: Path) -> ModuleData: extra_sys_path = parent.parent else: break - logger.info(f"Importing from {extra_sys_path.resolve()}") - root = module_paths[0] - name = f"🐍 {root.name}" if root.is_file() else f"📁 {root.name}" - root_tree = Tree(name) - if root.is_dir(): - root_tree.add("[dim]🐍 __init__.py[/dim]") - tree = root_tree - for sub_path in module_paths[1:]: - sub_name = ( - f"🐍 {sub_path.name}" if sub_path.is_file() else f"📁 {sub_path.name}" - ) - tree = tree.add(sub_name) - if sub_path.is_dir(): - tree.add("[dim]🐍 __init__.py[/dim]") - title = "[b green]Python module file[/b green]" - if len(module_paths) > 1 or module_path.is_dir(): - title = "[b green]Python package file structure[/b green]" - panel = Padding( - Panel( - root_tree, - title=title, - expand=False, - padding=(1, 2), - ), - 1, - ) - print(panel) + module_str = ".".join(p.stem for p in module_paths) - logger.info(f"Importing module [green]{module_str}[/green]") return ModuleData( - module_import_str=module_str, extra_sys_path=extra_sys_path.resolve() + module_import_str=module_str, + extra_sys_path=extra_sys_path.resolve(), + module_paths=module_paths, ) @@ -136,32 +103,30 @@ def get_app_name(*, mod_data: ModuleData, app_name: Union[str, None] = None) -> raise FastAPICLIException("Could not find FastAPI app in module, try using --app") -def get_import_string( +@dataclass +class ImportData: + app_name: str + module_data: ModuleData + import_string: str + + +def get_import_data( *, path: Union[Path, None] = None, app_name: Union[str, None] = None -) -> str: +) -> ImportData: if not path: path = get_default_path() - logger.info(f"Using path [blue]{path}[/blue]") - logger.info(f"Resolved absolute path {path.resolve()}") + + logger.debug(f"Using path [blue]{path}[/blue]") + logger.debug(f"Resolved absolute path {path.resolve()}") + 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) - import_example = Syntax( - f"from {mod_data.module_import_str} import {use_app_name}", "python" - ) - import_panel = Padding( - Panel( - import_example, - title="[b green]Importable FastAPI app[/b green]", - expand=False, - padding=(1, 2), - ), - 1, - ) - logger.info("Found importable FastAPI app") - print(import_panel) + import_string = f"{mod_data.module_import_str}:{use_app_name}" - logger.info(f"Using import string [b green]{import_string}[/b green]") - return import_string + + return ImportData( + app_name=use_app_name, module_data=mod_data, import_string=import_string + ) diff --git a/src/fastapi_cli/logging.py b/src/fastapi_cli/logging.py index 0ef44b0..67f116c 100644 --- a/src/fastapi_cli/logging.py +++ b/src/fastapi_cli/logging.py @@ -5,7 +5,9 @@ from rich.logging import RichHandler -def setup_logging(terminal_width: Union[int, None] = None) -> None: +def setup_logging( + terminal_width: Union[int, None] = None, level: int = logging.INFO +) -> None: logger = logging.getLogger("fastapi_cli") console = Console(width=terminal_width) if terminal_width else None rich_handler = RichHandler( @@ -19,5 +21,5 @@ def setup_logging(terminal_width: Union[int, None] = None) -> None: rich_handler.setFormatter(logging.Formatter("%(message)s")) logger.addHandler(rich_handler) - logger.setLevel(logging.INFO) + logger.setLevel(level) logger.propagate = False diff --git a/src/fastapi_cli/utils/__init__.py b/src/fastapi_cli/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/fastapi_cli/utils/cli.py b/src/fastapi_cli/utils/cli.py new file mode 100644 index 0000000..573f339 --- /dev/null +++ b/src/fastapi_cli/utils/cli.py @@ -0,0 +1,74 @@ +import logging +from typing import Any, Dict + +from rich_toolkit import RichToolkit, RichToolkitTheme +from rich_toolkit.styles import TaggedStyle +from uvicorn.logging import DefaultFormatter + + +class CustomFormatter(DefaultFormatter): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.toolkit = get_rich_toolkit() + + def formatMessage(self, record: logging.LogRecord) -> str: + return self.toolkit.print_as_string(record.message, tag=record.levelname) + + +def get_uvicorn_log_config() -> Dict[str, Any]: + return { + "version": 1, + "formatters": { + "default": { + "()": CustomFormatter, + "fmt": "%(levelprefix)s %(message)s", + "use_colors": None, + }, + "access": { + "()": CustomFormatter, + "fmt": "%(levelprefix)s %(client_addr)s - '%(request_line)s' %(status_code)s", + }, + }, + "handlers": { + "default": { + "formatter": "default", + "class": "logging.StreamHandler", + "stream": "ext://sys.stderr", + }, + "access": { + "formatter": "access", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + }, + }, + "loggers": { + "uvicorn": {"handlers": ["default"], "level": "INFO"}, + "uvicorn.error": {"level": "INFO"}, + "uvicorn.access": { + "handlers": ["access"], + "level": "INFO", + "propagate": False, + }, + }, + } + + +logger = logging.getLogger(__name__) + + +def get_rich_toolkit() -> RichToolkit: + theme = RichToolkitTheme( + style=TaggedStyle(tag_width=11), + theme={ + "tag.title": "white on #009485", + "tag": "white on #007166", + "placeholder": "grey85", + "text": "white", + "selected": "#007166", + "result": "grey85", + "progress": "on #007166", + "error": "red", + }, + ) + + return RichToolkit(theme=theme) diff --git a/tests/test_cli.py b/tests/test_cli.py index 44c14d2..035b655 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,6 +5,7 @@ import uvicorn from fastapi_cli.cli import app +from fastapi_cli.utils.cli import get_uvicorn_log_config from typer.testing import CliRunner from tests.utils import changing_dir @@ -29,15 +30,16 @@ def test_dev() -> None: "workers": None, "root_path": "", "proxy_headers": True, + "log_config": get_uvicorn_log_config(), } - assert "Using import string single_file_app:app" in result.output + assert "Using import string: single_file_app:app" in result.output + assert "Starting development server 🚀" in result.output + assert "Server started at https://127.0.0.1:8000" in result.output + assert "Documentation at https://127.0.0.1:8000/docs" in result.output assert ( - "╭────────── FastAPI CLI - Development mode ───────────╮" in result.output + "Running in development mode, for production use: fastapi run" + in result.output ) - assert "│ Serving at: http://127.0.0.1:8000" in result.output - assert "│ API docs: http://127.0.0.1:8000/docs" in result.output - assert "│ Running in development mode, for production use:" in result.output - assert "│ fastapi run" in result.output def test_dev_args() -> None: @@ -71,15 +73,16 @@ def test_dev_args() -> None: "workers": None, "root_path": "/api", "proxy_headers": False, + "log_config": get_uvicorn_log_config(), } - assert "Using import string single_file_app:api" in result.output + assert "Using import string: single_file_app:api" in result.output + assert "Starting development server 🚀" in result.output + assert "Server started at https://192.168.0.2:8080" in result.output + assert "Documentation at https://192.168.0.2:8080/docs" in result.output assert ( - "╭────────── FastAPI CLI - Development mode ───────────╮" in result.output + "Running in development mode, for production use: fastapi run" + in result.output ) - assert "│ Serving at: http://192.168.0.2:8080" in result.output - assert "│ API docs: http://192.168.0.2:8080/docs" in result.output - assert "│ Running in development mode, for production use:" in result.output - assert "│ fastapi run" in result.output def test_run() -> None: @@ -97,15 +100,16 @@ def test_run() -> None: "workers": None, "root_path": "", "proxy_headers": True, + "log_config": get_uvicorn_log_config(), } - assert "Using import string single_file_app:app" in result.output + assert "Using import string: single_file_app:app" in result.output + assert "Starting production server 🚀" in result.output + assert "Server started at https://0.0.0.0:8000" in result.output + assert "Documentation at https://0.0.0.0:8000/docs" in result.output assert ( - "╭─────────── FastAPI CLI - Production mode ───────────╮" in result.output + "Running in development mode, for production use: fastapi run" + not in result.output ) - assert "│ Serving at: http://0.0.0.0:8000" in result.output - assert "│ API docs: http://0.0.0.0:8000/docs" in result.output - assert "│ Running in production mode, for development use:" in result.output - assert "│ fastapi dev" in result.output def test_run_args() -> None: @@ -141,15 +145,17 @@ def test_run_args() -> None: "workers": 2, "root_path": "/api", "proxy_headers": False, + "log_config": get_uvicorn_log_config(), } - assert "Using import string single_file_app:api" in result.output + + assert "Using import string: single_file_app:api" in result.output + assert "Starting production server 🚀" in result.output + assert "Server started at https://192.168.0.2:8080" in result.output + assert "Documentation at https://192.168.0.2:8080/docs" in result.output assert ( - "╭─────────── FastAPI CLI - Production mode ───────────╮" in result.output + "Running in development mode, for production use: fastapi run" + not in result.output ) - assert "│ Serving at: http://192.168.0.2:8080" in result.output - assert "│ API docs: http://192.168.0.2:8080/docs" in result.output - assert "│ Running in production mode, for development use:" in result.output - assert "│ fastapi dev" in result.output def test_run_error() -> None: diff --git a/tests/test_requirements.py b/tests/test_requirements.py index bcad1b9..fdc99f3 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -1,7 +1,7 @@ from pathlib import Path import pytest -from fastapi_cli.discover import get_import_string +from fastapi_cli.discover import get_import_data from fastapi_cli.exceptions import FastAPICLIException from typer.testing import CliRunner @@ -37,7 +37,7 @@ def test_no_fastapi() -> None: fastapi_cli.discover.FastAPI = None # type: ignore[attr-defined, assignment] with changing_dir(assets_path): with pytest.raises(FastAPICLIException) as exc_info: - get_import_string(path=Path("single_file_app.py")) + get_import_data(path=Path("single_file_app.py")) assert "Could not import FastAPI, try running 'pip install fastapi'" in str( exc_info.value ) diff --git a/tests/test_utils_default_dir.py b/tests/test_utils_default_dir.py index 2665203..8ed8ac0 100644 --- a/tests/test_utils_default_dir.py +++ b/tests/test_utils_default_dir.py @@ -1,87 +1,51 @@ from pathlib import Path import pytest -from fastapi_cli.discover import get_import_string +from fastapi_cli.discover import get_import_data from fastapi_cli.exceptions import FastAPICLIException -from pytest import CaptureFixture from .utils import changing_dir assets_path = Path(__file__).parent / "assets" -def test_app_dir_main(capsys: CaptureFixture[str]) -> None: - with changing_dir(assets_path / "default_files" / "default_app_dir_main"): - import_string = get_import_string() - assert import_string == "app.main:app" - - captured = capsys.readouterr() - assert "Using path app/main.py" in captured.out - assert "Resolved absolute path" in captured.out - assert ( - "/tests/assets/default_files/default_app_dir_main/app/main.py" in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets/default_files/default_app_dir_main" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 app" in captured.out - assert "│ ├── 🐍 __init__.py" in captured.out - assert "│ └── 🐍 main.py" in captured.out - assert "Importing module app.main" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from app.main import app" in captured.out - assert "Using import string app.main:app" in captured.out - - -def test_app_dir_app(capsys: CaptureFixture[str]) -> None: - with changing_dir(assets_path / "default_files" / "default_app_dir_app"): - import_string = get_import_string() - assert import_string == "app.app:app" - - captured = capsys.readouterr() - assert "Using path app/app.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "/tests/assets/default_files/default_app_dir_app/app/app.py" in captured.out - assert "Importing from" in captured.out - assert "tests/assets/default_files/default_app_dir_app" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 app" in captured.out - assert "│ ├── 🐍 __init__.py" in captured.out - assert "│ └── 🐍 app.py" in captured.out - assert "Importing module app.app" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from app.app import app" in captured.out - assert "Using import string app.app:app" in captured.out - - -def test_app_dir_api(capsys: CaptureFixture[str]) -> None: - with changing_dir(assets_path / "default_files" / "default_app_dir_api"): - import_string = get_import_string() - assert import_string == "app.api:app" - - captured = capsys.readouterr() - assert "Using path app/api.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "/tests/assets/default_files/default_app_dir_api/app/api.py" in captured.out - assert "Importing from" in captured.out - assert "tests/assets/default_files/default_app_dir_api" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 app" in captured.out - assert "│ ├── 🐍 __init__.py" in captured.out - assert "│ └── 🐍 api.py" in captured.out - assert "Importing module app.api" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from app.api import app" in captured.out - assert "Using import string app.api:app" in captured.out +def test_app_dir_main() -> None: + dir = assets_path / "default_files" / "default_app_dir_main" + with changing_dir(dir): + import_data = get_import_data() + + assert import_data.import_string == "app.main:app" + + assert import_data.module_data.extra_sys_path == dir + assert import_data.module_data.module_import_str == "app.main" + + +def test_app_dir_app() -> None: + dir = assets_path / "default_files" / "default_app_dir_app" + with changing_dir(dir): + import_data = get_import_data() + + assert import_data.import_string == "app.app:app" + + assert import_data.module_data.extra_sys_path == dir + assert import_data.module_data.module_import_str == "app.app" + + +def test_app_dir_api() -> None: + dir = assets_path / "default_files" / "default_app_dir_api" + with changing_dir(dir): + import_data = get_import_data() + + assert import_data.import_string == "app.api:app" + + assert import_data.module_data.extra_sys_path == dir + assert import_data.module_data.module_import_str == "app.api" def test_app_dir_non_default() -> None: with changing_dir(assets_path / "default_files" / "default_app_dir_non_default"): with pytest.raises(FastAPICLIException) as e: - get_import_string() + get_import_data() assert ( "Could not find a default file to run, please provide an explicit path" in e.value.args[0] diff --git a/tests/test_utils_default_file.py b/tests/test_utils_default_file.py index f5c87c8..8bddcea 100644 --- a/tests/test_utils_default_file.py +++ b/tests/test_utils_default_file.py @@ -3,7 +3,7 @@ from pathlib import Path import pytest -from fastapi_cli.discover import get_import_string +from fastapi_cli.discover import get_import_data from fastapi_cli.exceptions import FastAPICLIException from pytest import CaptureFixture @@ -12,7 +12,7 @@ assets_path = Path(__file__).parent / "assets" -def test_single_file_main(capsys: CaptureFixture[str]) -> None: +def test_single_file_main() -> None: root_path = assets_path / "default_files" / "default_main" old_sys_path = sys.path.copy() with changing_dir(root_path): @@ -20,26 +20,16 @@ def test_single_file_main(capsys: CaptureFixture[str]) -> None: mod = importlib.import_module("main") importlib.reload(mod) - import_string = get_import_string() - assert import_string == "main:app" - - captured = capsys.readouterr() - assert "Using path main.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "/tests/assets/default_files/default_main/main.py" in captured.out - assert "Importing from" in captured.out - assert "/tests/assets/default_files/default_main" in captured.out - assert "╭─ Python module file ─╮" in captured.out - assert "│ 🐍 main.py" in captured.out - assert "Importing module main" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from main import app" in captured.out - assert "Using import string main:app" in captured.out + import_data = get_import_data() + + assert import_data.import_string == "main:app" + assert import_data.module_data.extra_sys_path == root_path + assert import_data.module_data.module_import_str == "main" + sys.path = old_sys_path -def test_single_file_app(capsys: CaptureFixture[str]) -> None: +def test_single_file_app() -> None: root_path = assets_path / "default_files" / "default_app" old_sys_path = sys.path.copy() with changing_dir(root_path): @@ -47,26 +37,16 @@ def test_single_file_app(capsys: CaptureFixture[str]) -> None: mod = importlib.import_module("app") importlib.reload(mod) - import_string = get_import_string() - assert import_string == "app:app" - - captured = capsys.readouterr() - assert "Using path app.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "/tests/assets/default_files/default_app/app.py" in captured.out - assert "Importing from" in captured.out - assert "/tests/assets/default_files/default_app" in captured.out - assert "╭─ Python module file ─╮" in captured.out - assert "│ 🐍 app.py" in captured.out - assert "Importing module app" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from app import app" in captured.out - assert "Using import string app:app" in captured.out + import_data = get_import_data() + + assert import_data.import_string == "app:app" + assert import_data.module_data.extra_sys_path == root_path + assert import_data.module_data.module_import_str == "app" + sys.path = old_sys_path -def test_single_file_api(capsys: CaptureFixture[str]) -> None: +def test_single_file_api() -> None: root_path = assets_path / "default_files" / "default_api" old_sys_path = sys.path.copy() with changing_dir(root_path): @@ -74,29 +54,19 @@ def test_single_file_api(capsys: CaptureFixture[str]) -> None: mod = importlib.import_module("api") importlib.reload(mod) - import_string = get_import_string() - assert import_string == "api:app" - - captured = capsys.readouterr() - assert "Using path api.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "/tests/assets/default_files/default_api/api.py" in captured.out - assert "Importing from" in captured.out - assert "/tests/assets/default_files/default_api" in captured.out - assert "╭─ Python module file ─╮" in captured.out - assert "│ 🐍 api.py" in captured.out - assert "Importing module api" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from api import app" in captured.out - assert "Using import string api:app" in captured.out + import_data = get_import_data() + + assert import_data.import_string == "api:app" + assert import_data.module_data.extra_sys_path == root_path + assert import_data.module_data.module_import_str == "api" + sys.path = old_sys_path def test_non_default_file(capsys: CaptureFixture[str]) -> None: with changing_dir(assets_path / "default_files" / "non_default"): with pytest.raises(FastAPICLIException) as e: - get_import_string() + get_import_data() assert ( "Could not find a default file to run, please provide an explicit path" in e.value.args[0] diff --git a/tests/test_utils_package.py b/tests/test_utils_package.py index d5573db..fb878f9 100644 --- a/tests/test_utils_package.py +++ b/tests/test_utils_package.py @@ -1,7 +1,7 @@ from pathlib import Path import pytest -from fastapi_cli.discover import get_import_string +from fastapi_cli.discover import get_import_data from fastapi_cli.exceptions import FastAPICLIException from pytest import CaptureFixture @@ -10,417 +10,159 @@ assets_path = Path(__file__).parent / "assets" -def test_package_app_root(capsys: CaptureFixture[str]) -> None: +def test_package_app_root() -> None: with changing_dir(assets_path): - import_string = get_import_string(path=Path("package/mod/app.py")) - assert import_string == "package.mod.app:app" + import_data = get_import_data(path=Path("package/mod/app.py")) - captured = capsys.readouterr() - assert "Using path package/mod/app.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/package/mod/app.py" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 package" in captured.out - assert "│ ├── 🐍 __init__.py" in captured.out - assert "│ └── 📁 mod" in captured.out - assert "│ ├── 🐍 __init__.py " in captured.out - assert "│ └── 🐍 app.py" in captured.out - assert "Importing module package.mod.app" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from package.mod.app import app" in captured.out - assert "Using import string package.mod.app:app" in captured.out - - -def test_package_api_root(capsys: CaptureFixture[str]) -> None: + assert import_data.import_string == "package.mod.app:app" + + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "package.mod.app" + + +def test_package_api_root() -> None: with changing_dir(assets_path): - import_string = get_import_string(path=Path("package/mod/api.py")) - assert import_string == "package.mod.api:api" + import_data = get_import_data(path=Path("package/mod/api.py")) - captured = capsys.readouterr() - assert "Using path package/mod/api.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/package/mod/api.py" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 package" in captured.out - assert "│ ├── 🐍 __init__.py" in captured.out - assert "│ └── 📁 mod" in captured.out - assert "│ ├── 🐍 __init__.py " in captured.out - assert "│ └── 🐍 api.py" in captured.out - assert "Importing module package.mod.api" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from package.mod.api import api" in captured.out - assert "Using import string package.mod.api:api" in captured.out - - -def test_package_other_root(capsys: CaptureFixture[str]) -> None: + assert import_data.import_string == "package.mod.api:api" + + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "package.mod.api" + + +def test_package_other_root() -> None: with changing_dir(assets_path): - import_string = get_import_string(path=Path("package/mod/other.py")) - assert import_string == "package.mod.other:first_other" + import_data = get_import_data(path=Path("package/mod/other.py")) - captured = capsys.readouterr() - assert "Using path package/mod/other.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/package/mod/other.py" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 package" in captured.out - assert "│ ├── 🐍 __init__.py" in captured.out - assert "│ └── 📁 mod" in captured.out - assert "│ ├── 🐍 __init__.py " in captured.out - assert "│ └── 🐍 other.py" in captured.out - assert "Importing module package.mod.other" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from package.mod.other import first_other" in captured.out - assert "Using import string package.mod.other:first_other" in captured.out - - -def test_package_app_mod(capsys: CaptureFixture[str]) -> None: + assert import_data.import_string == "package.mod.other:first_other" + + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "package.mod.other" + + +def test_package_app_mod() -> None: with changing_dir(assets_path / "package/mod"): - import_string = get_import_string(path=Path("app.py")) - assert import_string == "package.mod.app:app" + import_data = get_import_data(path=Path("app.py")) - captured = capsys.readouterr() - assert "Using path app.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/package/mod/app.py" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 package" in captured.out - assert "│ ├── 🐍 __init__.py" in captured.out - assert "│ └── 📁 mod" in captured.out - assert "│ ├── 🐍 __init__.py " in captured.out - assert "│ └── 🐍 app.py" in captured.out - assert "Importing module package.mod.app" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from package.mod.app import app" in captured.out - assert "Using import string package.mod.app:app" in captured.out - - -def test_package_api_mod(capsys: CaptureFixture[str]) -> None: + assert import_data.import_string == "package.mod.app:app" + + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "package.mod.app" + + +def test_package_api_mod() -> None: with changing_dir(assets_path / "package/mod"): - import_string = get_import_string(path=Path("api.py")) - assert import_string == "package.mod.api:api" + import_data = get_import_data(path=Path("api.py")) - captured = capsys.readouterr() - assert "Using path api.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/package/mod/api.py" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 package" in captured.out - assert "│ ├── 🐍 __init__.py" in captured.out - assert "│ └── 📁 mod" in captured.out - assert "│ ├── 🐍 __init__.py " in captured.out - assert "│ └── 🐍 api.py" in captured.out - assert "Importing module package.mod.api" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from package.mod.api import api" in captured.out - assert "Using import string package.mod.api:api" in captured.out - - -def test_package_other_mod(capsys: CaptureFixture[str]) -> None: + assert import_data.import_string == "package.mod.api:api" + + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "package.mod.api" + + +def test_package_other_mod() -> None: with changing_dir(assets_path / "package/mod"): - import_string = get_import_string(path=Path("other.py")) - assert import_string == "package.mod.other:first_other" + import_data = get_import_data(path=Path("other.py")) - captured = capsys.readouterr() - assert "Using path other.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/package/mod/other.py" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 package" in captured.out - assert "│ ├── 🐍 __init__.py" in captured.out - assert "│ └── 📁 mod" in captured.out - assert "│ ├── 🐍 __init__.py " in captured.out - assert "│ └── 🐍 other.py" in captured.out - assert "Importing module package.mod.other" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from package.mod.other import first_other" in captured.out - assert "Using import string package.mod.other:first_other" in captured.out - - -def test_package_app_above(capsys: CaptureFixture[str]) -> None: + assert import_data.import_string == "package.mod.other:first_other" + + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "package.mod.other" + + +def test_package_app_above() -> None: with changing_dir(assets_path.parent): - import_string = get_import_string(path=Path("assets/package/mod/app.py")) - assert import_string == "package.mod.app:app" + import_data = get_import_data(path=Path("assets/package/mod/app.py")) - captured = capsys.readouterr() - assert "Using path assets/package/mod/app.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/package/mod/app.py" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 package" in captured.out - assert "│ ├── 🐍 __init__.py" in captured.out - assert "│ └── 📁 mod" in captured.out - assert "│ ├── 🐍 __init__.py " in captured.out - assert "│ └── 🐍 app.py" in captured.out - assert "Importing module package.mod.app" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from package.mod.app import app" in captured.out - assert "Using import string package.mod.app:app" in captured.out - - -def test_package_api_parent(capsys: CaptureFixture[str]) -> None: + assert import_data.import_string == "package.mod.app:app" + + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "package.mod.app" + + +def test_package_api_parent() -> None: with changing_dir(assets_path.parent): - import_string = get_import_string(path=Path("assets/package/mod/api.py")) - assert import_string == "package.mod.api:api" + import_data = get_import_data(path=Path("assets/package/mod/api.py")) - captured = capsys.readouterr() - assert "Using path assets/package/mod/api.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/package/mod/api.py" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 package" in captured.out - assert "│ ├── 🐍 __init__.py" in captured.out - assert "│ └── 📁 mod" in captured.out - assert "│ ├── 🐍 __init__.py " in captured.out - assert "│ └── 🐍 api.py" in captured.out - assert "Importing module package.mod.api" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from package.mod.api import api" in captured.out - assert "Using import string package.mod.api:api" in captured.out - - -def test_package_other_parent(capsys: CaptureFixture[str]) -> None: + assert import_data.import_string == "package.mod.api:api" + + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "package.mod.api" + + +def test_package_other_parent() -> None: with changing_dir(assets_path.parent): - import_string = get_import_string(path=Path("assets/package/mod/other.py")) - assert import_string == "package.mod.other:first_other" + import_data = get_import_data(path=Path("assets/package/mod/other.py")) - captured = capsys.readouterr() - assert "Using path assets/package/mod/other.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/package/mod/other.py" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 package" in captured.out - assert "│ ├── 🐍 __init__.py" in captured.out - assert "│ └── 📁 mod" in captured.out - assert "│ ├── 🐍 __init__.py " in captured.out - assert "│ └── 🐍 other.py" in captured.out - assert "Importing module package.mod.other" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from package.mod.other import first_other" in captured.out - assert "Using import string package.mod.other:first_other" in captured.out - - -def test_package_mod_init_inside(capsys: CaptureFixture[str]) -> None: + assert import_data.import_string == "package.mod.other:first_other" + + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "package.mod.other" + + +def test_package_mod_init_inside() -> None: with changing_dir(assets_path / "package/mod"): - import_string = get_import_string(path=Path("__init__.py")) - assert import_string == "package.mod:app" + import_data = get_import_data(path=Path("__init__.py")) + assert import_data.import_string == "package.mod:app" - captured = capsys.readouterr() - assert "Using path __init__.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/package/mod/__init__.py" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 package" in captured.out - assert "│ ├── 🐍 __init__.py" in captured.out - assert "│ └── 📁 mod" in captured.out - assert "│ └── 🐍 __init__.py " in captured.out - assert "Importing module package.mod" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from package.mod import app" in captured.out - assert "Using import string package.mod:app" in captured.out - - -def test_package_mod_dir(capsys: CaptureFixture[str]) -> None: + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "package.mod" + + +def test_package_mod_dir() -> None: with changing_dir(assets_path): - import_string = get_import_string(path=Path("package/mod")) - assert import_string == "package.mod:app" + import_data = get_import_data(path=Path("package/mod")) - captured = capsys.readouterr() - assert "Using path package/mod" in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/package/mod" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 package" in captured.out - assert "│ ├── 🐍 __init__.py" in captured.out - assert "│ └── 📁 mod" in captured.out - assert "│ └── 🐍 __init__.py " in captured.out - assert "Importing module package.mod" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from package.mod import app" in captured.out - assert "Using import string package.mod:app" in captured.out - - -def test_package_init_inside(capsys: CaptureFixture[str]) -> None: + assert import_data.import_string == "package.mod:app" + + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "package.mod" + + +def test_package_init_inside() -> None: with changing_dir(assets_path / "package"): - import_string = get_import_string(path=Path("__init__.py")) - assert import_string == "package:app" + import_data = get_import_data(path=Path("__init__.py")) - captured = capsys.readouterr() - assert "Using path __init__.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/package/__init__.py" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 package" in captured.out - assert "│ └── 🐍 __init__.py" in captured.out - assert "Importing module package" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from package import app" in captured.out - assert "Using import string package:app" in captured.out - - -def test_package_dir_inside_package(capsys: CaptureFixture[str]) -> None: + assert import_data.import_string == "package:app" + + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "package" + + +def test_package_dir_inside_package() -> None: with changing_dir(assets_path / "package/mod"): - import_string = get_import_string(path=Path("../")) - assert import_string == "package:app" + import_data = get_import_data(path=Path("../")) - captured = capsys.readouterr() - assert "Using path .." in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/package" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 package" in captured.out - assert "│ └── 🐍 __init__.py" in captured.out - assert "Importing module package" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from package import app" in captured.out - assert "Using import string package:app" in captured.out - - -def test_package_dir_above_package(capsys: CaptureFixture[str]) -> None: + assert import_data.import_string == "package:app" + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "package" + + +def test_package_dir_above_package() -> None: with changing_dir(assets_path.parent): - import_string = get_import_string(path=Path("assets/package")) - assert import_string == "package:app" + import_data = get_import_data(path=Path("assets/package")) - captured = capsys.readouterr() - assert "Using path assets/package" in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/package" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 package" in captured.out - assert "│ └── 🐍 __init__.py" in captured.out - assert "Importing module package" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from package import app" in captured.out - assert "Using import string package:app" in captured.out - - -def test_package_dir_explicit_app(capsys: CaptureFixture[str]) -> None: + assert import_data.import_string == "package:app" + + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "package" + + +def test_package_dir_explicit_app() -> None: with changing_dir(assets_path): - import_string = get_import_string(path=Path("package"), app_name="api") - assert import_string == "package:api" + import_data = get_import_data(path=Path("package"), app_name="api") - captured = capsys.readouterr() - assert "Using path package" in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/package" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭─ Python package file structure ─╮" in captured.out - assert "│ 📁 package" in captured.out - assert "│ └── 🐍 __init__.py" in captured.out - assert "Importing module package" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from package import api" in captured.out - assert "Using import string package:api" in captured.out + assert import_data.import_string == "package:api" + + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "package" def test_broken_package_dir(capsys: CaptureFixture[str]) -> None: with changing_dir(assets_path): # TODO (when deprecating Python 3.8): remove ValueError with pytest.raises((ImportError, ValueError)): - get_import_string(path=Path("broken_package/mod/app.py")) + get_import_data(path=Path("broken_package/mod/app.py")) captured = capsys.readouterr() assert "Import error:" in captured.out @@ -430,7 +172,7 @@ def test_broken_package_dir(capsys: CaptureFixture[str]) -> None: def test_package_dir_no_app() -> None: with changing_dir(assets_path): with pytest.raises(FastAPICLIException) as e: - get_import_string(path=Path("package/core/utils.py")) + get_import_data(path=Path("package/core/utils.py")) assert ( "Could not find FastAPI app in module, try using --app" in e.value.args[0] ) @@ -439,7 +181,7 @@ def test_package_dir_no_app() -> None: def test_package_dir_object_not_an_app() -> None: with changing_dir(assets_path): with pytest.raises(FastAPICLIException) as e: - get_import_string( + get_import_data( path=Path("package/core/utils.py"), app_name="get_hello_world" ) assert ( @@ -451,5 +193,5 @@ def test_package_dir_object_not_an_app() -> None: def test_package_dir_object_app_name_not_found() -> None: with changing_dir(assets_path): with pytest.raises(FastAPICLIException) as e: - get_import_string(path=Path("package"), app_name="non_existent_app") + get_import_data(path=Path("package"), app_name="non_existent_app") assert "Could not find app name non_existent_app in package" in e.value.args[0] diff --git a/tests/test_utils_single_file.py b/tests/test_utils_single_file.py index 6395b32..3f5adde 100644 --- a/tests/test_utils_single_file.py +++ b/tests/test_utils_single_file.py @@ -1,7 +1,7 @@ from pathlib import Path import pytest -from fastapi_cli.discover import get_import_string +from fastapi_cli.discover import get_import_data from fastapi_cli.exceptions import FastAPICLIException from pytest import CaptureFixture @@ -12,104 +12,48 @@ def test_single_file_app(capsys: CaptureFixture[str]) -> None: with changing_dir(assets_path): - import_string = get_import_string(path=Path("single_file_app.py")) - assert import_string == "single_file_app:app" - - captured = capsys.readouterr() - assert "Using path single_file_app.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "/tests/assets/single_file_app.py" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭── Python module file ───╮" in captured.out - assert "│ 🐍 single_file_app.py" in captured.out - assert "Importing module single_file_app" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from single_file_app import app" in captured.out - assert "Using import string single_file_app:app" in captured.out + import_data = get_import_data(path=Path("single_file_app.py")) + + assert import_data.import_string == "single_file_app:app" + + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "single_file_app" def test_single_file_api(capsys: CaptureFixture[str]) -> None: with changing_dir(assets_path): - import_string = get_import_string(path=Path("single_file_api.py")) - assert import_string == "single_file_api:api" - - captured = capsys.readouterr() - assert "Using path single_file_api.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/single_file_api.py" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭── Python module file ───╮" in captured.out - assert "│ 🐍 single_file_api.py" in captured.out - assert "Importing module single_file_api" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from single_file_api import api" in captured.out - assert "Using import string single_file_api:api" in captured.out + import_data = get_import_data(path=Path("single_file_api.py")) + + assert import_data.import_string == "single_file_api:api" + + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "single_file_api" def test_single_file_other(capsys: CaptureFixture[str]) -> None: with changing_dir(assets_path): - import_string = get_import_string(path=Path("single_file_other.py")) - assert import_string == "single_file_other:first_other" - - captured = capsys.readouterr() - assert "Using path single_file_other.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/single_file_other.py" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭─── Python module file ────╮" in captured.out - assert "│ 🐍 single_file_other.py" in captured.out - assert "Importing module single_file_other" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from single_file_other import first_other" in captured.out - assert "Using import string single_file_other:first_other" in captured.out + import_data = get_import_data(path=Path("single_file_other.py")) + + assert import_data.import_string == "single_file_other:first_other" + + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "single_file_other" def test_single_file_explicit_object(capsys: CaptureFixture[str]) -> None: with changing_dir(assets_path): - import_string = get_import_string( + import_data = get_import_data( path=Path("single_file_app.py"), app_name="second_other" ) - assert import_string == "single_file_app:second_other" - - captured = capsys.readouterr() - assert "Using path single_file_app.py" in captured.out - assert "Resolved absolute path" in captured.out - assert "tests/assets/single_file_app.py" in captured.out - assert ( - "Searching for package file structure from directories with __init__.py files" - in captured.out - ) - assert "Importing from" in captured.out - assert "tests/assets" in captured.out - assert "╭── Python module file ───╮" in captured.out - assert "│ 🐍 single_file_app.py" in captured.out - assert "Importing module single_file_app" in captured.out - assert "Found importable FastAPI app" in captured.out - assert "Importable FastAPI app" in captured.out - assert "from single_file_app import second_other" in captured.out - assert "Using import string single_file_app:second_other" in captured.out + + assert import_data.import_string == "single_file_app:second_other" + + assert import_data.module_data.extra_sys_path == assets_path + assert import_data.module_data.module_import_str == "single_file_app" def test_single_non_existing_file() -> None: with changing_dir(assets_path): with pytest.raises(FastAPICLIException) as e: - get_import_string(path=assets_path / "non_existing.py") + get_import_data(path=assets_path / "non_existing.py") assert "Path does not exist" in e.value.args[0] From e11a0fb90940b48934262e3c849773b24e135360 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Mon, 23 Sep 2024 09:29:22 +0100 Subject: [PATCH 2/8] Improve error --- src/fastapi_cli/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index 12cebb7..d83d0ca 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -100,7 +100,8 @@ def _run( try: import_data = get_import_data(path=path, app_name=app) except FastAPICLIException as e: - logger.error(str(e)) + toolkit.print_line() + toolkit.print(f"[error]{e}") raise typer.Exit(code=1) from None logger.debug(f"Importing from {import_data.module_data.extra_sys_path}") From 44e471fda784394c7628fd979b1ad72b2ae3e719 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Mon, 23 Sep 2024 13:55:59 +0200 Subject: [PATCH 3/8] Tests for logger --- src/fastapi_cli/utils/cli.py | 3 ++- tests/test_utils_cli.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 tests/test_utils_cli.py diff --git a/src/fastapi_cli/utils/cli.py b/src/fastapi_cli/utils/cli.py index 573f339..96759ef 100644 --- a/src/fastapi_cli/utils/cli.py +++ b/src/fastapi_cli/utils/cli.py @@ -12,7 +12,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.toolkit = get_rich_toolkit() def formatMessage(self, record: logging.LogRecord) -> str: - return self.toolkit.print_as_string(record.message, tag=record.levelname) + return self.toolkit.print_as_string(record.getMessage(), tag=record.levelname) def get_uvicorn_log_config() -> Dict[str, Any]: @@ -68,6 +68,7 @@ def get_rich_toolkit() -> RichToolkit: "result": "grey85", "progress": "on #007166", "error": "red", + "log.info": "black on blue", }, ) diff --git a/tests/test_utils_cli.py b/tests/test_utils_cli.py new file mode 100644 index 0000000..8a38c12 --- /dev/null +++ b/tests/test_utils_cli.py @@ -0,0 +1,35 @@ +import logging + +from fastapi_cli.utils.cli import CustomFormatter, get_uvicorn_log_config + + +def test_get_uvicorn_config_uses_custom_formatter() -> None: + config = get_uvicorn_log_config() + + assert config["formatters"]["default"]["()"] is CustomFormatter + assert config["formatters"]["access"]["()"] is CustomFormatter + + +def test_custom_formatter() -> None: + formatter = CustomFormatter() + + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname="", + lineno=0, + msg="%(client_addr)s - '%(request_line)s' %(status_code)s", + args={ + "client_addr": "127.0.0.1", + "request_line": "GET / HTTP/1.1", + "status_code": 200, + }, + exc_info=None, + ) + + formatted = formatter.formatMessage(record) + + assert "INFO" in formatted + assert "127.0.0.1" in formatted + assert "GET / HTTP/1.1" in formatted + assert "200" in formatted From bf2c97210e7b65f536d64e5905259b7e82886165 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Mon, 23 Sep 2024 16:32:31 +0200 Subject: [PATCH 4/8] Improve coverage --- tests/assets/nested_package/__init__.py | 0 .../assets/nested_package/package/__init__.py | 2 ++ .../nested_package/package/core/__init__.py | 0 .../nested_package/package/core/utils.py | 2 ++ .../nested_package/package/mod/__init__.py | 1 + .../assets/nested_package/package/mod/api.py | 24 +++++++++++++ .../assets/nested_package/package/mod/app.py | 32 +++++++++++++++++ .../nested_package/package/mod/other.py | 16 +++++++++ tests/test_cli.py | 34 +++++++++++++++++++ 9 files changed, 111 insertions(+) create mode 100644 tests/assets/nested_package/__init__.py create mode 100644 tests/assets/nested_package/package/__init__.py create mode 100644 tests/assets/nested_package/package/core/__init__.py create mode 100644 tests/assets/nested_package/package/core/utils.py create mode 100644 tests/assets/nested_package/package/mod/__init__.py create mode 100644 tests/assets/nested_package/package/mod/api.py create mode 100644 tests/assets/nested_package/package/mod/app.py create mode 100644 tests/assets/nested_package/package/mod/other.py diff --git a/tests/assets/nested_package/__init__.py b/tests/assets/nested_package/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/assets/nested_package/package/__init__.py b/tests/assets/nested_package/package/__init__.py new file mode 100644 index 0000000..71c0c84 --- /dev/null +++ b/tests/assets/nested_package/package/__init__.py @@ -0,0 +1,2 @@ +from .mod.api import api as api +from .mod.app import app as app diff --git a/tests/assets/nested_package/package/core/__init__.py b/tests/assets/nested_package/package/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/assets/nested_package/package/core/utils.py b/tests/assets/nested_package/package/core/utils.py new file mode 100644 index 0000000..851594d --- /dev/null +++ b/tests/assets/nested_package/package/core/utils.py @@ -0,0 +1,2 @@ +def get_hello_world() -> str: + return "Hello World" diff --git a/tests/assets/nested_package/package/mod/__init__.py b/tests/assets/nested_package/package/mod/__init__.py new file mode 100644 index 0000000..959cefd --- /dev/null +++ b/tests/assets/nested_package/package/mod/__init__.py @@ -0,0 +1 @@ +from .app import app as app diff --git a/tests/assets/nested_package/package/mod/api.py b/tests/assets/nested_package/package/mod/api.py new file mode 100644 index 0000000..38b262d --- /dev/null +++ b/tests/assets/nested_package/package/mod/api.py @@ -0,0 +1,24 @@ +from fastapi import FastAPI + +first_other = FastAPI() + + +@first_other.get("/") +def first_other_root(): + return {"message": "package first_other"} + + +second_other = FastAPI() + + +@second_other.get("/") +def second_other_root(): + return {"message": "package second_other"} + + +api = FastAPI() + + +@api.get("/") +def api_root(): + return {"message": "package api"} diff --git a/tests/assets/nested_package/package/mod/app.py b/tests/assets/nested_package/package/mod/app.py new file mode 100644 index 0000000..ef9572e --- /dev/null +++ b/tests/assets/nested_package/package/mod/app.py @@ -0,0 +1,32 @@ +from fastapi import FastAPI + +first_other = FastAPI() + + +@first_other.get("/") +def first_other_root(): + return {"message": "package first_other"} + + +second_other = FastAPI() + + +@second_other.get("/") +def second_other_root(): + return {"message": "package second_other"} + + +api = FastAPI() + + +@api.get("/") +def api_root(): + return {"message": "package api"} + + +app = FastAPI() + + +@app.get("/") +def app_root(): + return {"message": "package app"} diff --git a/tests/assets/nested_package/package/mod/other.py b/tests/assets/nested_package/package/mod/other.py new file mode 100644 index 0000000..84a1a15 --- /dev/null +++ b/tests/assets/nested_package/package/mod/other.py @@ -0,0 +1,16 @@ +from fastapi import FastAPI + +first_other = FastAPI() + + +@first_other.get("/") +def first_other_root(): + return {"message": "package first_other"} + + +second_other = FastAPI() + + +@second_other.get("/") +def second_other_root(): + return {"message": "package second_other"} diff --git a/tests/test_cli.py b/tests/test_cli.py index 035b655..5352886 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -41,6 +41,40 @@ def test_dev() -> None: in result.output ) + assert "🐍 single_file_app.py" in result.output + + +def test_dev_package() -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke(app, ["dev", "nested_package/package"]) + assert result.exit_code == 0, result.output + assert mock_run.called + assert mock_run.call_args + assert mock_run.call_args.kwargs == { + "app": "nested_package.package:app", + "host": "127.0.0.1", + "port": 8000, + "reload": True, + "workers": None, + "root_path": "", + "proxy_headers": True, + "log_config": get_uvicorn_log_config(), + } + assert "Using import string: nested_package.package:app" in result.output + assert "Starting development server 🚀" in result.output + assert "Server started at https://127.0.0.1:8000" in result.output + assert "Documentation at https://127.0.0.1:8000/docs" in result.output + assert ( + "Running in development mode, for production use: fastapi run" + in result.output + ) + + assert "📁 package" in result.output + assert "└── 🐍 __init__.py" in result.output + assert "└── 📁 package" in result.output + assert " └── 🐍 __init__.py" in result.output + def test_dev_args() -> None: with changing_dir(assets_path): From 3aebbe02484841efce7014112600b7593b77d823 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Tue, 3 Dec 2024 11:44:45 +0000 Subject: [PATCH 5/8] Fix https --- tests/test_cli.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5352886..9d8b9ae 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -34,8 +34,8 @@ def test_dev() -> None: } assert "Using import string: single_file_app:app" in result.output assert "Starting development server 🚀" in result.output - assert "Server started at https://127.0.0.1:8000" in result.output - assert "Documentation at https://127.0.0.1:8000/docs" in result.output + assert "Server started at http://127.0.0.1:8000" in result.output + assert "Documentation at http://127.0.0.1:8000/docs" in result.output assert ( "Running in development mode, for production use: fastapi run" in result.output @@ -63,8 +63,8 @@ def test_dev_package() -> None: } assert "Using import string: nested_package.package:app" in result.output assert "Starting development server 🚀" in result.output - assert "Server started at https://127.0.0.1:8000" in result.output - assert "Documentation at https://127.0.0.1:8000/docs" in result.output + assert "Server started at http://127.0.0.1:8000" in result.output + assert "Documentation at http://127.0.0.1:8000/docs" in result.output assert ( "Running in development mode, for production use: fastapi run" in result.output @@ -111,8 +111,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 https://192.168.0.2:8080" in result.output - assert "Documentation at https://192.168.0.2:8080/docs" 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 ( "Running in development mode, for production use: fastapi run" in result.output @@ -138,8 +138,8 @@ def test_run() -> None: } assert "Using import string: single_file_app:app" in result.output assert "Starting production server 🚀" in result.output - assert "Server started at https://0.0.0.0:8000" in result.output - assert "Documentation at https://0.0.0.0:8000/docs" in result.output + assert "Server started at http://0.0.0.0:8000" in result.output + assert "Documentation at http://0.0.0.0:8000/docs" in result.output assert ( "Running in development mode, for production use: fastapi run" not in result.output @@ -184,8 +184,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 https://192.168.0.2:8080" in result.output - assert "Documentation at https://192.168.0.2:8080/docs" 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 ( "Running in development mode, for production use: fastapi run" not in result.output From b8d072e50ff9180b4ffc82206df03f11384fe2ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Tue, 3 Dec 2024 21:30:16 +0100 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=93=9D=20Update=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 67 ++++++++++++++++++++++--------------------------------- 1 file changed, 27 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 63a4f40..a003e88 100644 --- a/README.md +++ b/README.md @@ -32,46 +32,33 @@ To run your FastAPI app for development, you can use the `fastapi dev` command: ```console $ fastapi dev main.py -INFO Using path main.py -INFO Resolved absolute path /home/user/code/awesomeapp/main.py -INFO Searching for package file structure from directories with __init__.py files -INFO Importing from /home/user/code/awesomeapp - - ╭─ Python module file ─╮ - │ │ - │ 🐍 main.py │ - │ │ - ╰──────────────────────╯ - -INFO Importing module main -INFO Found importable FastAPI app - - ╭─ Importable FastAPI app ─╮ - │ │ - │ from main import app │ - │ │ - ╰──────────────────────────╯ - -INFO Using import string main:app - - ╭────────── FastAPI CLI - Development mode ───────────╮ - │ │ - │ Serving at: http://127.0.0.1:8000 │ - │ │ - │ API docs: http://127.0.0.1:8000/docs │ - │ │ - │ Running in development mode, for production use: │ - │ │ - │ fastapi run │ - │ │ - ╰─────────────────────────────────────────────────────╯ - -INFO: Will watch for changes in these directories: ['/home/user/code/awesomeapp'] -INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) -INFO: Started reloader process [56345] using WatchFiles -INFO: Started server process [56352] -INFO: Waiting for application startup. -INFO: Application startup complete. + + FastAPI Starting development server 🚀 + + Searching for package file structure from directories with __init__.py files + Importing from /home/user/code/fastapi-cli + + module 🐍 main.py + + code Importing the FastAPI app object from the module with the following code: + + from main import app + + app Using import string: main:app + + server Server started at http://127.0.0.1:8000 + server Documentation at http://127.0.0.1:8000/docs + + tip Running in development mode, for production use: fastapi run + + Logs: + + INFO Will watch for changes in these directories: ['/home/user/code/fastapi-cli'] + INFO Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) + INFO Started reloader process [4106097] using WatchFiles + INFO Started server process [4106120] + INFO Waiting for application startup. + INFO Application startup complete. ``` From 8128a119bd796f8df5593e5fca64c4dc32ac76cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Tue, 3 Dec 2024 21:32:34 +0100 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=93=9D=20Update=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a003e88..6fc80e1 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ $ fastapi dev main.py FastAPI Starting development server 🚀 Searching for package file structure from directories with __init__.py files - Importing from /home/user/code/fastapi-cli + Importing from /home/user/code/awesomeapp module 🐍 main.py @@ -53,7 +53,7 @@ $ fastapi dev main.py Logs: - INFO Will watch for changes in these directories: ['/home/user/code/fastapi-cli'] + INFO Will watch for changes in these directories: ['/home/user/code/awesomeapp'] INFO Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) INFO Started reloader process [4106097] using WatchFiles INFO Started server process [4106120] From 01c4c337a1193c0cc6b723720efe05a09b9235a8 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Wed, 4 Dec 2024 11:44:20 +0000 Subject: [PATCH 8/8] Bump rich toolkit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 58c475a..4852187 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ classifiers = [ dependencies = [ "typer >= 0.12.3", "uvicorn[standard] >= 0.15.0", - "rich-toolkit >= 0.7.0", + "rich-toolkit >= 0.11.1" ] [project.optional-dependencies]