From d4b144c7f8c601ec398bde3e016e3180ba9ed9e8 Mon Sep 17 00:00:00 2001 From: "Michael \"M3\" Lasevich" Date: Wed, 18 Sep 2024 15:10:02 -0400 Subject: [PATCH 01/12] Add schema command --- README.md | 6 ++++ src/fastapi_cli/cli.py | 40 +++++++++++++++++++++++ src/fastapi_cli/discover.py | 64 +++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 9 ++++++ 4 files changed, 119 insertions(+) diff --git a/README.md b/README.md index 63a4f40..ba14ebe 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,12 @@ It will listen on the IP address `0.0.0.0`, which means all the available IP add In most cases you would (and should) have a "termination proxy" handling HTTPS for you on top, this will depend on how you deploy your application, your provider might do this for you, or you might need to set it up yourself. You can learn more about it in the FastAPI Deployment documentation. +## `fastapi schema` + +When you run `fastapi schema`, it will generate a swagger/openapi document. + +This document will be output to stderr by default, however `--output ` option can be used to write output into file. You can control the format of the JSON file by specifying indent level with `--indent #`. If set to 0, JSON will be in the minimal/compress form. Default is 2 spaces. + ## License This project is licensed under the terms of the MIT license. diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index d5bcb8e..50435f9 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -1,3 +1,5 @@ +import json +import sys from logging import getLogger from pathlib import Path from typing import Any, Union @@ -9,6 +11,7 @@ from typing_extensions import Annotated from fastapi_cli.discover import get_import_string +from fastapi_cli.discover import get_app from fastapi_cli.exceptions import FastAPICLIException from . import __version__ @@ -272,6 +275,43 @@ def run( proxy_headers=proxy_headers, ) +@app.command() +def schema( + path: Annotated[ + Union[Path, None], + typer.Argument( + help="A path to a Python file or package directory (with [blue]__init__.py[/blue] files) containing a [bold]FastAPI[/bold] app. If not provided, a default set of paths will be tried." + ), + ] = None, + *, + app: Annotated[ + Union[str, None], + typer.Option( + help="The name of the variable that contains the [bold]FastAPI[/bold] app in the imported module or package. If not provided, it is detected automatically." + ), + ] = None, + output: Annotated[ + Union[str, None], + typer.Option( + help="The filename to write schema to. If not provided, write to stderr." + ), + ] = None, + indent: Annotated[ + int, + typer.Option( + help="JSON format indent. If 0, disable pretty printing" + ), + ] = 2, + ) -> Any: + """ Generate schema """ + app = get_app(path=path, app_name=app) + schema = app.openapi() + + stream = open(output, "w") if output else sys.stderr + json.dump(schema, stream, indent=indent if indent > 0 else None) + if output: + stream.close() + return 0 def main() -> None: app() diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index f442438..3bee848 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -1,5 +1,6 @@ import importlib import sys +from contextlib import contextmanager from dataclasses import dataclass from logging import getLogger from pathlib import Path @@ -46,6 +47,18 @@ class ModuleData: module_import_str: str extra_sys_path: Path + @contextmanager + def sys_path(self): + """ Context manager to temporarily alter sys.path""" + extra_sys_path = str(self.extra_sys_path) if self.extra_sys_path else "" + if extra_sys_path: + logger.warning("Adding %s to sys.path...", extra_sys_path) + sys.path.insert(0, extra_sys_path) + yield + if extra_sys_path and sys.path and sys.path[0] == extra_sys_path: + logger.warning("Removing %s from sys.path...", extra_sys_path) + sys.path.pop(0) + def get_module_data_from_path(path: Path) -> ModuleData: logger.info( @@ -165,3 +178,54 @@ def get_import_string( 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 + +def get_app( + *, path: Union[Path, None] = None, app_name: Union[str, None] = None +) -> FastAPI: + if not path: + path = get_default_path() + 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) + try: + with mod_data.sys_path(): + mod = importlib.import_module(mod_data.module_import_str) + except (ImportError, ValueError) as e: + logger.error(f"Import error: {e}") + logger.warning( + "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 " + f"{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} " + f"doesn't seem to be a FastAPI app" + ) + return app + for preferred_name in ["app", "api"]: + if preferred_name in object_names_set: + obj = getattr(mod, preferred_name) + if isinstance(obj, FastAPI): + return obj + for name in object_names: + obj = getattr(mod, name) + if isinstance(obj, FastAPI): + return obj + raise FastAPICLIException( + "Could not find FastAPI app in module, try using --app") diff --git a/tests/test_cli.py b/tests/test_cli.py index 44c14d2..87d4942 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -179,6 +179,15 @@ def test_dev_help() -> None: assert "The name of the variable that contains the FastAPI app" in result.output assert "Use multiple worker processes." not in result.output +def test_schema() -> None: + with changing_dir(assets_path): + with open('openapi.json', 'r') as stream: + expected = stream.read() + assert expected != "" , "Failed to read expected result" + result = runner.invoke(app, ["schema", "single_file_app.py"]) + assert result.exit_code == 0, result.output + assert expected in result.output, result.output + def test_run_help() -> None: result = runner.invoke(app, ["run", "--help"]) From d228f308c83f2c7f2792eff2dba7eb854165b265 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 18 Sep 2024 19:13:02 +0000 Subject: [PATCH 02/12] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/fastapi_cli/cli.py | 57 ++++++++++++++++++------------------- src/fastapi_cli/discover.py | 8 +++--- tests/test_cli.py | 7 +++-- 4 files changed, 37 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index ba14ebe..8be3476 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ In most cases you would (and should) have a "termination proxy" handling HTTPS f When you run `fastapi schema`, it will generate a swagger/openapi document. -This document will be output to stderr by default, however `--output ` option can be used to write output into file. You can control the format of the JSON file by specifying indent level with `--indent #`. If set to 0, JSON will be in the minimal/compress form. Default is 2 spaces. +This document will be output to stderr by default, however `--output ` option can be used to write output into file. You can control the format of the JSON file by specifying indent level with `--indent #`. If set to 0, JSON will be in the minimal/compress form. Default is 2 spaces. ## License diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index 50435f9..72ac354 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -10,8 +10,7 @@ from rich.panel import Panel from typing_extensions import Annotated -from fastapi_cli.discover import get_import_string -from fastapi_cli.discover import get_app +from fastapi_cli.discover import get_app, get_import_string from fastapi_cli.exceptions import FastAPICLIException from . import __version__ @@ -275,35 +274,34 @@ def run( proxy_headers=proxy_headers, ) + @app.command() def schema( - path: Annotated[ - Union[Path, None], - typer.Argument( - help="A path to a Python file or package directory (with [blue]__init__.py[/blue] files) containing a [bold]FastAPI[/bold] app. If not provided, a default set of paths will be tried." - ), - ] = None, - *, - app: Annotated[ - Union[str, None], - typer.Option( - help="The name of the variable that contains the [bold]FastAPI[/bold] app in the imported module or package. If not provided, it is detected automatically." - ), - ] = None, - output: Annotated[ - Union[str, None], - typer.Option( - help="The filename to write schema to. If not provided, write to stderr." - ), - ] = None, - indent: Annotated[ - int, - typer.Option( - help="JSON format indent. If 0, disable pretty printing" - ), - ] = 2, - ) -> Any: - """ Generate schema """ + path: Annotated[ + Union[Path, None], + typer.Argument( + help="A path to a Python file or package directory (with [blue]__init__.py[/blue] files) containing a [bold]FastAPI[/bold] app. If not provided, a default set of paths will be tried." + ), + ] = None, + *, + app: Annotated[ + Union[str, None], + typer.Option( + help="The name of the variable that contains the [bold]FastAPI[/bold] app in the imported module or package. If not provided, it is detected automatically." + ), + ] = None, + output: Annotated[ + Union[str, None], + typer.Option( + help="The filename to write schema to. If not provided, write to stderr." + ), + ] = None, + indent: Annotated[ + int, + typer.Option(help="JSON format indent. If 0, disable pretty printing"), + ] = 2, +) -> Any: + """Generate schema""" app = get_app(path=path, app_name=app) schema = app.openapi() @@ -313,5 +311,6 @@ def schema( stream.close() return 0 + def main() -> None: app() diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index 3bee848..0fa5d23 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -49,7 +49,7 @@ class ModuleData: @contextmanager def sys_path(self): - """ Context manager to temporarily alter sys.path""" + """Context manager to temporarily alter sys.path""" extra_sys_path = str(self.extra_sys_path) if self.extra_sys_path else "" if extra_sys_path: logger.warning("Adding %s to sys.path...", extra_sys_path) @@ -179,8 +179,9 @@ def get_import_string( logger.info(f"Using import string [b green]{import_string}[/b green]") return import_string + def get_app( - *, path: Union[Path, None] = None, app_name: Union[str, None] = None + *, path: Union[Path, None] = None, app_name: Union[str, None] = None ) -> FastAPI: if not path: path = get_default_path() @@ -227,5 +228,4 @@ def get_app( obj = getattr(mod, name) if isinstance(obj, FastAPI): return obj - raise FastAPICLIException( - "Could not find FastAPI app in module, try using --app") + raise FastAPICLIException("Could not find FastAPI app in module, try using --app") diff --git a/tests/test_cli.py b/tests/test_cli.py index 87d4942..2ffab85 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -179,14 +179,15 @@ def test_dev_help() -> None: assert "The name of the variable that contains the FastAPI app" in result.output assert "Use multiple worker processes." not in result.output + def test_schema() -> None: with changing_dir(assets_path): - with open('openapi.json', 'r') as stream: + with open("openapi.json") as stream: expected = stream.read() - assert expected != "" , "Failed to read expected result" + assert expected != "", "Failed to read expected result" result = runner.invoke(app, ["schema", "single_file_app.py"]) assert result.exit_code == 0, result.output - assert expected in result.output, result.output + assert expected in result.output, result.output def test_run_help() -> None: From 1aebb9e7322cd99d03e673642eae5237b49a454a Mon Sep 17 00:00:00 2001 From: "Michael \"M3\" Lasevich" Date: Wed, 18 Sep 2024 15:21:19 -0400 Subject: [PATCH 03/12] lint fixes --- src/fastapi_cli/cli.py | 4 ++-- src/fastapi_cli/discover.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index 50435f9..a675558 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -304,8 +304,8 @@ def schema( ] = 2, ) -> Any: """ Generate schema """ - app = get_app(path=path, app_name=app) - schema = app.openapi() + fastapi_app = get_app(path=path, app_name=app) + schema = fastapi_app.openapi() stream = open(output, "w") if output else sys.stderr json.dump(schema, stream, indent=indent if indent > 0 else None) diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index 3bee848..95c1b13 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -48,13 +48,13 @@ class ModuleData: extra_sys_path: Path @contextmanager - def sys_path(self): + def sys_path(self) -> str: """ Context manager to temporarily alter sys.path""" extra_sys_path = str(self.extra_sys_path) if self.extra_sys_path else "" if extra_sys_path: logger.warning("Adding %s to sys.path...", extra_sys_path) sys.path.insert(0, extra_sys_path) - yield + yield extra_sys_path if extra_sys_path and sys.path and sys.path[0] == extra_sys_path: logger.warning("Removing %s from sys.path...", extra_sys_path) sys.path.pop(0) From 242b2897700141f22c0b617fc09af304fb65010c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 18 Sep 2024 19:28:19 +0000 Subject: [PATCH 04/12] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/fastapi_cli/cli.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index 307d82f..25b1a12 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -298,12 +298,10 @@ def schema( ] = None, indent: Annotated[ int, - typer.Option( - help="JSON format indent. If 0, disable pretty printing" - ), - ] = 2, - ) -> Any: - """ Generate schema """ + typer.Option(help="JSON format indent. If 0, disable pretty printing"), + ] = 2, +) -> Any: + """Generate schema""" fastapi_app = get_app(path=path, app_name=app) schema = fastapi_app.openapi() From 2ac74a457185f9de5bdb3069d5405a8657ae27b0 Mon Sep 17 00:00:00 2001 From: "Michael \"M3\" Lasevich" Date: Thu, 19 Sep 2024 09:50:00 -0400 Subject: [PATCH 05/12] lint fixes --- src/fastapi_cli/discover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index 3cde7d5..309a7a0 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from logging import getLogger from pathlib import Path -from typing import Union +from typing import Union, Generator from rich import print from rich.padding import Padding @@ -48,7 +48,7 @@ class ModuleData: extra_sys_path: Path @contextmanager - def sys_path(self) -> str: + def sys_path(self) -> Generator: """Context manager to temporarily alter sys.path""" extra_sys_path = str(self.extra_sys_path) if self.extra_sys_path else "" if extra_sys_path: From 3967675627270c5c56ef7414ebecbb12b461914e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Sep 2024 13:50:11 +0000 Subject: [PATCH 06/12] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/fastapi_cli/discover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index 309a7a0..88a6a9b 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from logging import getLogger from pathlib import Path -from typing import Union, Generator +from typing import Generator, Union from rich import print from rich.padding import Padding From d11bd79ac3f227d4ea2bda7de41d18b38b49d95d Mon Sep 17 00:00:00 2001 From: "Michael \"M3\" Lasevich" Date: Thu, 19 Sep 2024 09:56:16 -0400 Subject: [PATCH 07/12] lint fixes --- src/fastapi_cli/discover.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index 88a6a9b..ac72118 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from logging import getLogger from pathlib import Path -from typing import Generator, Union +from typing import Iterator, Union from rich import print from rich.padding import Padding @@ -48,8 +48,8 @@ class ModuleData: extra_sys_path: Path @contextmanager - def sys_path(self) -> Generator: - """Context manager to temporarily alter sys.path""" + def sys_path(self) -> Iterator[str]: + """Context manaxger to temporarily alter sys.path""" extra_sys_path = str(self.extra_sys_path) if self.extra_sys_path else "" if extra_sys_path: logger.warning("Adding %s to sys.path...", extra_sys_path) From 93f5ac16189ac877b086bc3979f9c89ce0b3a4e1 Mon Sep 17 00:00:00 2001 From: "Michael \"M3\" Lasevich" Date: Thu, 19 Sep 2024 10:38:51 -0400 Subject: [PATCH 08/12] Add missing unit-test file --- tests/assets/openapi.json | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/assets/openapi.json diff --git a/tests/assets/openapi.json b/tests/assets/openapi.json new file mode 100644 index 0000000..c3daefe --- /dev/null +++ b/tests/assets/openapi.json @@ -0,0 +1,25 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "FastAPI", + "version": "0.1.0" + }, + "paths": { + "/": { + "get": { + "summary": "App Root", + "operationId": "app_root__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + } + } +} \ No newline at end of file From 02b1eaa140d4574b8ba948ff2572b420d16a626a Mon Sep 17 00:00:00 2001 From: "Michael \"M3\" Lasevich" Date: Thu, 19 Sep 2024 14:58:59 -0400 Subject: [PATCH 09/12] cleanup logging --- src/fastapi_cli/discover.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index ac72118..20ba734 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -49,14 +49,14 @@ class ModuleData: @contextmanager def sys_path(self) -> Iterator[str]: - """Context manaxger to temporarily alter sys.path""" + """Context manager to temporarily alter sys.path""" extra_sys_path = str(self.extra_sys_path) if self.extra_sys_path else "" if extra_sys_path: - logger.warning("Adding %s to sys.path...", extra_sys_path) + logger.debug("Adding %s to sys.path...", extra_sys_path) sys.path.insert(0, extra_sys_path) yield extra_sys_path if extra_sys_path and sys.path and sys.path[0] == extra_sys_path: - logger.warning("Removing %s from sys.path...", extra_sys_path) + logger.debug("Removing %s from sys.path...", extra_sys_path) sys.path.pop(0) From 340e0a90f7bd503b280c34056e04768ba64bcd37 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Sep 2024 19:00:43 +0000 Subject: [PATCH 10/12] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/assets/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/assets/openapi.json b/tests/assets/openapi.json index c3daefe..61506d7 100644 --- a/tests/assets/openapi.json +++ b/tests/assets/openapi.json @@ -22,4 +22,4 @@ } } } -} \ No newline at end of file +} From 3904b878d3aaf0610ae874cae2612d5d4b79878a Mon Sep 17 00:00:00 2001 From: "Michael \"M3\" Lasevich" Date: Thu, 19 Sep 2024 15:03:30 -0400 Subject: [PATCH 11/12] Fix auto-broken test data file :-) --- tests/test_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2ffab85..b73ff37 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -183,7 +183,7 @@ def test_dev_help() -> None: def test_schema() -> None: with changing_dir(assets_path): with open("openapi.json") as stream: - expected = stream.read() + expected = stream.read().strip() assert expected != "", "Failed to read expected result" result = runner.invoke(app, ["schema", "single_file_app.py"]) assert result.exit_code == 0, result.output From b68acd485926ba0b84534bda78f1705f1b31d8f2 Mon Sep 17 00:00:00 2001 From: "Michael \"M3\" Lasevich" Date: Thu, 19 Sep 2024 18:35:48 -0400 Subject: [PATCH 12/12] Refactor to reuse code and increase test coverage --- src/fastapi_cli/discover.py | 69 ++++++++++--------------------------- tests/test_cli.py | 44 +++++++++++++++++++++++ tests/test_utils_package.py | 13 ++++++- 3 files changed, 75 insertions(+), 51 deletions(-) diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index 20ba734..8d5651a 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from logging import getLogger from pathlib import Path -from typing import Iterator, Union +from typing import Iterator, Tuple, Union from rich import print from rich.padding import Padding @@ -149,9 +149,9 @@ 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( +def get_import_string_parts( *, path: Union[Path, None] = None, app_name: Union[str, None] = None -) -> str: +) -> Tuple[ModuleData, str]: if not path: path = get_default_path() logger.info(f"Using path [blue]{path}[/blue]") @@ -161,6 +161,15 @@ def get_import_string( 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) + + return mod_data, use_app_name + + +def get_import_string( + *, path: Union[Path, None] = None, app_name: Union[str, None] = None +) -> str: + mod_data, use_app_name = get_import_string_parts(path=path, app_name=app_name) + import_string = f"{mod_data.module_import_str}:{use_app_name}" import_example = Syntax( f"from {mod_data.module_import_str} import {use_app_name}", "python" ) @@ -175,7 +184,7 @@ def get_import_string( ) 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 @@ -183,49 +192,9 @@ def get_import_string( def get_app( *, path: Union[Path, None] = None, app_name: Union[str, None] = None ) -> FastAPI: - if not path: - path = get_default_path() - 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) - try: - with mod_data.sys_path(): - mod = importlib.import_module(mod_data.module_import_str) - except (ImportError, ValueError) as e: - logger.error(f"Import error: {e}") - logger.warning( - "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 " - f"{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} " - f"doesn't seem to be a FastAPI app" - ) - return app - for preferred_name in ["app", "api"]: - if preferred_name in object_names_set: - obj = getattr(mod, preferred_name) - if isinstance(obj, FastAPI): - return obj - for name in object_names: - obj = getattr(mod, name) - if isinstance(obj, FastAPI): - return obj - raise FastAPICLIException("Could not find FastAPI app in module, try using --app") + mod_data, use_app_name = get_import_string_parts(path=path, app_name=app_name) + with mod_data.sys_path(): + mod = importlib.import_module(mod_data.module_import_str) + app = getattr(mod, use_app_name) + ## get_import_string_parts guarantees app is FastAPI object + return app # type: ignore[no-any-return] diff --git a/tests/test_cli.py b/tests/test_cli.py index b73ff37..915937a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import os import subprocess import sys from pathlib import Path @@ -5,6 +6,7 @@ import uvicorn from fastapi_cli.cli import app +from fastapi_cli.exceptions import FastAPICLIException from typer.testing import CliRunner from tests.utils import changing_dir @@ -14,6 +16,13 @@ assets_path = Path(__file__).parent / "assets" +def read_file(filename: str, strip: bool = True) -> str: + """Read file and return content as string""" + with open("openapi.json") as stream: + data = stream.read() + return data.strip() if data and strip else data + + def test_dev() -> None: with changing_dir(assets_path): with patch.object(uvicorn, "run") as mock_run: @@ -190,6 +199,41 @@ def test_schema() -> None: assert expected in result.output, result.output +def test_schema_file() -> None: + with changing_dir(assets_path): + filename = "unit-test.json" + expected = read_file("openapi.json", strip=True) + assert expected != "", "Failed to read expected result" + result = runner.invoke( + app, ["schema", "single_file_app.py", "--output", filename] + ) + assert os.path.isfile(filename) + actual = read_file(filename, strip=True) + os.remove(filename) + assert result.exit_code == 0, result.output + assert expected == actual + + +def test_schema_invalid_path() -> None: + with changing_dir(assets_path): + result = runner.invoke(app, ["schema", "invalid/single_file_app.py"]) + assert result.exit_code == 1, result.output + assert isinstance(result.exception, FastAPICLIException) + assert "Path does not exist invalid/single_file_app.py" in str(result.exception) + + +# +# +# def test_schema_invalid_package() -> None: +# with changing_dir(assets_path): +# result = runner.invoke( +# app, ["schema", "broken_package/mod/app.py"] +# ) +# assert result.exit_code == 1, result.output +# assert isinstance(result.exception, ImportError) +# assert "attempted relative import beyond top-level package" in str(result.exception) + + def test_run_help() -> None: result = runner.invoke(app, ["run", "--help"]) assert result.exit_code == 0, result.output diff --git a/tests/test_utils_package.py b/tests/test_utils_package.py index d5573db..641a067 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_app, get_import_string from fastapi_cli.exceptions import FastAPICLIException from pytest import CaptureFixture @@ -427,6 +427,17 @@ def test_broken_package_dir(capsys: CaptureFixture[str]) -> None: assert "Ensure all the package directories have an __init__.py file" in captured.out +def test_get_app_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_app(path=Path("broken_package/mod/app.py")) + + captured = capsys.readouterr() + assert "Import error:" in captured.out + assert "Ensure all the package directories have an __init__.py file" in captured.out + + def test_package_dir_no_app() -> None: with changing_dir(assets_path): with pytest.raises(FastAPICLIException) as e: