maprt-emulator 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- maprt_emulator/__init__.py +11 -0
- maprt_emulator/__main__.py +6 -0
- maprt_emulator/app.py +182 -0
- maprt_emulator/auth.py +105 -0
- maprt_emulator/check.py +262 -0
- maprt_emulator/cli.py +378 -0
- maprt_emulator/data/maps/demo_hfs_lowres.txt +1334 -0
- maprt_emulator/data/maps/index.json +17 -0
- maprt_emulator/data/patients.json +22 -0
- maprt_emulator/data/rooms.json +26 -0
- maprt_emulator/data/surfaces/17c0b9d4-3e8a-4f21-b6c5-9d20e8713f4a.obj +28808 -0
- maprt_emulator/data/surfaces/a6f3fbee-6714-4f92-80c1-f5aadda4cbeb.obj +28808 -0
- maprt_emulator/data/surfaces/dad7394f-6893-4ce3-96ed-8bc3091cf4c3.obj +28808 -0
- maprt_emulator/envelope.py +123 -0
- maprt_emulator/inventory.py +322 -0
- maprt_emulator/library.py +430 -0
- maprt_emulator/mapgen.py +333 -0
- maprt_emulator/maps.py +176 -0
- maprt_emulator/obj.py +103 -0
- maprt_emulator/packs.py +438 -0
- maprt_emulator/replies.py +98 -0
- maprt_emulator/routes/__init__.py +1 -0
- maprt_emulator/routes/beams.py +166 -0
- maprt_emulator/routes/getmap.py +270 -0
- maprt_emulator/routes/inventory.py +23 -0
- maprt_emulator/routes/ping.py +26 -0
- maprt_emulator/routes/rooms.py +61 -0
- maprt_emulator/routes/surfaces.py +114 -0
- maprt_emulator/scenarios.py +215 -0
- maprt_emulator/schemas.py +153 -0
- maprt_emulator/settings.py +239 -0
- maprt_emulator/store.py +210 -0
- maprt_emulator-0.1.0.dist-info/METADATA +179 -0
- maprt_emulator-0.1.0.dist-info/RECORD +38 -0
- maprt_emulator-0.1.0.dist-info/WHEEL +4 -0
- maprt_emulator-0.1.0.dist-info/entry_points.txt +2 -0
- maprt_emulator-0.1.0.dist-info/licenses/LICENSE +21 -0
- maprt_emulator-0.1.0.dist-info/licenses/LICENSE-packs +396 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""maprt_emulator — the MapRT Emulator, a local emulator of the Vision RT MapRT API 2.0.
|
|
2
|
+
|
|
3
|
+
Serves the seven documented REST endpoints over FastAPI, with the same Bearer-token auth, the
|
|
4
|
+
same JSON envelope, the same two-ways-to-fail error model and the same streaming collision-map
|
|
5
|
+
wire format as a real appliance — reading surfaces and recorded clearance maps out of a folder,
|
|
6
|
+
and synthesizing plausible maps when no recording matches.
|
|
7
|
+
|
|
8
|
+
**Not a medical device. Teaching use only. Not affiliated with or endorsed by Vision RT.**
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
maprt_emulator/app.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""The FastAPI application: wiring, the ``Server`` header, and envelope error rendering.
|
|
2
|
+
|
|
3
|
+
It serves the seven MapRT REST routes, plus FastAPI's own pages (``/docs``, ``/redoc``,
|
|
4
|
+
``/openapi.json``), which every FastAPI application has, and the inventory page (``/inventory``,
|
|
5
|
+
``/inventory.md``: what is served and what a call can reach; not in the schema). The pages describe only what an
|
|
6
|
+
appliance takes and sends (:mod:`maprt_emulator.replies`), so a call tried there is a real one. There are no emulator control routes: a
|
|
7
|
+
real appliance has none, and a client tested here must not come to rely on one. A real appliance
|
|
8
|
+
publishes no API pages either, so a client must not depend on those. Faults are chosen when the
|
|
9
|
+
emulator starts (``--scenario``), not over HTTP.
|
|
10
|
+
|
|
11
|
+
Two things to remember when reading responses:
|
|
12
|
+
|
|
13
|
+
* Every endpoint but ``GetMap`` answers with the envelope: ``{"data", "errorData", "success",
|
|
14
|
+
"errorCode", "errorCodeType"}`` on success, and the same without ``data`` on a negative result.
|
|
15
|
+
* **A ``200`` status does not mean the call succeeded.** A negative result — bad token, unlicensed
|
|
16
|
+
feature, ambiguous room name — comes back at ``200`` with ``success: false``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import logging
|
|
21
|
+
|
|
22
|
+
from fastapi import FastAPI, Security
|
|
23
|
+
from fastapi.exceptions import RequestValidationError
|
|
24
|
+
from fastapi.responses import JSONResponse
|
|
25
|
+
|
|
26
|
+
from maprt_emulator import envelope
|
|
27
|
+
from maprt_emulator.auth import bearer_scheme
|
|
28
|
+
from maprt_emulator.library import load_library, serves_packs
|
|
29
|
+
from maprt_emulator.maps import MapLibrary
|
|
30
|
+
from maprt_emulator.routes import beams, getmap, inventory, ping, rooms, surfaces
|
|
31
|
+
from maprt_emulator.routes.getmap import MapCache
|
|
32
|
+
from maprt_emulator.scenarios import ScenarioState
|
|
33
|
+
from maprt_emulator.settings import API_VERSION, SERVER_VERSION, EmulatorSettings
|
|
34
|
+
from maprt_emulator.store import Catalog
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("maprt_emulator")
|
|
37
|
+
|
|
38
|
+
#: ``create_app``'s default for ``library``: load the packs the settings ask for.
|
|
39
|
+
_FROM_SETTINGS = object()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def create_app(settings=None, library=_FROM_SETTINGS):
|
|
43
|
+
"""Build the application. ``settings`` defaults to one resolved from the environment.
|
|
44
|
+
|
|
45
|
+
``library`` is the :class:`maprt_emulator.library.PackLibrary` to serve (or ``None`` for none),
|
|
46
|
+
when the caller has already loaded it, as the command line does to report it in the banner;
|
|
47
|
+
by default the packs the settings ask for are loaded here.
|
|
48
|
+
"""
|
|
49
|
+
settings = settings or EmulatorSettings()
|
|
50
|
+
|
|
51
|
+
# FastAPI's default /docs, /redoc and /openapi.json stay: they come with any FastAPI app.
|
|
52
|
+
app = FastAPI(title="MapRT Emulator", version="2.0.0", description=DOCS_DESCRIPTION)
|
|
53
|
+
_describe_only_the_api(app)
|
|
54
|
+
|
|
55
|
+
app.state.settings = settings
|
|
56
|
+
app.state.token = settings.resolve_token()
|
|
57
|
+
app.state.catalog = Catalog.load(settings.data_dir)
|
|
58
|
+
app.state.library = load_library(settings) if library is _FROM_SETTINGS else library
|
|
59
|
+
# Decision 3: with packs loaded, the emulator presents the newest appliance they were recorded
|
|
60
|
+
# on, in the Server header and the ping reply. Nothing else changes with it.
|
|
61
|
+
appliance = app.state.library.appliance if serves_packs(app) else None
|
|
62
|
+
app.state.server_version = appliance["server"] if appliance else SERVER_VERSION
|
|
63
|
+
app.state.api_version = appliance["apiVersion"] if appliance else API_VERSION
|
|
64
|
+
app.state.maps = MapLibrary.load(settings.data_dir)
|
|
65
|
+
app.state.map_cache = MapCache(app.state.catalog, library=app.state.library)
|
|
66
|
+
app.state.scenarios = ScenarioState(settings)
|
|
67
|
+
|
|
68
|
+
@app.exception_handler(envelope.MapRTFailure)
|
|
69
|
+
async def _render_failure(_request, exc: envelope.MapRTFailure):
|
|
70
|
+
"""Render a reported failure as the MapRT envelope at the status the raiser chose.
|
|
71
|
+
|
|
72
|
+
Note the status is usually **200**: that is the API's own convention for a negative
|
|
73
|
+
result, not an oversight here.
|
|
74
|
+
"""
|
|
75
|
+
if exc.detail:
|
|
76
|
+
logger.warning("%s: %s", exc.error_code, exc.detail)
|
|
77
|
+
return JSONResponse(exc.envelope(), status_code=exc.status_code)
|
|
78
|
+
|
|
79
|
+
@app.exception_handler(RequestValidationError)
|
|
80
|
+
async def _render_validation_error(_request, exc: RequestValidationError):
|
|
81
|
+
"""Render a malformed request body as the MapRT envelope at HTTP 400.
|
|
82
|
+
|
|
83
|
+
FastAPI's own answer would be ``422`` with ``{"detail": [...]}``, which is a perfectly
|
|
84
|
+
good REST convention and the wrong one here twice over: the spec (§5.3) puts a rejected
|
|
85
|
+
request at a 4xx such as 400, not 422, and the body is not the
|
|
86
|
+
envelope, so a MapRT client that has one parser for every response cannot read it.
|
|
87
|
+
|
|
88
|
+
The validation detail goes to the server log, not ``errorData``: no recorded error
|
|
89
|
+
carries anything there, so a client must not learn to read it.
|
|
90
|
+
"""
|
|
91
|
+
logger.warning("InvalidArgument: %s", "; ".join(_readable(error) for error in exc.errors()))
|
|
92
|
+
return JSONResponse(envelope.fail(*envelope.INVALID_ARGUMENT), status_code=400)
|
|
93
|
+
|
|
94
|
+
@app.middleware("http")
|
|
95
|
+
async def _appliance_behaviour(request, call_next):
|
|
96
|
+
"""Latency, the armed scenario's transport-level faults, and the ``Server`` header.
|
|
97
|
+
|
|
98
|
+
The faults are applied only to ``/integration`` paths, which is every route there is; an
|
|
99
|
+
unknown path still gets a plain 404.
|
|
100
|
+
"""
|
|
101
|
+
scenarios_state = app.state.scenarios
|
|
102
|
+
is_api = request.url.path.startswith("/integration")
|
|
103
|
+
|
|
104
|
+
if settings.latency_ms:
|
|
105
|
+
await asyncio.sleep(settings.latency_ms / 1000)
|
|
106
|
+
|
|
107
|
+
if is_api and scenarios_state.extra_delay:
|
|
108
|
+
await asyncio.sleep(scenarios_state.extra_delay)
|
|
109
|
+
|
|
110
|
+
if is_api and scenarios_state.take_throttle_slot():
|
|
111
|
+
# The spec (§2.2.1.3) allows a server to throttle: to tell a client it can't take the
|
|
112
|
+
# request now and that it should try again. Retry-After tells a client how long to
|
|
113
|
+
# wait instead of hammering.
|
|
114
|
+
logger.warning("ServerBusy: the 'throttle' scenario refused %s", request.url.path)
|
|
115
|
+
response = JSONResponse(envelope.fail(*envelope.SERVER_BUSY), status_code=503)
|
|
116
|
+
response.headers["Retry-After"] = "1"
|
|
117
|
+
response.headers["server"] = app.state.server_version
|
|
118
|
+
return response
|
|
119
|
+
|
|
120
|
+
if is_api and scenarios_state.forced_status:
|
|
121
|
+
# A 5xx has no envelope to read on a real appliance, and a client that assumes it can
|
|
122
|
+
# always parse JSON fails inside its own error handler. One is sent anyway here, so
|
|
123
|
+
# the status is the thing being exercised rather than the body.
|
|
124
|
+
logger.warning("InternalError: the 'server-error' scenario answered %s", request.url.path)
|
|
125
|
+
response = JSONResponse(envelope.fail(*envelope.INTERNAL_ERROR),
|
|
126
|
+
status_code=scenarios_state.forced_status)
|
|
127
|
+
response.headers["server"] = app.state.server_version
|
|
128
|
+
return response
|
|
129
|
+
|
|
130
|
+
response = await call_next(request)
|
|
131
|
+
# Every appliance response carries a Server header, Saturn/<version> (spec §5.1). Worth
|
|
132
|
+
# reproducing — it is how a client can tell what it is talking to,
|
|
133
|
+
# and a notebook reads it to confirm the connection landed where it thinks.
|
|
134
|
+
response.headers["server"] = app.state.server_version
|
|
135
|
+
return response
|
|
136
|
+
|
|
137
|
+
# Security() here only documents the Bearer token for /docs; require_token does the checking.
|
|
138
|
+
for router in (ping.router, rooms.router, surfaces.router, getmap.router, beams.router):
|
|
139
|
+
app.include_router(router, dependencies=[Security(bearer_scheme)])
|
|
140
|
+
# A page for people, like /docs: no token, and not part of the API (left out of the schema).
|
|
141
|
+
app.include_router(inventory.router)
|
|
142
|
+
|
|
143
|
+
return app
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
DOCS_DESCRIPTION = """\
|
|
147
|
+
A local emulator of the MapRT API 2.0 REST routes. Teaching and testing use only. A real
|
|
148
|
+
appliance publishes no page like this one.
|
|
149
|
+
|
|
150
|
+
To try a call: **Authorize** with the token the emulator printed at start. A browser can't send
|
|
151
|
+
the `VisionRT.Integration.Saturn/<version>` User-Agent an appliance requires, so start the
|
|
152
|
+
emulator with `--lenient-user-agent`, or every call here answers `VersionMismatch`.
|
|
153
|
+
|
|
154
|
+
[The inventory](/inventory) ([Markdown](/inventory.md)) lists what this emulator serves: with
|
|
155
|
+
packs loaded, every recorded request, with a body to copy that gets the recorded reply.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _describe_only_the_api(app):
|
|
160
|
+
"""Leave FastAPI's 422 validation reply out of the schema: the emulator never sends one.
|
|
161
|
+
|
|
162
|
+
A malformed body gets the MapRT error envelope at 400 (``_render_validation_error``), and
|
|
163
|
+
each POST route documents that 400 itself.
|
|
164
|
+
"""
|
|
165
|
+
generate = app.openapi
|
|
166
|
+
|
|
167
|
+
def openapi():
|
|
168
|
+
schema = generate()
|
|
169
|
+
for operations in schema.get("paths", {}).values():
|
|
170
|
+
for operation in operations.values():
|
|
171
|
+
operation.get("responses", {}).pop("422", None)
|
|
172
|
+
for name in ("HTTPValidationError", "ValidationError"):
|
|
173
|
+
schema.get("components", {}).get("schemas", {}).pop(name, None)
|
|
174
|
+
return schema
|
|
175
|
+
|
|
176
|
+
app.openapi = openapi
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _readable(error):
|
|
180
|
+
"""One pydantic validation error as a short string, without its internal bookkeeping."""
|
|
181
|
+
location = ".".join(str(part) for part in error.get("loc", ()) if part != "body")
|
|
182
|
+
return f"{location or 'body'}: {error.get('msg', 'invalid')}"
|
maprt_emulator/auth.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Bearer-token auth and the User-Agent check, as a real appliance applies them.
|
|
2
|
+
|
|
3
|
+
Three headers go on every MapRT API call::
|
|
4
|
+
|
|
5
|
+
Content-Type: application/json
|
|
6
|
+
Authorization: Bearer <API_access_token>
|
|
7
|
+
User-Agent: VisionRT.Integration.Saturn/<major>.<minor>.<patch>
|
|
8
|
+
|
|
9
|
+
The token is a **shared secret** issued per appliance — not a login, not a session, nothing to
|
|
10
|
+
refresh. It is the whole of the authentication story, which is why a real deployment puts the
|
|
11
|
+
appliance behind TLS on a controlled network and treats the token like a password.
|
|
12
|
+
|
|
13
|
+
What a real appliance does, read from recordings of one (Saturn/1.3.217.2, API 2.0.0,
|
|
14
|
+
2026-10-23), and what the emulator therefore does:
|
|
15
|
+
|
|
16
|
+
* **Any token problem** — no ``Authorization`` header, ``Bearer`` with nothing after it, the token
|
|
17
|
+
without the ``Bearer`` prefix, or a wrong token — gets HTTP **200** with ``success: false`` and
|
|
18
|
+
``InvalidClientToken``. Never a 401. (``--auth-401`` returns 401 instead, for exercising a
|
|
19
|
+
client's other branch.)
|
|
20
|
+
* **The token is checked first.** A wrong token with a bad User-Agent is ``InvalidClientToken``.
|
|
21
|
+
* **The User-Agent** must be ``VisionRT.Integration.Saturn/`` followed by a version whose
|
|
22
|
+
*major.minor* is not newer than the API's (2.0): ``…/2.0.0``, ``…/2.0.1`` and ``…/1.2.8``
|
|
23
|
+
pass; ``…/2.1.0``, ``…/3.0.0``, ``…/`` with no version, no header at all, or ``curl/8.7.1``
|
|
24
|
+
all get HTTP 200, ``success: false``, ``VersionMismatch``. The patch number is ignored.
|
|
25
|
+
|
|
26
|
+
The status code is the trap: a client that trusts it reads every one of these as a success.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
|
|
31
|
+
from fastapi import Request
|
|
32
|
+
from fastapi.security import HTTPBearer
|
|
33
|
+
|
|
34
|
+
from maprt_emulator import envelope
|
|
35
|
+
from maprt_emulator.settings import API_VERSION, USER_AGENT_PREFIX
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger("maprt_emulator.auth")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
#: For ``/docs`` only: it gives the page an Authorize button that sends ``Bearer <token>``.
|
|
41
|
+
#: ``auto_error=False`` means it never answers a request itself; :func:`require_token` does,
|
|
42
|
+
#: with the envelope an appliance sends.
|
|
43
|
+
bearer_scheme = HTTPBearer(auto_error=False, description="The token the emulator printed at start.")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def bearer_token(request):
|
|
47
|
+
"""The token from the ``Authorization`` header, or ``None`` if none was presented."""
|
|
48
|
+
header = request.headers.get("authorization")
|
|
49
|
+
if not header:
|
|
50
|
+
return None
|
|
51
|
+
scheme, _, value = header.partition(" ")
|
|
52
|
+
if scheme.lower() != "bearer" or not value.strip():
|
|
53
|
+
return None
|
|
54
|
+
return value.strip()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _major_minor(version):
|
|
58
|
+
"""``(major, minor)`` from a ``a.b[.c…]`` version string, or ``None`` if it isn't one."""
|
|
59
|
+
parts = version.split(".")
|
|
60
|
+
if len(parts) < 2 or not parts[0].isdigit() or not parts[1].isdigit():
|
|
61
|
+
return None
|
|
62
|
+
return int(parts[0]), int(parts[1])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def user_agent_accepted(agent, api_version=API_VERSION):
|
|
66
|
+
"""Whether an appliance serving ``api_version`` accepts this User-Agent (see the module notes)."""
|
|
67
|
+
if not agent or not agent.startswith(USER_AGENT_PREFIX):
|
|
68
|
+
return False
|
|
69
|
+
client = _major_minor(agent[len(USER_AGENT_PREFIX):])
|
|
70
|
+
return client is not None and client <= _major_minor(api_version)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_user_agent(request, settings):
|
|
74
|
+
"""Refuse a User-Agent the appliance would refuse, unless ``--lenient-user-agent`` is set.
|
|
75
|
+
|
|
76
|
+
Strict by default, because the appliance is. Lenient only logs, so a beginner's first bare
|
|
77
|
+
``GET`` can work before the header has been explained.
|
|
78
|
+
"""
|
|
79
|
+
agent = request.headers.get("user-agent", "")
|
|
80
|
+
if user_agent_accepted(agent):
|
|
81
|
+
return
|
|
82
|
+
if settings.strict_user_agent:
|
|
83
|
+
raise envelope.MapRTFailure(envelope.VERSION_MISMATCH)
|
|
84
|
+
logger.warning("User-Agent %r would get VersionMismatch from an appliance", agent or "(none)")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def require_token(request: Request):
|
|
88
|
+
"""FastAPI dependency: authenticate the request, or raise :class:`envelope.MapRTFailure`.
|
|
89
|
+
|
|
90
|
+
The token first, then the User-Agent — the appliance's order. Also the one place both are
|
|
91
|
+
applied, so every authenticated endpoint gets them without each route remembering to ask.
|
|
92
|
+
"""
|
|
93
|
+
settings = request.app.state.settings
|
|
94
|
+
presented = bearer_token(request)
|
|
95
|
+
|
|
96
|
+
# The `bad-token` scenario refuses a correct token too — what an expired or rotated secret
|
|
97
|
+
# looks like from the client side, which is otherwise impossible to arrange on demand.
|
|
98
|
+
rejected = (presented is None or presented != request.app.state.token
|
|
99
|
+
or request.app.state.scenarios.rejects_token)
|
|
100
|
+
if rejected:
|
|
101
|
+
raise envelope.MapRTFailure(envelope.INVALID_CLIENT_TOKEN,
|
|
102
|
+
status_code=401 if settings.auth_401 else 200)
|
|
103
|
+
|
|
104
|
+
check_user_agent(request, settings)
|
|
105
|
+
return presented
|
maprt_emulator/check.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Validate a data directory without starting a server — ``maprt-emulator --check-data``.
|
|
2
|
+
|
|
3
|
+
The loaders refuse to start on anything they cannot read, which is the right behaviour for a
|
|
4
|
+
server but a slow way to iterate when you are adding data: edit, start, read a traceback, stop,
|
|
5
|
+
repeat. This does the same loading, then goes further and reports the problems that are *not*
|
|
6
|
+
fatal but are almost always mistakes — a map recorded against a surface id that no longer exists,
|
|
7
|
+
an ``.obj`` nobody references, a plan naming a patient the appliance has never heard of.
|
|
8
|
+
|
|
9
|
+
Nothing here changes what the server will do. It tells you what it is about to do.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from maprt_emulator.library import load_library
|
|
15
|
+
from maprt_emulator.maps import MapLibrary, MapLibraryError
|
|
16
|
+
from maprt_emulator.store import Catalog, CatalogError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Report:
|
|
20
|
+
"""Findings, split by consequence.
|
|
21
|
+
|
|
22
|
+
Note what the split is *not*: whether the loader raises. A duplicate room GUID and a
|
|
23
|
+
truncated recorded map both load perfectly happily and then serve wrong answers, which is
|
|
24
|
+
worse than refusing to start — so they are errors here even though the server would come up.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
self.errors = [] # the server will refuse to start
|
|
29
|
+
self.warnings = [] # it will start, but this is probably not what you meant
|
|
30
|
+
self.notes = [] # worth seeing, not a problem
|
|
31
|
+
|
|
32
|
+
def error(self, message):
|
|
33
|
+
"""Something that will produce wrong behaviour. Not necessarily a startup failure."""
|
|
34
|
+
self.errors.append(message)
|
|
35
|
+
|
|
36
|
+
def warn(self, message):
|
|
37
|
+
self.warnings.append(message)
|
|
38
|
+
|
|
39
|
+
def note(self, message):
|
|
40
|
+
self.notes.append(message)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def ok(self):
|
|
44
|
+
return not self.errors
|
|
45
|
+
|
|
46
|
+
def render(self, data_dir):
|
|
47
|
+
lines = [f"Checking {data_dir}", ""]
|
|
48
|
+
for label, items, marker in (("ERROR", self.errors, "✗"),
|
|
49
|
+
("WARNING", self.warnings, "!"),
|
|
50
|
+
("", self.notes, "·")):
|
|
51
|
+
for item in items:
|
|
52
|
+
prefix = f"{marker} {label}: " if label else f" {marker} "
|
|
53
|
+
lines.append(f"{prefix}{item}")
|
|
54
|
+
if self.errors:
|
|
55
|
+
lines += ["", (f"{_count(len(self.errors), 'error')} — fix these before serving this "
|
|
56
|
+
f"data. Some of them will not stop the emulator starting; it will "
|
|
57
|
+
f"start and give wrong answers, which is worse.")]
|
|
58
|
+
elif self.warnings:
|
|
59
|
+
lines += ["", (f"No errors, {_count(len(self.warnings), 'warning')}. "
|
|
60
|
+
f"The emulator will run.")]
|
|
61
|
+
else:
|
|
62
|
+
lines += ["", "No problems found."]
|
|
63
|
+
return "\n".join(lines)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _count(number, noun):
|
|
67
|
+
return f"{number} {noun}{'' if number == 1 else 's'}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check(data_dir):
|
|
71
|
+
"""Validate ``data_dir`` and return a :class:`Report`."""
|
|
72
|
+
data_dir = Path(data_dir)
|
|
73
|
+
report = Report()
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
catalog = Catalog.load(data_dir)
|
|
77
|
+
except CatalogError as exc:
|
|
78
|
+
report.error(str(exc))
|
|
79
|
+
return report # nothing else can be checked without a catalog
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
maps = MapLibrary.load(data_dir)
|
|
83
|
+
except MapLibraryError as exc:
|
|
84
|
+
report.error(str(exc))
|
|
85
|
+
maps = None
|
|
86
|
+
|
|
87
|
+
report.note(", ".join([
|
|
88
|
+
_count(len(catalog.known_patients), "patient"),
|
|
89
|
+
_count(len(catalog.surfaces), "surface"),
|
|
90
|
+
_count(len(catalog.rooms), "room"),
|
|
91
|
+
_count(len(maps) if maps else 0, "recorded map"),
|
|
92
|
+
]))
|
|
93
|
+
|
|
94
|
+
_check_rooms(catalog, report)
|
|
95
|
+
_check_surfaces(catalog, data_dir, report)
|
|
96
|
+
_check_patients(catalog, report)
|
|
97
|
+
if maps is not None:
|
|
98
|
+
_check_maps(maps, catalog, report)
|
|
99
|
+
_check_plans(catalog, data_dir, report)
|
|
100
|
+
|
|
101
|
+
return report
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _check_rooms(catalog, report):
|
|
105
|
+
ids = [room.id for room in catalog.rooms]
|
|
106
|
+
duplicates = {i for i in ids if ids.count(i) > 1}
|
|
107
|
+
for room_id in sorted(duplicates):
|
|
108
|
+
# GetMap keys on the GUID, so two rooms sharing one makes the choice between them
|
|
109
|
+
# arbitrary — and silently so.
|
|
110
|
+
report.error(f"two or more rooms share the id {room_id}; room ids must be unique")
|
|
111
|
+
|
|
112
|
+
names = [room.machine_name for room in catalog.rooms]
|
|
113
|
+
shared = sorted({n for n in names if names.count(n) > 1})
|
|
114
|
+
if shared:
|
|
115
|
+
# Not a problem — the shipped data does this on purpose, and so do real sites.
|
|
116
|
+
report.note(f"machine name(s) shared by several rooms: {', '.join(shared)} "
|
|
117
|
+
f"(GET /integration/rooms/{{machineName}} will return DuplicateKey for these)")
|
|
118
|
+
|
|
119
|
+
for room in catalog.rooms:
|
|
120
|
+
if len(room.machine_name) > 16:
|
|
121
|
+
report.warn(f"room {room.name!r} has a machineName of {len(room.machine_name)} "
|
|
122
|
+
f"characters; the spec caps it at 16")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _check_surfaces(catalog, data_dir, report):
|
|
126
|
+
from maprt_emulator.obj import ObjParseError, parse_obj
|
|
127
|
+
|
|
128
|
+
referenced = {surface.path.resolve() for surface in catalog.surfaces}
|
|
129
|
+
for directory in (data_dir / "surfaces", data_dir / "local" / "surfaces"):
|
|
130
|
+
if not directory.is_dir():
|
|
131
|
+
continue
|
|
132
|
+
for path in sorted(directory.glob("*.obj")):
|
|
133
|
+
if path.resolve() not in referenced:
|
|
134
|
+
report.warn(f"{path.relative_to(data_dir)} is not listed for any patient in "
|
|
135
|
+
f"patients.json, so it will never be served")
|
|
136
|
+
|
|
137
|
+
for surface in catalog.surfaces:
|
|
138
|
+
try:
|
|
139
|
+
vertices, faces, _normals = parse_obj(surface.path.read_bytes())
|
|
140
|
+
except ObjParseError as exc:
|
|
141
|
+
report.error(f"{surface.path.name} does not parse as an OBJ mesh: {exc}")
|
|
142
|
+
continue
|
|
143
|
+
if len(vertices) < 100 or len(faces) < 100:
|
|
144
|
+
report.warn(f"{surface.path.name} has only {len(vertices)} vertices and "
|
|
145
|
+
f"{len(faces)} faces — is it the mesh you meant?")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _check_patients(catalog, report):
|
|
149
|
+
for patient_id in sorted(catalog.known_patients):
|
|
150
|
+
if len(patient_id) > 64:
|
|
151
|
+
report.warn(f"patient id {patient_id!r} is {len(patient_id)} characters; "
|
|
152
|
+
f"DICOM caps it at 64")
|
|
153
|
+
if not catalog.surfaces_for(patient_id):
|
|
154
|
+
# Legitimate and deliberately represented in the shipped data, but say so in case it
|
|
155
|
+
# is a typo in a surface id rather than an intentionally empty patient.
|
|
156
|
+
report.note(f"patient {patient_id} has no surfaces (a valid case: the API returns "
|
|
157
|
+
f"an empty list at success:true)")
|
|
158
|
+
|
|
159
|
+
timestamps = [(p, [s.timestamp for s in catalog.surfaces_for(p)])
|
|
160
|
+
for p in catalog.known_patients]
|
|
161
|
+
for patient_id, stamps in timestamps:
|
|
162
|
+
if len(stamps) != len(set(stamps)):
|
|
163
|
+
report.warn(f"patient {patient_id} has two surfaces with the same timeStamp; "
|
|
164
|
+
f"'use the most recent' becomes arbitrary")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _check_maps(maps, catalog, report):
|
|
168
|
+
known_surfaces = {surface.id for surface in catalog.surfaces}
|
|
169
|
+
known_rooms = {room.id for room in catalog.rooms}
|
|
170
|
+
|
|
171
|
+
for recording in maps.recordings:
|
|
172
|
+
name = recording.path.name
|
|
173
|
+
# Not fatal — the library simply never matches it — but it means a recording you went to
|
|
174
|
+
# the trouble of capturing will silently never be served.
|
|
175
|
+
if recording.surface_id not in known_surfaces:
|
|
176
|
+
report.warn(f"{name} is recorded against surface {recording.surface_id}, which is "
|
|
177
|
+
f"not in patients.json — it will never be served")
|
|
178
|
+
if recording.room_id not in known_rooms:
|
|
179
|
+
report.warn(f"{name} is recorded against room {recording.room_id}, which is not in "
|
|
180
|
+
f"rooms.json — it will never be served")
|
|
181
|
+
|
|
182
|
+
text = recording.text()
|
|
183
|
+
if not text.strip().endswith("DONE"):
|
|
184
|
+
report.error(f"{name} has no DONE terminator; it was captured from a truncated "
|
|
185
|
+
f"response and will parse into a wrong-shaped array")
|
|
186
|
+
try:
|
|
187
|
+
header = text.split("\n", 1)[0]
|
|
188
|
+
couch_samples, gantry_samples = (int(v) for v in header.split(","))
|
|
189
|
+
except ValueError:
|
|
190
|
+
report.error(f"{name} does not start with a 'couch,gantry' header line")
|
|
191
|
+
continue
|
|
192
|
+
triples = len(text.split()) - 2
|
|
193
|
+
if triples != couch_samples * gantry_samples:
|
|
194
|
+
report.error(f"{name} promises {couch_samples * gantry_samples:,} triples but "
|
|
195
|
+
f"carries {triples:,}")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _check_plans(catalog, data_dir, report):
|
|
199
|
+
plans_dir = data_dir / "plans"
|
|
200
|
+
if not plans_dir.is_dir():
|
|
201
|
+
return
|
|
202
|
+
try:
|
|
203
|
+
import pydicom
|
|
204
|
+
except ImportError:
|
|
205
|
+
report.note("pydicom is not installed, so RT Plans were not checked")
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
for path in sorted(plans_dir.glob("*.dcm")):
|
|
209
|
+
try:
|
|
210
|
+
plan = pydicom.dcmread(path, stop_before_pixels=True)
|
|
211
|
+
except Exception as exc: # noqa: BLE001 — any unreadable file is worth reporting
|
|
212
|
+
report.error(f"{path.name} does not read as DICOM: {type(exc).__name__}: {exc}")
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
if getattr(plan, "Modality", None) != "RTPLAN":
|
|
216
|
+
report.warn(f"{path.name} is {getattr(plan, 'Modality', 'unknown')}, not RTPLAN")
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
patient_id = str(getattr(plan, "PatientID", ""))
|
|
220
|
+
if patient_id not in catalog.known_patients:
|
|
221
|
+
# The capstone notebook looks this patient up; a mismatch fails its second cell.
|
|
222
|
+
report.warn(f"{path.name} is for patient {patient_id!r}, who is not in "
|
|
223
|
+
f"patients.json — a notebook using this plan will fail")
|
|
224
|
+
|
|
225
|
+
machines = {str(b.TreatmentMachineName) for b in getattr(plan, "BeamSequence", [])
|
|
226
|
+
if hasattr(b, "TreatmentMachineName")}
|
|
227
|
+
configured = {room.machine_name for room in catalog.rooms}
|
|
228
|
+
for machine in sorted(machines - configured):
|
|
229
|
+
report.warn(f"{path.name} names machine {machine!r}, which is not in rooms.json")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def check_packs(settings, report):
|
|
233
|
+
"""Add the packs the settings select to ``report``: a refusal, or a skipped pack, is an error
|
|
234
|
+
(a packs folder can be checked before a workshop); a mixed-version warning is a warning; the rest
|
|
235
|
+
are notes."""
|
|
236
|
+
try:
|
|
237
|
+
library = load_library(settings)
|
|
238
|
+
except CatalogError as exc:
|
|
239
|
+
report.error(f"packs: {exc}")
|
|
240
|
+
return
|
|
241
|
+
if library is None:
|
|
242
|
+
report.note("packs: off (--no-packs)" if settings.no_packs else "packs: none (no --packs-dir)")
|
|
243
|
+
return
|
|
244
|
+
report.note(f"packs: {library.summary()}")
|
|
245
|
+
for entry in library.skipped:
|
|
246
|
+
report.error(f"packs: {entry.name or entry.path} ({entry.path}) failed its checks and would be skipped: "
|
|
247
|
+
+ "; ".join(map(str, entry.findings)))
|
|
248
|
+
for warning in library.warnings:
|
|
249
|
+
report.warn(f"packs: {warning}")
|
|
250
|
+
for notice in library.notices:
|
|
251
|
+
report.note(f"packs: {notice}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def main(data_dir, settings=None):
|
|
255
|
+
"""Print the report; return a process exit code. With ``settings``, the packs they select are
|
|
256
|
+
checked too."""
|
|
257
|
+
report = check(data_dir)
|
|
258
|
+
if settings is not None:
|
|
259
|
+
check_packs(settings, report)
|
|
260
|
+
print(report.render(data_dir))
|
|
261
|
+
return 0 if report.ok else 1
|
|
262
|
+
|