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.
Files changed (38) hide show
  1. maprt_emulator/__init__.py +11 -0
  2. maprt_emulator/__main__.py +6 -0
  3. maprt_emulator/app.py +182 -0
  4. maprt_emulator/auth.py +105 -0
  5. maprt_emulator/check.py +262 -0
  6. maprt_emulator/cli.py +378 -0
  7. maprt_emulator/data/maps/demo_hfs_lowres.txt +1334 -0
  8. maprt_emulator/data/maps/index.json +17 -0
  9. maprt_emulator/data/patients.json +22 -0
  10. maprt_emulator/data/rooms.json +26 -0
  11. maprt_emulator/data/surfaces/17c0b9d4-3e8a-4f21-b6c5-9d20e8713f4a.obj +28808 -0
  12. maprt_emulator/data/surfaces/a6f3fbee-6714-4f92-80c1-f5aadda4cbeb.obj +28808 -0
  13. maprt_emulator/data/surfaces/dad7394f-6893-4ce3-96ed-8bc3091cf4c3.obj +28808 -0
  14. maprt_emulator/envelope.py +123 -0
  15. maprt_emulator/inventory.py +322 -0
  16. maprt_emulator/library.py +430 -0
  17. maprt_emulator/mapgen.py +333 -0
  18. maprt_emulator/maps.py +176 -0
  19. maprt_emulator/obj.py +103 -0
  20. maprt_emulator/packs.py +438 -0
  21. maprt_emulator/replies.py +98 -0
  22. maprt_emulator/routes/__init__.py +1 -0
  23. maprt_emulator/routes/beams.py +166 -0
  24. maprt_emulator/routes/getmap.py +270 -0
  25. maprt_emulator/routes/inventory.py +23 -0
  26. maprt_emulator/routes/ping.py +26 -0
  27. maprt_emulator/routes/rooms.py +61 -0
  28. maprt_emulator/routes/surfaces.py +114 -0
  29. maprt_emulator/scenarios.py +215 -0
  30. maprt_emulator/schemas.py +153 -0
  31. maprt_emulator/settings.py +239 -0
  32. maprt_emulator/store.py +210 -0
  33. maprt_emulator-0.1.0.dist-info/METADATA +179 -0
  34. maprt_emulator-0.1.0.dist-info/RECORD +38 -0
  35. maprt_emulator-0.1.0.dist-info/WHEEL +4 -0
  36. maprt_emulator-0.1.0.dist-info/entry_points.txt +2 -0
  37. maprt_emulator-0.1.0.dist-info/licenses/LICENSE +21 -0
  38. maprt_emulator-0.1.0.dist-info/licenses/LICENSE-packs +396 -0
maprt_emulator/cli.py ADDED
@@ -0,0 +1,378 @@
1
+ """``maprt-emulator`` — starting the emulator, and the token banner.
2
+
3
+ The emulator mints a **fresh token every time it starts** and makes it available two ways:
4
+
5
+ * printed in a banner, for someone watching a terminal;
6
+ * written to ``.maprt_emulator/token``, for everyone else.
7
+
8
+ The file is not a convenience, it is the only thing that works on Binder and Colab, where the
9
+ server runs as a background process and nobody ever sees its output.
10
+ """
11
+
12
+ import argparse
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import uvicorn
18
+
19
+ from maprt_emulator.app import create_app
20
+ from maprt_emulator.library import load_library
21
+ from maprt_emulator.maps import MapLibrary, MapLibraryError
22
+ from maprt_emulator.scenarios import SCENARIOS, catalogue
23
+ from maprt_emulator.settings import LICENSABLE, EmulatorSettings
24
+ from maprt_emulator.store import Catalog, CatalogError
25
+
26
+ BANNER_WIDTH = 74
27
+
28
+
29
+ def _stdout_can_print_the_banner():
30
+ """Switch stdout to UTF-8 if its encoding can't hold the banner's box and dash characters.
31
+
32
+ On Windows a redirected or piped stdout (a launcher capturing the output, a log file) is
33
+ cp1252, and printing the box there raised ``UnicodeEncodeError`` before the server started.
34
+ """
35
+ try:
36
+ "╭─╮│╰╯—".encode(sys.stdout.encoding or "ascii")
37
+ except (UnicodeEncodeError, LookupError):
38
+ if hasattr(sys.stdout, "reconfigure"):
39
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
40
+
41
+
42
+ def _box(lines):
43
+ inner = BANNER_WIDTH - 2
44
+ out = ["╭" + "─" * inner + "╮"]
45
+ for line in lines:
46
+ out.append("│" + line.ljust(inner)[:inner] + "│")
47
+ out.append("╰" + "─" * inner + "╯")
48
+ return "\n".join(out)
49
+
50
+
51
+ def _elide(path, width):
52
+ """Shorten a path to ``width``, dropping leading components rather than trailing ones.
53
+
54
+ Truncating from the right hides the part that matters — *which* data directory this is — and
55
+ cuts mid-word, which reads like a bug in the banner rather than a deliberate shortening.
56
+ """
57
+ text = str(path)
58
+ if len(text) <= width:
59
+ return text
60
+ parts = text.split("/")
61
+ for start in range(1, len(parts)):
62
+ candidate = ".../" + "/".join(parts[start:])
63
+ if len(candidate) <= width:
64
+ return candidate
65
+ # A single component longer than the whole field: nothing to drop, so cut the front.
66
+ return "..." + text[-(width - 3):]
67
+
68
+
69
+ def banner(settings, catalog, token_path, maps=None, library=None):
70
+ """The startup box, plus the copy-paste line for a notebook, and what the packs need said."""
71
+ serving_packs = library is not None and bool(library.packs)
72
+ warnings = []
73
+ if settings.scenario != "ok":
74
+ warnings.append(f" Scenario {settings.scenario}: {SCENARIOS[settings.scenario].description}")
75
+ if settings.map_source != "auto":
76
+ warnings.append(f" Maps --map-source {settings.map_source}")
77
+ if settings.shuffle_map:
78
+ warnings.append(" Maps triples served in random order")
79
+ if settings.unlicensed:
80
+ warnings.append(f" Unlicensed {', '.join(settings.unlicensed)}")
81
+ if settings.auth_401:
82
+ warnings.append(" Auth a token problem returns HTTP 401 (an appliance sends 200)")
83
+ if not settings.strict_user_agent:
84
+ warnings.append(" User-Agent not enforced (an appliance answers VersionMismatch)")
85
+ if settings.open_ping:
86
+ warnings.append(" Ping served WITHOUT a token")
87
+ if settings.source_headers:
88
+ warnings.append(" Headers X-Emulator-* source headers sent (an appliance sends none)")
89
+
90
+ lines = [
91
+ " MapRT Emulator — NOT a medical device. Teaching only.",
92
+ "",
93
+ f" Base URL {settings.base_url}",
94
+ f" Docs {settings.base_url}/docs",
95
+ *([" (Try it out needs --lenient-user-agent)"] if settings.strict_user_agent else []),
96
+ *_data_lines(settings, catalog, maps, library),
97
+ f" Token file {token_path}",
98
+ ]
99
+ if serving_packs and library.warnings:
100
+ warnings.append(" Appliance packs from several versions; see below")
101
+ if warnings:
102
+ lines.extend(["", *warnings])
103
+
104
+ return "\n".join([
105
+ "",
106
+ _box(lines),
107
+ *_pack_report(library),
108
+ "",
109
+ "Copy this line into your notebook (or let it read the token file):",
110
+ "",
111
+ f" MAPRT_EMULATOR_TOKEN={settings.resolve_token()}",
112
+ "",
113
+ "Press Ctrl+C to stop the server.",
114
+ "",
115
+ ])
116
+
117
+
118
+ def _data_lines(settings, catalog, maps, library):
119
+ """The banner's lines about what is served: the packs, or the catalog."""
120
+ width = BANNER_WIDTH - 16
121
+ if library is not None and library.packs:
122
+ appliance = library.appliance
123
+ first, *rest = _wrap(library.summary(), BANNER_WIDTH - 16)
124
+ return [f" Packs {first}", *(f" {part}" for part in rest),
125
+ f" {_elide(settings.packs_dir, width)}",
126
+ f" Appliance {appliance['server']} (API {appliance['apiVersion']})"]
127
+ lines = [f" Data {catalog.summary()}" + (f" · {maps.summary()}" if maps else ""),
128
+ f" {_elide(settings.data_dir, width)}"]
129
+ if settings.no_packs:
130
+ lines.append(" Packs off (--no-packs)")
131
+ elif library is not None:
132
+ lines.append(f" Packs none in {_elide(settings.packs_dir, width - 8)}")
133
+ return lines
134
+
135
+
136
+ def _wrap(text, width):
137
+ """``text`` as lines of at most ``width``, broken after its ``;`` and ``,`` separators."""
138
+ lines = []
139
+ for clause in text.split("; "):
140
+ line = ""
141
+ for part in clause.split(", "):
142
+ candidate = f"{line}, {part}" if line else part
143
+ if line and len(candidate) + 1 > width:
144
+ lines.append(line + ",")
145
+ line = part
146
+ else:
147
+ line = candidate
148
+ lines.append(line)
149
+ return lines
150
+
151
+
152
+ def _pack_report(library):
153
+ """Below the box: each pack warning and notice once, and every skipped pack with its findings."""
154
+ if library is None or not (library.warnings or library.notices or library.skipped):
155
+ return []
156
+ lines = ["", "Packs:"]
157
+ lines += [f" ! {warning}" for warning in library.warnings]
158
+ for entry in library.skipped:
159
+ lines.append(f" ! Skipped {entry.name or entry.path} ({entry.path}), which failed its checks:")
160
+ lines += [f" - {finding}" for finding in entry.findings]
161
+ lines += [f" · {notice}" for notice in library.notices]
162
+ return lines
163
+
164
+
165
+ def _parser(prog="maprt-emulator"):
166
+ parser = argparse.ArgumentParser(
167
+ prog=prog,
168
+ description="Run a local emulator of the Vision RT MapRT API 2.0. Teaching use only.",
169
+ epilog="maprt-emulator inventory [the same options] prints, as Markdown, what those options "
170
+ "would serve, without starting a server (also at /inventory and /inventory.md).",
171
+ )
172
+ parser.add_argument("--host", default=None, help="interface to bind (default 127.0.0.1)")
173
+ parser.add_argument("--port", type=int, default=None, help="port to bind (default 8000)")
174
+ parser.add_argument("--token", default=None,
175
+ help="use this API token instead of generating a fresh one")
176
+ parser.add_argument("--data-dir", default=None,
177
+ help="the catalog to serve (default: the nearest data/MapRT/ directory)")
178
+ parser.add_argument("--packs-dir", default=None,
179
+ help="serve the session packs in this folder (every .zip, and every folder "
180
+ "with a manifest.json); they replace the catalog")
181
+ parser.add_argument("--pack", action="append", default=None, metavar="NAME",
182
+ help="serve only this pack from --packs-dir, by its manifest name "
183
+ "(repeatable); a named pack that fails its checks refuses startup")
184
+ parser.add_argument("--no-packs", action="store_true",
185
+ help="read no pack, even with MAPRT_EMULATOR_PACKS_DIR set: serve the catalog")
186
+ parser.add_argument("--state-dir", default=None,
187
+ help="where to write the token file (default .maprt_emulator)")
188
+ parser.add_argument("--unlicensed", default=None,
189
+ help=f"comma-separated features to report as unlicensed: "
190
+ f"{', '.join(LICENSABLE)}")
191
+ parser.add_argument("--scenario", default=None, choices=sorted(SCENARIOS),
192
+ help="arm a fault for the whole run (default ok: none); see --list-scenarios")
193
+ parser.add_argument("--list-scenarios", action="store_true",
194
+ help="describe every scenario and exit")
195
+ parser.add_argument("--latency-ms", type=int, default=None,
196
+ help="artificial delay per request, so calls are visibly not free")
197
+ parser.add_argument("--map-source", default=None, choices=("auto", "recorded", "synthetic"),
198
+ help="auto prefers a recorded map; recorded refuses to invent one; "
199
+ "synthetic ignores the recordings (default auto)")
200
+ parser.add_argument("--seed", type=int, default=None,
201
+ help="varies the clearance model slightly; same seed, same maps")
202
+ parser.add_argument("--map-seconds", type=float, default=None,
203
+ help="roughly how long GetMap takes to stream (default 3; a real "
204
+ "appliance takes minutes)")
205
+ parser.add_argument("--shuffle-map", action="store_true",
206
+ help="randomise the order of the map's triples; the spec says the list "
207
+ "is unordered, so a client must not depend on it")
208
+ parser.add_argument("--auth-401", action="store_true",
209
+ help="answer a token problem with HTTP 401 instead of the appliance's 200")
210
+ parser.add_argument("--lenient-user-agent", action="store_true",
211
+ help="accept any User-Agent (only log it); an appliance answers one that "
212
+ "isn't VisionRT.Integration.Saturn/<2.0 or older> with VersionMismatch")
213
+ parser.add_argument("--open-ping", action="store_true",
214
+ help="serve /integration/ping without a token (debugging aid)")
215
+ parser.add_argument("--source-headers", action="store_true",
216
+ help="name the answering source (recorded or synthetic) in X-Emulator-* "
217
+ "response headers; an appliance sends none, so they are off by default")
218
+ parser.add_argument("--check-data", action="store_true",
219
+ help="validate the data directory (and the packs, if any) and exit, "
220
+ "without starting a server")
221
+ parser.add_argument("--reload", action="store_true", help="reload on code changes (developing)")
222
+ parser.add_argument("--log-level", default="info", help="uvicorn log level")
223
+ return parser
224
+
225
+
226
+ def settings_from_args(args):
227
+ """Turn parsed arguments into :class:`EmulatorSettings`, leaving unset flags to the environment."""
228
+ overrides = {}
229
+ if args.host is not None:
230
+ overrides["host"] = args.host
231
+ if args.port is not None:
232
+ overrides["port"] = args.port
233
+ if args.token is not None:
234
+ overrides["token"] = args.token
235
+ if args.data_dir is not None:
236
+ overrides["data_dir"] = Path(args.data_dir)
237
+ if args.state_dir is not None:
238
+ overrides["state_dir"] = Path(args.state_dir)
239
+ if args.no_packs and (args.packs_dir is not None or args.pack):
240
+ raise SystemExit("maprt-emulator: --no-packs reads no pack, so it can't be given with "
241
+ "--packs-dir or --pack.")
242
+ if args.packs_dir is not None:
243
+ overrides["packs_dir"] = Path(args.packs_dir)
244
+ if args.pack:
245
+ overrides["packs"] = tuple(args.pack)
246
+ if args.latency_ms is not None:
247
+ overrides["latency_ms"] = args.latency_ms
248
+ if args.map_source is not None:
249
+ overrides["map_source"] = args.map_source
250
+ if args.seed is not None:
251
+ overrides["seed"] = args.seed
252
+ if args.scenario is not None:
253
+ overrides["scenario"] = args.scenario
254
+ if args.map_seconds is not None:
255
+ overrides["map_seconds"] = args.map_seconds
256
+ if args.unlicensed is not None:
257
+ features = tuple(f.strip() for f in args.unlicensed.split(",") if f.strip())
258
+ unknown = [f for f in features if f not in LICENSABLE]
259
+ if unknown:
260
+ raise SystemExit(f"maprt-emulator: unknown feature(s) {', '.join(unknown)}. "
261
+ f"Choose from: {', '.join(LICENSABLE)}")
262
+ overrides["unlicensed"] = features
263
+ # Store-true flags: only pass them when set, so the environment can still turn them on.
264
+ for flag in ("auth_401", "open_ping", "shuffle_map", "source_headers", "no_packs"):
265
+ if getattr(args, flag):
266
+ overrides[flag] = True
267
+ if args.lenient_user_agent:
268
+ overrides["strict_user_agent"] = False
269
+ return EmulatorSettings(**overrides)
270
+
271
+
272
+ def list_scenarios():
273
+ """Every scenario: what it does, and what it is there to teach."""
274
+ lines = []
275
+ for scenario in catalogue():
276
+ lines += [scenario["name"], f" {scenario['description']}"]
277
+ if scenario["teaches"]:
278
+ lines.append(f" Teaches: {scenario['teaches']}")
279
+ lines.append("")
280
+ return "\n".join(lines)
281
+
282
+
283
+ def inventory(argv):
284
+ """``maprt-emulator inventory [--packs-dir …] [--pack …] [--no-packs] [--data-dir …]``: print
285
+ the inventory page's Markdown for what these settings would serve, without starting a server."""
286
+ settings = settings_from_args(_parser("maprt-emulator inventory").parse_args(argv))
287
+ from maprt_emulator import inventory as page
288
+
289
+ try:
290
+ app = create_app(settings)
291
+ except CatalogError as exc:
292
+ raise SystemExit(f"maprt-emulator: {exc}") from exc
293
+ _stdout_can_print_the_banner()
294
+ print(page.markdown(page.build(app)), end="")
295
+
296
+
297
+ def main(argv=None):
298
+ argv = sys.argv[1:] if argv is None else list(argv)
299
+ if argv[:1] == ["inventory"]:
300
+ inventory(argv[1:])
301
+ return
302
+ args = _parser().parse_args(argv)
303
+ if args.list_scenarios:
304
+ print(list_scenarios())
305
+ return
306
+ settings = settings_from_args(args)
307
+
308
+ if args.check_data:
309
+ # Before the catalog load below, because that one raises on the first problem it meets.
310
+ # The checker reports everything at once, which is what you want while editing data.
311
+ from maprt_emulator.check import main as check_main
312
+ raise SystemExit(check_main(settings.data_dir, settings))
313
+
314
+ # Load the catalog here, before uvicorn starts, so a data problem is a clear message at the
315
+ # prompt rather than a traceback inside a worker during someone's first notebook cell.
316
+ try:
317
+ catalog = Catalog.load(settings.data_dir)
318
+ except CatalogError as exc:
319
+ raise SystemExit(f"maprt-emulator: {exc}") from exc
320
+
321
+ try:
322
+ maps = MapLibrary.load(settings.data_dir)
323
+ except MapLibraryError as exc:
324
+ raise SystemExit(f"maprt-emulator: {exc}") from exc
325
+
326
+ # The packs too, once, here: a refusal is a message at the prompt, the banner reports what was
327
+ # loaded, and the app is handed the same library rather than loading it again.
328
+ try:
329
+ library = load_library(settings)
330
+ except CatalogError as exc:
331
+ raise SystemExit(f"maprt-emulator: {exc}") from exc
332
+
333
+ packs_report = {"off": True} if settings.no_packs else (library.report() if library is not None else None)
334
+ token_path = settings.write_token(packs=packs_report)
335
+ _stdout_can_print_the_banner()
336
+ print(banner(settings, catalog, token_path, maps, library), flush=True)
337
+
338
+ # Build the app HERE, from the settings we just parsed and just described in the banner, and
339
+ # hand uvicorn the object. The alternative — giving uvicorn an import string and letting it
340
+ # call create_app() itself — re-resolves EmulatorSettings from the environment in the server
341
+ # process, so every command-line flag is silently dropped while the banner still claims it
342
+ # is in effect. That is worse than not having the flags.
343
+ #
344
+ # --reload is the one case that cannot work this way: reloading means re-importing in a child
345
+ # process, which needs an import string. There, the settings go through the environment
346
+ # instead, which is why they all have to be environment-expressible.
347
+ if args.reload:
348
+ os.environ.update(settings.as_env())
349
+ target, factory = "maprt_emulator.app:create_app", True
350
+ else:
351
+ target, factory = create_app(settings, library), False
352
+
353
+ uvicorn.run(
354
+ target,
355
+ factory=factory,
356
+ host=settings.host,
357
+ port=settings.port,
358
+ reload=args.reload,
359
+ log_level=args.log_level,
360
+ # uvicorn would otherwise advertise itself in the Server header; the middleware replaces
361
+ # it with Saturn/... to match a real appliance.
362
+ server_header=False,
363
+ )
364
+
365
+
366
+ def address(argv, prog="maprt-emulator"):
367
+ """The ``(host, port)`` the emulator would listen on, given these arguments.
368
+
369
+ What a launcher (the SGRT Scripting Workshop's ``maprt-workshop``) calls before starting
370
+ the emulator, so it knows what to wait for. Parsing here also means ``maprt-workshop --help``
371
+ and a mistyped flag are reported at the prompt, before anything starts.
372
+ """
373
+ settings = settings_from_args(_parser(prog).parse_args(argv))
374
+ return settings.host, settings.port
375
+
376
+
377
+ if __name__ == "__main__":
378
+ main()