Skip to content

Commit 36511c9

Browse files
authored
CLI to Generate OpenAPI Schema (#691)
* Migrated custom YAML dumper to 'murfey.cli.__init__' due to shared use with multiple files; added formatter for the argument parser * Added CLI to construct an OpenAPI schema of either the instrument server or backend server and save it as either a JSON or YAML file * Added unit tests for the new CLI
1 parent 6fc82bd commit 36511c9

File tree

5 files changed

+285
-48
lines changed

5 files changed

+285
-48
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ GitHub = "https://github.com/DiamondLightSource/python-murfey"
8787
"murfey.db_sql" = "murfey.cli.murfey_db_sql:run"
8888
"murfey.decrypt_password" = "murfey.cli.decrypt_db_password:run"
8989
"murfey.generate_key" = "murfey.cli.generate_crypto_key:run"
90+
"murfey.generate_openapi_schema" = "murfey.cli.generate_openapi_schema:run"
9091
"murfey.generate_password" = "murfey.cli.generate_db_password:run"
9192
"murfey.generate_route_manifest" = "murfey.cli.generate_route_manifest:run"
9293
"murfey.instrument_server" = "murfey.instrument_server:run"

src/murfey/cli/__init__.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import re
5+
import textwrap
6+
7+
import yaml
8+
9+
10+
class LineWrapHelpFormatter(argparse.RawDescriptionHelpFormatter):
11+
"""
12+
A helper class for formatting the help messages the CLIs nicely. This implementation
13+
will preserve indents at the start of a line and interpret newline metacharacters
14+
accordingly.
15+
16+
Credits: https://stackoverflow.com/a/35925919
17+
"""
18+
19+
def _add_whitespace(self, idx, wspace_idx, text):
20+
if idx == 0:
21+
return text
22+
return (" " * wspace_idx) + text
23+
24+
def _split_lines(self, text, width):
25+
text_rows = text.splitlines()
26+
for idx, line in enumerate(text_rows):
27+
search = re.search(r"\s*[0-9\-]{0,}\.?\s*", line)
28+
if line.strip() == "":
29+
text_rows[idx] = " "
30+
elif search:
31+
wspace_line = search.end()
32+
lines = [
33+
self._add_whitespace(i, wspace_line, x)
34+
for i, x in enumerate(textwrap.wrap(line, width))
35+
]
36+
text_rows[idx] = lines
37+
return [item for sublist in text_rows for item in sublist]
38+
39+
40+
class PrettierDumper(yaml.Dumper):
41+
"""
42+
Custom YAML Dumper class that sets `indentless` to False. This generates a YAML
43+
file that is then compliant with Prettier's formatting style
44+
"""
45+
46+
def increase_indent(self, flow=False, indentless=False):
47+
# Force 'indentless=False' so list items align with Prettier
48+
return super(PrettierDumper, self).increase_indent(flow, indentless=False)
49+
50+
51+
def prettier_str_representer(dumper, data):
52+
"""
53+
Helper function to format strings according to Prettier's standards:
54+
- No quoting unless it can be misinterpreted as another data type
55+
- When quoting, use double quotes unless string already contains double quotes
56+
"""
57+
58+
def is_implicitly_resolved(value: str) -> bool:
59+
for (
60+
first_char,
61+
resolvers,
62+
) in yaml.resolver.Resolver.yaml_implicit_resolvers.items():
63+
if first_char is None or (value and value[0] in first_char):
64+
for resolver in resolvers:
65+
if len(resolver) == 3:
66+
_, regexp, _ = resolver
67+
else:
68+
_, regexp = resolver
69+
if regexp.match(value):
70+
return True
71+
return False
72+
73+
# If no quoting is needed, use default plain style
74+
if not is_implicitly_resolved(data):
75+
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
76+
77+
# If the string already contains double quotes, fall back to single quotes
78+
if '"' in data and "'" not in data:
79+
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="'")
80+
81+
# Otherwise, prefer double quotes
82+
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style='"')
83+
84+
85+
# Add the custom string representer to PrettierDumper
86+
PrettierDumper.add_representer(str, prettier_str_representer)
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import contextlib
2+
import io
3+
import json
4+
from argparse import ArgumentParser
5+
from pathlib import Path
6+
7+
import yaml
8+
from fastapi.openapi.utils import get_openapi
9+
10+
import murfey
11+
from murfey.cli import LineWrapHelpFormatter, PrettierDumper
12+
13+
14+
def run():
15+
# Set up argument parser
16+
parser = ArgumentParser(
17+
description=(
18+
"Generates an OpenAPI schema of the chosen FastAPI server "
19+
"and outputs it as either a JSON or YAML file"
20+
),
21+
formatter_class=LineWrapHelpFormatter,
22+
)
23+
parser.add_argument(
24+
"--target",
25+
"-t",
26+
default="server",
27+
help=(
28+
"The target FastAPI server to construct the OpenAPI schema for. \n"
29+
"OPTIONS: instrument-server | server \n"
30+
"DEFAULT: server"
31+
),
32+
)
33+
parser.add_argument(
34+
"--output",
35+
"-o",
36+
default="yaml",
37+
help=(
38+
"Set the output format of the OpenAPI schema. \n"
39+
"OPTIONS: json | yaml \n"
40+
"DEFAULT: yaml"
41+
),
42+
)
43+
parser.add_argument(
44+
"--to-file",
45+
"-f",
46+
default="",
47+
help=(
48+
"Alternative file path and file name to save the schema as. "
49+
"Can be a relative or absolute path. \n"
50+
"By default, the schema will be saved to 'murfey/utils/', "
51+
"and it will have the name 'openapi.json' or 'openapi.yaml'."
52+
),
53+
)
54+
parser.add_argument(
55+
"--debug",
56+
action="store_true",
57+
default=False,
58+
help="Shows additional steps when setting ",
59+
)
60+
args = parser.parse_args()
61+
62+
# Load the relevant FastAPI app
63+
target = str(args.target).lower()
64+
65+
# Silence output during import; only return genuine errors
66+
buffer = io.StringIO()
67+
with contextlib.redirect_stdout(buffer), contextlib.redirect_stderr(buffer):
68+
if target == "server":
69+
from murfey.server.main import app
70+
elif target == "instrument-server":
71+
from murfey.instrument_server.main import app
72+
else:
73+
raise ValueError(
74+
"Unexpected value for target server. It must be one of "
75+
"'instrument-server' or 'server'"
76+
)
77+
if args.debug:
78+
print(f"Imported FastAPI app for {target}")
79+
80+
if not app.openapi_schema:
81+
schema = get_openapi(
82+
title=app.title,
83+
version=app.version,
84+
openapi_version=app.openapi_version,
85+
description=app.description,
86+
routes=app.routes,
87+
)
88+
if args.debug:
89+
print(f"Constructed OpenAPI schema for {target}")
90+
else:
91+
schema = app.openapi_schema
92+
if args.debug:
93+
print(f"Loaded OpenAPI schema for {target}")
94+
95+
output = str(args.output).lower()
96+
if output not in ("json", "yaml"):
97+
raise ValueError(
98+
"Invalid file format selected. Output must be either 'json' or 'yaml'"
99+
)
100+
murfey_dir = Path(murfey.__path__[0])
101+
save_path = (
102+
murfey_dir / "util" / f"openapi-{target}.{output}"
103+
if not args.to_file
104+
else Path(args.to_file)
105+
)
106+
with open(save_path, "w") as f:
107+
if output == "json":
108+
json.dump(schema, f, indent=2)
109+
else:
110+
yaml.dump(
111+
schema,
112+
f,
113+
Dumper=PrettierDumper,
114+
default_flow_style=False,
115+
sort_keys=False,
116+
indent=2,
117+
)
118+
print(f"OpenAPI schema saved to {save_path}")
119+
exit()
120+
121+
122+
if __name__ == "__main__":
123+
run()

src/murfey/cli/generate_route_manifest.py

Lines changed: 1 addition & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -17,54 +17,7 @@
1717
from fastapi import APIRouter
1818

1919
import murfey
20-
21-
22-
class PrettierDumper(yaml.Dumper):
23-
"""
24-
Custom YAML Dumper class that sets `indentless` to False. This generates a YAML
25-
file that is then compliant with Prettier's formatting style
26-
"""
27-
28-
def increase_indent(self, flow=False, indentless=False):
29-
# Force 'indentless=False' so list items align with Prettier
30-
return super(PrettierDumper, self).increase_indent(flow, indentless=False)
31-
32-
33-
def prettier_str_representer(dumper, data):
34-
"""
35-
Helper function to format strings according to Prettier's standards:
36-
- No quoting unless it can be misinterpreted as another data type
37-
- When quoting, use double quotes unless string already contains double quotes
38-
"""
39-
40-
def is_implicitly_resolved(value: str) -> bool:
41-
for (
42-
first_char,
43-
resolvers,
44-
) in yaml.resolver.Resolver.yaml_implicit_resolvers.items():
45-
if first_char is None or (value and value[0] in first_char):
46-
for resolver in resolvers:
47-
if len(resolver) == 3:
48-
_, regexp, _ = resolver
49-
else:
50-
_, regexp = resolver
51-
if regexp.match(value):
52-
return True
53-
return False
54-
55-
# If no quoting is needed, use default plain style
56-
if not is_implicitly_resolved(data):
57-
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
58-
59-
# If the string already contains double quotes, fall back to single quotes
60-
if '"' in data and "'" not in data:
61-
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="'")
62-
63-
# Otherwise, prefer double quotes
64-
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style='"')
65-
66-
67-
PrettierDumper.add_representer(str, prettier_str_representer)
20+
from murfey.cli import PrettierDumper
6821

6922

7023
def find_routers(name: str) -> dict[str, APIRouter]:
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import sys
2+
from pathlib import Path
3+
4+
import pytest
5+
from pytest_mock import MockerFixture
6+
7+
import murfey
8+
from murfey.cli.generate_openapi_schema import run
9+
10+
params_matrix: tuple[tuple[str | None, str | None, bool], ...] = (
11+
# Target | Output | To File
12+
(None, None, False),
13+
("instrument-server", "json", True),
14+
("server", "yaml", False),
15+
("instrument-server", "yaml", False),
16+
("server", "json", True),
17+
)
18+
19+
20+
@pytest.mark.parametrize("test_params", params_matrix)
21+
def test_run(
22+
mocker: MockerFixture,
23+
tmp_path: Path,
24+
test_params: tuple[str | None, str | None, bool],
25+
):
26+
# Unpack test params
27+
target, output, to_file = test_params
28+
29+
# Mock out print() and exit()
30+
mock_print = mocker.patch("builtins.print")
31+
mock_exit = mocker.patch("builtins.exit")
32+
33+
# Construct the CLI args
34+
sys_args = [""]
35+
if target is not None:
36+
sys_args.extend(["-t", target])
37+
if output is not None:
38+
sys_args.extend(["-o", output])
39+
40+
target = target if target is not None else "server"
41+
output = output if output is not None else "yaml"
42+
if to_file:
43+
save_path = tmp_path / f"openapi.{output}"
44+
sys_args.extend(["-f", str(save_path)])
45+
else:
46+
save_path = Path(murfey.__path__[0]) / "util" / f"openapi-{target}.{output}"
47+
sys_args.extend(["--debug"])
48+
sys.argv = sys_args
49+
50+
# Run the function and check that it runs as expected
51+
run()
52+
print_calls = mock_print.call_args_list
53+
last_print_call = print_calls[-1]
54+
last_printed = last_print_call.args[0]
55+
assert last_printed.startswith("OpenAPI schema saved to")
56+
mock_exit.assert_called_once()
57+
assert save_path.exists()
58+
59+
60+
failure_params_matrix = (
61+
["-t", "blah"],
62+
["-o", "blah"],
63+
)
64+
65+
66+
@pytest.mark.parametrize("test_params", failure_params_matrix)
67+
def test_run_fails(test_params: list[str]):
68+
# Construct the CLI args
69+
sys_args = [""]
70+
sys_args.extend(test_params)
71+
sys.argv = sys_args
72+
73+
with pytest.raises(ValueError):
74+
run()

0 commit comments

Comments
 (0)