Skip to content

Commit 4f78a6f

Browse files
authored
Fix route manifest CLI output (#678)
* Added logic to format the YAML file in accordance with Prettier's formatting standards * Updated type hinting in router functions to use pipe-based unions * Added the route manifest-generating CLI as a pre-commit hook
1 parent 1a89bef commit 4f78a6f

File tree

7 files changed

+180
-49
lines changed

7 files changed

+180
-49
lines changed

.pre-commit-config.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
# YAML formatting specifications: https://yaml.org/spec/1.2.2/
22

33
repos:
4+
# Generate up-to-date Murfey route manifest
5+
# NOTE: This will only work if Murfey is installed in the current Python environment
6+
- repo: local
7+
hooks:
8+
- id: generate-route-manifest
9+
name: Generating Murfey route manifest
10+
entry: murfey.generate_route_manifest
11+
language: system
12+
# Only run if FastAPI router-related modules are changed
13+
files: ^src/murfey/(instrument_server/.+\.py|server/(main|api/.+)\.py)$
14+
pass_filenames: false
15+
416
# Syntax validation and some basic sanity checks
517
- repo: https://github.com/pre-commit/pre-commit-hooks
618
rev: v4.5.0 # Released 2023-10-09

src/murfey/cli/generate_route_manifest.py

Lines changed: 88 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
server and backend server to enable lookup of the URLs based on function name.
44
"""
55

6+
import contextlib
67
import importlib
78
import inspect
9+
import io
810
import pkgutil
911
from argparse import ArgumentParser
1012
from pathlib import Path
@@ -17,6 +19,54 @@
1719
import murfey
1820

1921

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)
68+
69+
2070
def find_routers(name: str) -> dict[str, APIRouter]:
2171

2272
def _extract_routers_from_module(module: ModuleType):
@@ -30,34 +80,36 @@ def _extract_routers_from_module(module: ModuleType):
3080

3181
routers = {}
3282

33-
# Import the module or package
34-
try:
35-
root = importlib.import_module(name)
36-
except ImportError:
37-
raise ImportError(
38-
f"Cannot import '{name}'. Please ensure that you've installed all the "
39-
"dependencies for the client, instrument server, and backend server "
40-
"before running this command."
41-
)
42-
43-
# If it's a package, walk through submodules and extract routers from each
44-
if hasattr(root, "__path__"):
45-
module_list = pkgutil.walk_packages(root.__path__, prefix=name + ".")
46-
for _, module_name, _ in module_list:
47-
try:
48-
module = importlib.import_module(module_name)
49-
except ImportError:
50-
raise ImportError(
51-
f"Cannot import '{module_name}'. Please ensure that you've "
52-
"installed all the dependencies for the client, instrument "
53-
"server, and backend server before running this command."
54-
)
55-
56-
routers.update(_extract_routers_from_module(module))
57-
58-
# Extract directly from single module
59-
else:
60-
routers.update(_extract_routers_from_module(root))
83+
# Silence output during import and only return messages if imports fail
84+
buffer = io.StringIO()
85+
with contextlib.redirect_stdout(buffer), contextlib.redirect_stderr(buffer):
86+
# Import the module or package
87+
try:
88+
root = importlib.import_module(name)
89+
except Exception as e:
90+
captured_logs = buffer.getvalue().strip()
91+
message = f"Cannot import '{name}': {e}"
92+
if captured_logs:
93+
message += f"\n--- Captured output ---\n{captured_logs}"
94+
raise ImportError(message) from e
95+
96+
# If it's a package, walk through submodules and extract routers from each
97+
if hasattr(root, "__path__"):
98+
module_list = pkgutil.walk_packages(root.__path__, prefix=name + ".")
99+
for _, module_name, _ in module_list:
100+
try:
101+
module = importlib.import_module(module_name)
102+
except Exception as e:
103+
captured_logs = buffer.getvalue().strip()
104+
message = f"Cannot import '{name}': {e}"
105+
if captured_logs:
106+
message += f"\n--- Captured output ---\n{captured_logs}"
107+
raise ImportError(message) from e
108+
routers.update(_extract_routers_from_module(module))
109+
110+
# Extract directly from single module
111+
else:
112+
routers.update(_extract_routers_from_module(root))
61113

62114
return routers
63115

@@ -138,7 +190,14 @@ def run():
138190
murfey_dir = Path(murfey.__path__[0])
139191
manifest_file = murfey_dir / "util" / "route_manifest.yaml"
140192
with open(manifest_file, "w") as file:
141-
yaml.dump(manifest, file, default_flow_style=False, sort_keys=False)
193+
yaml.dump(
194+
manifest,
195+
file,
196+
Dumper=PrettierDumper,
197+
default_flow_style=False,
198+
sort_keys=False,
199+
indent=2,
200+
)
142201
print(
143202
"Route manifest for instrument and backend servers saved to "
144203
f"{str(manifest_file)!r}"

src/murfey/server/api/websocket.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import json
55
import logging
66
from datetime import datetime
7-
from typing import Any, TypeVar, Union
7+
from typing import Any, TypeVar
88

99
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
1010
from sqlmodel import Session, select
@@ -27,7 +27,7 @@ def __init__(self):
2727
async def connect(
2828
self,
2929
websocket: WebSocket,
30-
client_id: Union[int, str],
30+
client_id: int | str,
3131
register_client: bool = True,
3232
):
3333
await websocket.accept()
@@ -48,7 +48,7 @@ def _register_new_client(client_id: int):
4848
murfey_db.commit()
4949
murfey_db.close()
5050

51-
def disconnect(self, client_id: Union[int, str], unregister_client: bool = True):
51+
def disconnect(self, client_id: int | str, unregister_client: bool = True):
5252
self.active_connections.pop(client_id)
5353
if unregister_client:
5454
murfey_db: Session = next(get_murfey_db_session())
@@ -97,7 +97,7 @@ async def websocket_endpoint(websocket: WebSocket, client_id: int):
9797
@ws.websocket("/connect/{client_id}")
9898
async def websocket_connection_endpoint(
9999
websocket: WebSocket,
100-
client_id: Union[int, str],
100+
client_id: int | str,
101101
):
102102
await manager.connect(websocket, client_id, register_client=False)
103103
await manager.broadcast(f"Client {client_id} joined")
@@ -161,7 +161,7 @@ async def close_ws_connection(client_id: int):
161161

162162

163163
@ws.delete("/connect/{client_id}")
164-
async def close_unrecorded_ws_connection(client_id: Union[int, str]):
164+
async def close_unrecorded_ws_connection(client_id: int | str):
165165
client_id_str = str(client_id).replace("\r\n", "").replace("\n", "")
166166
log.info(f"Disconnecting {client_id_str}")
167167
manager.disconnect(client_id)

src/murfey/util/api.py

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,11 @@ def url_path_for(
121121
)
122122
logger.error(message)
123123
raise KeyError(message)
124-
# Skip complicated type resolution for now
124+
# Skip complicated type resolution
125125
if param_type.startswith("typing."):
126126
continue
127-
elif type(kwargs[param_name]).__name__ not in param_type:
127+
# Validate incoming type against allowed ones
128+
if type(kwargs[param_name]).__name__ not in param_type:
128129
message = (
129130
f"Error validating parameters for {function_name!r}; "
130131
f"{param_name!r} must be {param_type!r}, "
@@ -135,14 +136,3 @@ def url_path_for(
135136

136137
# Render and return the path
137138
return render_path(route_path, kwargs)
138-
139-
140-
if __name__ == "__main__":
141-
# Run test on some existing routes
142-
url_path = url_path_for(
143-
"workflow.tomo_router",
144-
"register_tilt",
145-
visit_name="nt15587-15",
146-
session_id=2,
147-
)
148-
print(url_path)

src/murfey/util/route_manifest.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,7 +1194,7 @@ murfey.server.api.websocket.ws:
11941194
function: websocket_connection_endpoint
11951195
path_params:
11961196
- name: client_id
1197-
type: typing.Union[int, str]
1197+
type: int | str
11981198
methods: []
11991199
- path: /ws/test/{client_id}
12001200
function: close_ws_connection
@@ -1207,7 +1207,7 @@ murfey.server.api.websocket.ws:
12071207
function: close_unrecorded_ws_connection
12081208
path_params:
12091209
- name: client_id
1210-
type: typing.Union[int, str]
1210+
type: int | str
12111211
methods:
12121212
- DELETE
12131213
murfey.server.api.workflow.correlative_router:
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import sys
2+
3+
from pytest_mock import MockerFixture
4+
5+
from murfey.cli.generate_route_manifest import run
6+
7+
8+
def test_run(
9+
mocker: MockerFixture,
10+
):
11+
# Mock out print() and exit()
12+
mock_print = mocker.patch("builtins.print")
13+
mock_exit = mocker.patch("builtins.exit")
14+
15+
# Run the function with its args
16+
sys.argv = ["", "--debug"]
17+
run()
18+
19+
# Check that the final print message and exit() are called
20+
print_calls = mock_print.call_args_list
21+
last_print_call = print_calls[-1]
22+
last_printed = last_print_call.args[0]
23+
assert last_printed.startswith(
24+
"Route manifest for instrument and backend servers saved to"
25+
)
26+
mock_exit.assert_called_once()

tests/util/test_api.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import pytest
2+
3+
from murfey.util.api import url_path_for
4+
5+
url_path_test_matrix: tuple[tuple[str, str, dict[str, str | int], str], ...] = (
6+
# Router name | Function name | kwargs | Expected URL
7+
(
8+
"instrument_server.api.router",
9+
"health",
10+
{},
11+
"/health",
12+
),
13+
(
14+
"instrument_server.api.router",
15+
"stop_multigrid_watcher",
16+
{"session_id": 0, "label": "some_label"},
17+
"/sessions/0/multigrid_watcher/some_label",
18+
),
19+
(
20+
"api.hub.router",
21+
"get_instrument_image",
22+
{"instrument_name": "test"},
23+
"/instrument/test/image",
24+
),
25+
(
26+
"api.instrument.router",
27+
"check_if_session_is_active",
28+
{
29+
"instrument_name": "test",
30+
"session_id": 0,
31+
},
32+
"/instrument_server/instruments/test/sessions/0/active",
33+
),
34+
)
35+
36+
37+
@pytest.mark.parametrize("test_params", url_path_test_matrix)
38+
def test_url_path_for(test_params: tuple[str, str, dict[str, str | int], str]):
39+
# Unpack test params
40+
router_name, function_name, kwargs, expected_url_path = test_params
41+
assert (
42+
url_path_for(router_name=router_name, function_name=function_name, **kwargs)
43+
== expected_url_path
44+
)

0 commit comments

Comments
 (0)