Skip to content

Commit da44007

Browse files
author
Michael "M3" Lasevich
committed
Merge branch 'feature/add-schema-generation' into feature/merge
# Conflicts: # src/fastapi_cli/cli.py # src/fastapi_cli/discover.py # tests/test_cli.py # tests/test_utils_package.py
2 parents 1844ba1 + b68acd4 commit da44007

File tree

6 files changed

+239
-4
lines changed

6 files changed

+239
-4
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ It will listen on the IP address `0.0.0.0`, which means all the available IP add
8989

9090
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 <a href="https://fastapi.tiangolo.com/deployment/" class="external-link" target="_blank">FastAPI Deployment documentation</a>.
9191

92+
## `fastapi schema`
93+
94+
When you run `fastapi schema`, it will generate a swagger/openapi document.
95+
96+
This document will be output to stderr by default, however `--output <filename>` 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.
97+
9298
## License
9399

94100
This project is licensed under the terms of the MIT license.

src/fastapi_cli/cli.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import json
12
import logging
3+
import sys
4+
25
from pathlib import Path
36
from typing import Any, List, Union
47

@@ -7,7 +10,7 @@
710
from rich.tree import Tree
811
from typing_extensions import Annotated
912

10-
from fastapi_cli.discover import get_import_data
13+
from fastapi_cli.discover import get_app, get_import_string, get_import_data
1114
from fastapi_cli.exceptions import FastAPICLIException
1215

1316
from . import __version__
@@ -370,5 +373,42 @@ def run(
370373
)
371374

372375

376+
@app.command()
377+
def schema(
378+
path: Annotated[
379+
Union[Path, None],
380+
typer.Argument(
381+
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."
382+
),
383+
] = None,
384+
*,
385+
app: Annotated[
386+
Union[str, None],
387+
typer.Option(
388+
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."
389+
),
390+
] = None,
391+
output: Annotated[
392+
Union[str, None],
393+
typer.Option(
394+
help="The filename to write schema to. If not provided, write to stderr."
395+
),
396+
] = None,
397+
indent: Annotated[
398+
int,
399+
typer.Option(help="JSON format indent. If 0, disable pretty printing"),
400+
] = 2,
401+
) -> Any:
402+
"""Generate schema"""
403+
fastapi_app = get_app(path=path, app_name=app)
404+
schema = fastapi_app.openapi()
405+
406+
stream = open(output, "w") if output else sys.stderr
407+
json.dump(schema, stream, indent=indent if indent > 0 else None)
408+
if output:
409+
stream.close()
410+
return 0
411+
412+
373413
def main() -> None:
374414
app()

src/fastapi_cli/discover.py

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import importlib
22
import sys
3+
from contextlib import contextmanager
34
from dataclasses import dataclass
45
from logging import getLogger
56
from pathlib import Path
6-
from typing import List, Union
7+
from typing import Iterator, List, Tuple, Union
78

89
from fastapi_cli.exceptions import FastAPICLIException
910

@@ -41,6 +42,18 @@ class ModuleData:
4142
extra_sys_path: Path
4243
module_paths: List[Path]
4344

45+
@contextmanager
46+
def sys_path(self) -> Iterator[str]:
47+
"""Context manager to temporarily alter sys.path"""
48+
extra_sys_path = str(self.extra_sys_path) if self.extra_sys_path else ""
49+
if extra_sys_path:
50+
logger.debug("Adding %s to sys.path...", extra_sys_path)
51+
sys.path.insert(0, extra_sys_path)
52+
yield extra_sys_path
53+
if extra_sys_path and sys.path and sys.path[0] == extra_sys_path:
54+
logger.debug("Removing %s from sys.path...", extra_sys_path)
55+
sys.path.pop(0)
56+
4457

4558
def get_module_data_from_path(path: Path) -> ModuleData:
4659
use_path = path.resolve()
@@ -103,6 +116,8 @@ def get_app_name(*, mod_data: ModuleData, app_name: Union[str, None] = None) ->
103116
raise FastAPICLIException("Could not find FastAPI app in module, try using --app")
104117

105118

119+
# TODO: fix get_import_data vs get_import_string
120+
106121
@dataclass
107122
class ImportData:
108123
app_name: str
@@ -111,7 +126,7 @@ class ImportData:
111126

112127

113128
def get_import_data(
114-
*, path: Union[Path, None] = None, app_name: Union[str, None] = None
129+
*, path: Union[Path, None] = None, app_name: Union[str, None] = None
115130
) -> ImportData:
116131
if not path:
117132
path = get_default_path()
@@ -130,3 +145,85 @@ def get_import_data(
130145
return ImportData(
131146
app_name=use_app_name, module_data=mod_data, import_string=import_string
132147
)
148+
149+
def get_import_string(
150+
*, path: Union[Path, None] = None, app_name: Union[str, None] = None
151+
) -> str:
152+
if not path:
153+
path = get_default_path()
154+
155+
logger.debug(f"Using path [blue]{path}[/blue]")
156+
logger.debug(f"Resolved absolute path {path.resolve()}")
157+
158+
if not path.exists():
159+
raise FastAPICLIException(f"Path does not exist {path}")
160+
mod_data = get_module_data_from_path(path)
161+
sys.path.insert(0, str(mod_data.extra_sys_path))
162+
use_app_name = get_app_name(mod_data=mod_data, app_name=app_name)
163+
import_example = Syntax(
164+
f"from {mod_data.module_import_str} import {use_app_name}", "python"
165+
)
166+
import_panel = Padding(
167+
Panel(
168+
import_example,
169+
title="[b green]Importable FastAPI app[/b green]",
170+
expand=False,
171+
padding=(1, 2),
172+
),
173+
1,
174+
)
175+
logger.info("Found importable FastAPI app")
176+
print(import_panel)
177+
import_string = f"{mod_data.module_import_str}:{use_app_name}"
178+
logger.info(f"Using import string [b green]{import_string}[/b green]")
179+
return import_string
180+
181+
def get_import_string_parts(
182+
*, path: Union[Path, None] = None, app_name: Union[str, None] = None
183+
) -> Tuple[ModuleData, str]:
184+
if not path:
185+
path = get_default_path()
186+
logger.info(f"Using path [blue]{path}[/blue]")
187+
logger.info(f"Resolved absolute path {path.resolve()}")
188+
if not path.exists():
189+
raise FastAPICLIException(f"Path does not exist {path}")
190+
mod_data = get_module_data_from_path(path)
191+
sys.path.insert(0, str(mod_data.extra_sys_path))
192+
use_app_name = get_app_name(mod_data=mod_data, app_name=app_name)
193+
194+
return mod_data, use_app_name
195+
196+
197+
def get_import_string(
198+
*, path: Union[Path, None] = None, app_name: Union[str, None] = None
199+
) -> str:
200+
mod_data, use_app_name = get_import_string_parts(path=path, app_name=app_name)
201+
import_string = f"{mod_data.module_import_str}:{use_app_name}"
202+
import_example = Syntax(
203+
f"from {mod_data.module_import_str} import {use_app_name}", "python"
204+
)
205+
import_panel = Padding(
206+
Panel(
207+
import_example,
208+
title="[b green]Importable FastAPI app[/b green]",
209+
expand=False,
210+
padding=(1, 2),
211+
),
212+
1,
213+
)
214+
logger.info("Found importable FastAPI app")
215+
print(import_panel)
216+
217+
logger.info(f"Using import string [b green]{import_string}[/b green]")
218+
return import_string
219+
220+
221+
def get_app(
222+
*, path: Union[Path, None] = None, app_name: Union[str, None] = None
223+
) -> FastAPI:
224+
mod_data, use_app_name = get_import_string_parts(path=path, app_name=app_name)
225+
with mod_data.sys_path():
226+
mod = importlib.import_module(mod_data.module_import_str)
227+
app = getattr(mod, use_app_name)
228+
## get_import_string_parts guarantees app is FastAPI object
229+
return app # type: ignore[no-any-return]

tests/assets/openapi.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"openapi": "3.1.0",
3+
"info": {
4+
"title": "FastAPI",
5+
"version": "0.1.0"
6+
},
7+
"paths": {
8+
"/": {
9+
"get": {
10+
"summary": "App Root",
11+
"operationId": "app_root__get",
12+
"responses": {
13+
"200": {
14+
"description": "Successful Response",
15+
"content": {
16+
"application/json": {
17+
"schema": {}
18+
}
19+
}
20+
}
21+
}
22+
}
23+
}
24+
}
25+
}

tests/test_cli.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
import os
12
import subprocess
23
import sys
34
from pathlib import Path
45
from unittest.mock import patch
56

67
import uvicorn
8+
from fastapi_cli.cli import app
9+
# from fastapi_cli.exceptions import FastAPICLIException
10+
711
from typer.testing import CliRunner
812

913
from fastapi_cli.cli import app
@@ -15,6 +19,13 @@
1519
assets_path = Path(__file__).parent / "assets"
1620

1721

22+
def read_file(filename: str, strip: bool = True) -> str:
23+
"""Read file and return content as string"""
24+
with open("openapi.json") as stream:
25+
data = stream.read()
26+
return data.strip() if data and strip else data
27+
28+
1829
def test_dev() -> None:
1930
with changing_dir(assets_path):
2031
with patch.object(uvicorn, "run") as mock_run:
@@ -251,6 +262,51 @@ def test_dev_help() -> None:
251262
assert "Use multiple worker processes." not in result.output
252263

253264

265+
def test_schema() -> None:
266+
with changing_dir(assets_path):
267+
with open("openapi.json") as stream:
268+
expected = stream.read().strip()
269+
assert expected != "", "Failed to read expected result"
270+
result = runner.invoke(app, ["schema", "single_file_app.py"])
271+
assert result.exit_code == 0, result.output
272+
assert expected in result.output, result.output
273+
274+
275+
def test_schema_file() -> None:
276+
with changing_dir(assets_path):
277+
filename = "unit-test.json"
278+
expected = read_file("openapi.json", strip=True)
279+
assert expected != "", "Failed to read expected result"
280+
result = runner.invoke(
281+
app, ["schema", "single_file_app.py", "--output", filename]
282+
)
283+
assert os.path.isfile(filename)
284+
actual = read_file(filename, strip=True)
285+
os.remove(filename)
286+
assert result.exit_code == 0, result.output
287+
assert expected == actual
288+
289+
290+
def test_schema_invalid_path() -> None:
291+
with changing_dir(assets_path):
292+
result = runner.invoke(app, ["schema", "invalid/single_file_app.py"])
293+
assert result.exit_code == 1, result.output
294+
assert isinstance(result.exception, FastAPICLIException)
295+
assert "Path does not exist invalid/single_file_app.py" in str(result.exception)
296+
297+
298+
#
299+
#
300+
# def test_schema_invalid_package() -> None:
301+
# with changing_dir(assets_path):
302+
# result = runner.invoke(
303+
# app, ["schema", "broken_package/mod/app.py"]
304+
# )
305+
# assert result.exit_code == 1, result.output
306+
# assert isinstance(result.exception, ImportError)
307+
# assert "attempted relative import beyond top-level package" in str(result.exception)
308+
309+
254310
def test_run_help() -> None:
255311
result = runner.invoke(app, ["run", "--help"])
256312
assert result.exit_code == 0, result.output

tests/test_utils_package.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44
from pytest import CaptureFixture
5-
5+
from fastapi_cli.discover import get_app, get_import_string
66
from fastapi_cli.discover import get_import_data
77
from fastapi_cli.exceptions import FastAPICLIException
88
from tests.utils import changing_dir
@@ -169,6 +169,17 @@ def test_broken_package_dir(capsys: CaptureFixture[str]) -> None:
169169
assert "Ensure all the package directories have an __init__.py file" in captured.out
170170

171171

172+
def test_get_app_broken_package_dir(capsys: CaptureFixture[str]) -> None:
173+
with changing_dir(assets_path):
174+
# TODO (when deprecating Python 3.8): remove ValueError
175+
with pytest.raises((ImportError, ValueError)):
176+
get_app(path=Path("broken_package/mod/app.py"))
177+
178+
captured = capsys.readouterr()
179+
assert "Import error:" in captured.out
180+
assert "Ensure all the package directories have an __init__.py file" in captured.out
181+
182+
172183
def test_package_dir_no_app() -> None:
173184
with changing_dir(assets_path):
174185
with pytest.raises(FastAPICLIException) as e:

0 commit comments

Comments
 (0)