Skip to content

Commit d4b144c

Browse files
committed
Add schema command
1 parent b65b8a4 commit d4b144c

File tree

4 files changed

+119
-0
lines changed

4 files changed

+119
-0
lines changed

README.md

+6
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ It will listen on the IP address `0.0.0.0`, which means all the available IP add
102102

103103
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>.
104104

105+
## `fastapi schema`
106+
107+
When you run `fastapi schema`, it will generate a swagger/openapi document.
108+
109+
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.
110+
105111
## License
106112

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

src/fastapi_cli/cli.py

+40
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
import sys
13
from logging import getLogger
24
from pathlib import Path
35
from typing import Any, Union
@@ -9,6 +11,7 @@
911
from typing_extensions import Annotated
1012

1113
from fastapi_cli.discover import get_import_string
14+
from fastapi_cli.discover import get_app
1215
from fastapi_cli.exceptions import FastAPICLIException
1316

1417
from . import __version__
@@ -272,6 +275,43 @@ def run(
272275
proxy_headers=proxy_headers,
273276
)
274277

278+
@app.command()
279+
def schema(
280+
path: Annotated[
281+
Union[Path, None],
282+
typer.Argument(
283+
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."
284+
),
285+
] = None,
286+
*,
287+
app: Annotated[
288+
Union[str, None],
289+
typer.Option(
290+
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."
291+
),
292+
] = None,
293+
output: Annotated[
294+
Union[str, None],
295+
typer.Option(
296+
help="The filename to write schema to. If not provided, write to stderr."
297+
),
298+
] = None,
299+
indent: Annotated[
300+
int,
301+
typer.Option(
302+
help="JSON format indent. If 0, disable pretty printing"
303+
),
304+
] = 2,
305+
) -> Any:
306+
""" Generate schema """
307+
app = get_app(path=path, app_name=app)
308+
schema = app.openapi()
309+
310+
stream = open(output, "w") if output else sys.stderr
311+
json.dump(schema, stream, indent=indent if indent > 0 else None)
312+
if output:
313+
stream.close()
314+
return 0
275315

276316
def main() -> None:
277317
app()

src/fastapi_cli/discover.py

+64
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
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
@@ -46,6 +47,18 @@ class ModuleData:
4647
module_import_str: str
4748
extra_sys_path: Path
4849

50+
@contextmanager
51+
def sys_path(self):
52+
""" Context manager to temporarily alter sys.path"""
53+
extra_sys_path = str(self.extra_sys_path) if self.extra_sys_path else ""
54+
if extra_sys_path:
55+
logger.warning("Adding %s to sys.path...", extra_sys_path)
56+
sys.path.insert(0, extra_sys_path)
57+
yield
58+
if extra_sys_path and sys.path and sys.path[0] == extra_sys_path:
59+
logger.warning("Removing %s from sys.path...", extra_sys_path)
60+
sys.path.pop(0)
61+
4962

5063
def get_module_data_from_path(path: Path) -> ModuleData:
5164
logger.info(
@@ -165,3 +178,54 @@ def get_import_string(
165178
import_string = f"{mod_data.module_import_str}:{use_app_name}"
166179
logger.info(f"Using import string [b green]{import_string}[/b green]")
167180
return import_string
181+
182+
def get_app(
183+
*, path: Union[Path, None] = None, app_name: Union[str, None] = None
184+
) -> FastAPI:
185+
if not path:
186+
path = get_default_path()
187+
logger.debug(f"Using path [blue]{path}[/blue]")
188+
logger.debug(f"Resolved absolute path {path.resolve()}")
189+
if not path.exists():
190+
raise FastAPICLIException(f"Path does not exist {path}")
191+
mod_data = get_module_data_from_path(path)
192+
try:
193+
with mod_data.sys_path():
194+
mod = importlib.import_module(mod_data.module_import_str)
195+
except (ImportError, ValueError) as e:
196+
logger.error(f"Import error: {e}")
197+
logger.warning(
198+
"Ensure all the package directories have an [blue]__init__.py["
199+
"/blue] file"
200+
)
201+
raise
202+
if not FastAPI: # type: ignore[truthy-function]
203+
raise FastAPICLIException(
204+
"Could not import FastAPI, try running 'pip install fastapi'"
205+
) from None
206+
object_names = dir(mod)
207+
object_names_set = set(object_names)
208+
if app_name:
209+
if app_name not in object_names_set:
210+
raise FastAPICLIException(
211+
f"Could not find app name {app_name} in "
212+
f"{mod_data.module_import_str}"
213+
)
214+
app = getattr(mod, app_name)
215+
if not isinstance(app, FastAPI):
216+
raise FastAPICLIException(
217+
f"The app name {app_name} in {mod_data.module_import_str} "
218+
f"doesn't seem to be a FastAPI app"
219+
)
220+
return app
221+
for preferred_name in ["app", "api"]:
222+
if preferred_name in object_names_set:
223+
obj = getattr(mod, preferred_name)
224+
if isinstance(obj, FastAPI):
225+
return obj
226+
for name in object_names:
227+
obj = getattr(mod, name)
228+
if isinstance(obj, FastAPI):
229+
return obj
230+
raise FastAPICLIException(
231+
"Could not find FastAPI app in module, try using --app")

tests/test_cli.py

+9
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,15 @@ def test_dev_help() -> None:
179179
assert "The name of the variable that contains the FastAPI app" in result.output
180180
assert "Use multiple worker processes." not in result.output
181181

182+
def test_schema() -> None:
183+
with changing_dir(assets_path):
184+
with open('openapi.json', 'r') as stream:
185+
expected = stream.read()
186+
assert expected != "" , "Failed to read expected result"
187+
result = runner.invoke(app, ["schema", "single_file_app.py"])
188+
assert result.exit_code == 0, result.output
189+
assert expected in result.output, result.output
190+
182191

183192
def test_run_help() -> None:
184193
result = runner.invoke(app, ["run", "--help"])

0 commit comments

Comments
 (0)