nds-mapviewer 2026.1.4.dev72__tar.gz → 2026.1.4.dev74__tar.gz
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.
- {nds_mapviewer-2026.1.4.dev72/src/nds_mapviewer.egg-info → nds_mapviewer-2026.1.4.dev74}/PKG-INFO +1 -1
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74/src/nds_mapviewer.egg-info}/PKG-INFO +1 -1
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/cli.py +6 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/config.py +79 -17
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/docker_client.py +7 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/tui.py +35 -9
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/viewer.py +151 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/tests/test_cli.py +2 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/tests/test_config.py +57 -1
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/tests/test_docker_client.py +4 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/tests/test_viewer.py +69 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/.gitignore +0 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/CHANGELOG.md +0 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/README.md +0 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/pyproject.toml +0 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/setup.cfg +0 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/setup.py +0 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/nds_mapviewer.egg-info/SOURCES.txt +0 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/nds_mapviewer.egg-info/dependency_links.txt +0 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/nds_mapviewer.egg-info/entry_points.txt +0 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/nds_mapviewer.egg-info/requires.txt +0 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/nds_mapviewer.egg-info/top_level.txt +0 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/__init__.py +0 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/browser.py +0 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/container_runtime.py +0 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/exceptions.py +0 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/ui.py +0 -0
- {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/tests/conftest.py +0 -0
{nds_mapviewer-2026.1.4.dev72/src/nds_mapviewer.egg-info → nds_mapviewer-2026.1.4.dev74}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nds-mapviewer
|
|
3
|
-
Version: 2026.1.4.
|
|
3
|
+
Version: 2026.1.4.dev74
|
|
4
4
|
Summary: CLI to run the NDS MapViewer Docker container for visualizing map data (NDS.Live, GeoJSON, and more)
|
|
5
5
|
Author-email: NDS Association <support@nds-association.org>
|
|
6
6
|
License: Proprietary
|
{nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74/src/nds_mapviewer.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nds-mapviewer
|
|
3
|
-
Version: 2026.1.4.
|
|
3
|
+
Version: 2026.1.4.dev74
|
|
4
4
|
Summary: CLI to run the NDS MapViewer Docker container for visualizing map data (NDS.Live, GeoJSON, and more)
|
|
5
5
|
Author-email: NDS Association <support@nds-association.org>
|
|
6
6
|
License: Proprietary
|
|
@@ -129,6 +129,7 @@ def cmd_main(args: argparse.Namespace) -> int:
|
|
|
129
129
|
bind_address=bind_address,
|
|
130
130
|
pull_policy=pull_policy,
|
|
131
131
|
run_config=config,
|
|
132
|
+
run_config_path=DEFAULT_CONFIG,
|
|
132
133
|
run_label="~/.nds/config.yaml",
|
|
133
134
|
)
|
|
134
135
|
app.run()
|
|
@@ -151,6 +152,7 @@ def cmd_main(args: argparse.Namespace) -> int:
|
|
|
151
152
|
bind_address=bind_address,
|
|
152
153
|
pull_policy=pull_policy,
|
|
153
154
|
run_config=config,
|
|
155
|
+
run_config_path=LEGACY_CONFIG,
|
|
154
156
|
run_label="~/.nds/mapviewer.yaml",
|
|
155
157
|
)
|
|
156
158
|
app.run()
|
|
@@ -173,6 +175,7 @@ def cmd_main(args: argparse.Namespace) -> int:
|
|
|
173
175
|
all_sources: list[dict] = []
|
|
174
176
|
all_http_settings: list[dict] = []
|
|
175
177
|
descriptions: list[str] = []
|
|
178
|
+
source_config_path: Path | None = None
|
|
176
179
|
|
|
177
180
|
for source_arg in source_args:
|
|
178
181
|
# Parse optional path:mapId syntax
|
|
@@ -208,6 +211,8 @@ def cmd_main(args: argparse.Namespace) -> int:
|
|
|
208
211
|
|
|
209
212
|
elif resolved.source_type in ("local_config", "saved_config"):
|
|
210
213
|
config = load_config(resolved.config_path)
|
|
214
|
+
if len(source_args) == 1:
|
|
215
|
+
source_config_path = resolved.config_path
|
|
211
216
|
descriptions.append(
|
|
212
217
|
f"saved: {resolved.config_path.stem}"
|
|
213
218
|
if resolved.source_type == "saved_config"
|
|
@@ -236,6 +241,7 @@ def cmd_main(args: argparse.Namespace) -> int:
|
|
|
236
241
|
bind_address=bind_address,
|
|
237
242
|
pull_policy=pull_policy,
|
|
238
243
|
run_config=merged,
|
|
244
|
+
run_config_path=source_config_path,
|
|
239
245
|
run_label=source_desc,
|
|
240
246
|
)
|
|
241
247
|
app.run()
|
{nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/config.py
RENAMED
|
@@ -19,6 +19,7 @@ NDS_DIR = Path.home() / ".nds"
|
|
|
19
19
|
CONFIGS_DIR = NDS_DIR / "configs"
|
|
20
20
|
DEFAULT_CONFIG = NDS_DIR / "config.yaml"
|
|
21
21
|
LEGACY_CONFIG = NDS_DIR / "mapviewer.yaml"
|
|
22
|
+
GENERATED_CONFIG_FILE = "mapviewer.yaml"
|
|
22
23
|
|
|
23
24
|
# Patterns for detecting local paths in URIs
|
|
24
25
|
FILESTORE_PATTERN = re.compile(r"^filestore:(.+)$")
|
|
@@ -243,6 +244,7 @@ def _find_common_parent(paths: list[Path]) -> dict[Path, Path]:
|
|
|
243
244
|
def transform_config(
|
|
244
245
|
config: dict[str, Any],
|
|
245
246
|
base_dir: str | Path | None = None,
|
|
247
|
+
existing_volumes: list[VolumeMount] | None = None,
|
|
246
248
|
) -> TransformedConfig:
|
|
247
249
|
"""
|
|
248
250
|
Transform a MapViewer config for Docker execution.
|
|
@@ -255,6 +257,46 @@ def transform_config(
|
|
|
255
257
|
import copy
|
|
256
258
|
|
|
257
259
|
transformed = copy.deepcopy(config)
|
|
260
|
+
existing_by_host: dict[tuple[Path, str], str] = {}
|
|
261
|
+
used_container_paths: set[str] = set()
|
|
262
|
+
for volume in existing_volumes or []:
|
|
263
|
+
host_path = volume.host_path.expanduser().resolve()
|
|
264
|
+
root = "/" + volume.container_path.strip("/").split("/", 1)[0]
|
|
265
|
+
existing_by_host[(host_path, root)] = volume.container_path
|
|
266
|
+
used_container_paths.add(volume.container_path)
|
|
267
|
+
|
|
268
|
+
assigned_by_host: dict[tuple[Path, str], str] = dict(existing_by_host)
|
|
269
|
+
|
|
270
|
+
def existing_container_path(host_path: Path, root: str) -> str | None:
|
|
271
|
+
container_path = assigned_by_host.get((host_path.expanduser().resolve(), root))
|
|
272
|
+
if container_path == root or (
|
|
273
|
+
container_path is not None
|
|
274
|
+
and container_path.startswith(f"{root}/")
|
|
275
|
+
):
|
|
276
|
+
return container_path
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
def next_container_path(root: str, preferred: str) -> str:
|
|
280
|
+
candidate = preferred
|
|
281
|
+
if candidate not in used_container_paths:
|
|
282
|
+
used_container_paths.add(candidate)
|
|
283
|
+
return candidate
|
|
284
|
+
|
|
285
|
+
index = 0
|
|
286
|
+
while True:
|
|
287
|
+
candidate = f"{root}/{index}"
|
|
288
|
+
if candidate not in used_container_paths:
|
|
289
|
+
used_container_paths.add(candidate)
|
|
290
|
+
return candidate
|
|
291
|
+
index += 1
|
|
292
|
+
|
|
293
|
+
def assign_volume(host_path: Path, root: str, preferred: str) -> str:
|
|
294
|
+
host_path = host_path.expanduser().resolve()
|
|
295
|
+
container_path = existing_container_path(host_path, root)
|
|
296
|
+
if container_path is None:
|
|
297
|
+
container_path = next_container_path(root, preferred)
|
|
298
|
+
assigned_by_host[(host_path, root)] = container_path
|
|
299
|
+
return container_path
|
|
258
300
|
local_paths: list[Path] = []
|
|
259
301
|
path_locations: list[tuple[str, int, str, str]] = [] # (section, index, field, original)
|
|
260
302
|
|
|
@@ -289,9 +331,10 @@ def transform_config(
|
|
|
289
331
|
path_to_mount_parent = _find_common_parent(local_paths)
|
|
290
332
|
|
|
291
333
|
# Deduplicate mount parents
|
|
292
|
-
unique_parents =
|
|
334
|
+
unique_parents = sorted(set(path_to_mount_parent.values()), key=lambda p: p.as_posix())
|
|
293
335
|
parent_to_container_path = {
|
|
294
|
-
parent: f"/data/{idx}"
|
|
336
|
+
parent: assign_volume(parent, "/data", f"/data/{idx}")
|
|
337
|
+
for idx, parent in enumerate(unique_parents)
|
|
295
338
|
}
|
|
296
339
|
|
|
297
340
|
# Create volume mounts
|
|
@@ -331,11 +374,18 @@ def transform_config(
|
|
|
331
374
|
transformed[section][idx][field_name] = new_value
|
|
332
375
|
|
|
333
376
|
asset_volumes: list[VolumeMount] = []
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
)
|
|
377
|
+
asset_volume_keys: set[tuple[Path, str]] = set()
|
|
378
|
+
|
|
379
|
+
def add_asset_volume(host_path: Path, root: str, preferred: str) -> str:
|
|
380
|
+
resolved_host_path = host_path.expanduser().resolve()
|
|
381
|
+
container_path = assign_volume(resolved_host_path, root, preferred)
|
|
382
|
+
key = (resolved_host_path, container_path)
|
|
383
|
+
if key not in asset_volume_keys:
|
|
384
|
+
asset_volume_keys.add(key)
|
|
385
|
+
asset_volumes.append(
|
|
386
|
+
VolumeMount(host_path=resolved_host_path, container_path=container_path)
|
|
387
|
+
)
|
|
388
|
+
return container_path
|
|
339
389
|
|
|
340
390
|
style_asset_index = 0
|
|
341
391
|
background_asset_index = 0
|
|
@@ -352,17 +402,21 @@ def transform_config(
|
|
|
352
402
|
|
|
353
403
|
if url.endswith("/*"):
|
|
354
404
|
host_dir = _resolve_host_asset_path(url[:-2], base_dir)
|
|
355
|
-
container_dir =
|
|
405
|
+
container_dir = add_asset_volume(
|
|
406
|
+
host_dir,
|
|
407
|
+
CUSTOM_STYLES_ROOT,
|
|
408
|
+
f"{CUSTOM_STYLES_ROOT}/{style_asset_index}",
|
|
409
|
+
)
|
|
356
410
|
style_asset_index += 1
|
|
357
|
-
add_asset_volume(host_dir, container_dir)
|
|
358
411
|
return f"{container_dir}/*"
|
|
359
412
|
|
|
360
413
|
host_file = _resolve_host_asset_path(url, base_dir)
|
|
361
|
-
container_path = (
|
|
362
|
-
|
|
414
|
+
container_path = add_asset_volume(
|
|
415
|
+
host_file,
|
|
416
|
+
CUSTOM_STYLES_ROOT,
|
|
417
|
+
f"{CUSTOM_STYLES_ROOT}/{style_asset_index}-{host_file.name}",
|
|
363
418
|
)
|
|
364
419
|
style_asset_index += 1
|
|
365
|
-
add_asset_volume(host_file, container_path)
|
|
366
420
|
return container_path
|
|
367
421
|
|
|
368
422
|
def transform_background_template(template: str) -> str:
|
|
@@ -376,9 +430,12 @@ def transform_config(
|
|
|
376
430
|
return template
|
|
377
431
|
|
|
378
432
|
host_root, suffix = _split_background_template(template, base_dir)
|
|
379
|
-
container_dir =
|
|
433
|
+
container_dir = add_asset_volume(
|
|
434
|
+
host_root,
|
|
435
|
+
CUSTOM_BACKGROUNDS_ROOT,
|
|
436
|
+
f"{CUSTOM_BACKGROUNDS_ROOT}/{background_asset_index}",
|
|
437
|
+
)
|
|
380
438
|
background_asset_index += 1
|
|
381
|
-
add_asset_volume(host_root, container_dir)
|
|
382
439
|
if not suffix:
|
|
383
440
|
return container_dir
|
|
384
441
|
return f"{container_dir}/{suffix}"
|
|
@@ -422,14 +479,19 @@ def write_config_to_temp(config: dict[str, Any]) -> Path:
|
|
|
422
479
|
base_temp.mkdir(parents=True, exist_ok=True)
|
|
423
480
|
|
|
424
481
|
temp_dir = Path(tempfile.mkdtemp(prefix="mapviewer-", dir=base_temp))
|
|
425
|
-
|
|
482
|
+
write_config_file(config, temp_dir / GENERATED_CONFIG_FILE)
|
|
483
|
+
|
|
484
|
+
return temp_dir
|
|
426
485
|
|
|
486
|
+
|
|
487
|
+
def write_config_file(config: dict[str, Any], config_file: str | Path) -> None:
|
|
488
|
+
"""Write a generated MapViewer config file with restrictive permissions."""
|
|
489
|
+
config_file = Path(config_file)
|
|
490
|
+
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
427
491
|
fd = os.open(str(config_file), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
428
492
|
with os.fdopen(fd, "w") as f:
|
|
429
493
|
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
430
494
|
|
|
431
|
-
return temp_dir
|
|
432
|
-
|
|
433
495
|
|
|
434
496
|
def validate_paths(
|
|
435
497
|
config: dict[str, Any],
|
{nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/docker_client.py
RENAMED
|
@@ -673,6 +673,13 @@ class ContainerLogStream:
|
|
|
673
673
|
def close(self, timeout: float = 3.0) -> None:
|
|
674
674
|
if self._proc.poll() is not None:
|
|
675
675
|
return
|
|
676
|
+
for pipe in (getattr(self._proc, "stdout", None), getattr(self._proc, "stderr", None)):
|
|
677
|
+
if pipe is None:
|
|
678
|
+
continue
|
|
679
|
+
try:
|
|
680
|
+
pipe.close()
|
|
681
|
+
except Exception:
|
|
682
|
+
pass
|
|
676
683
|
self._proc.terminate()
|
|
677
684
|
try:
|
|
678
685
|
self._proc.wait(timeout=timeout)
|
|
@@ -1140,6 +1140,7 @@ class RunScreen(Screen):
|
|
|
1140
1140
|
def __init__(
|
|
1141
1141
|
self,
|
|
1142
1142
|
config: dict[str, Any] | None = None,
|
|
1143
|
+
config_path: str | Path | None = None,
|
|
1143
1144
|
label: str = "",
|
|
1144
1145
|
connect_name: str | None = None,
|
|
1145
1146
|
saved_config: str | None = None,
|
|
@@ -1148,6 +1149,7 @@ class RunScreen(Screen):
|
|
|
1148
1149
|
) -> None:
|
|
1149
1150
|
super().__init__()
|
|
1150
1151
|
self._config = config
|
|
1152
|
+
self._config_path = Path(config_path).expanduser().resolve() if config_path else None
|
|
1151
1153
|
self._label = label
|
|
1152
1154
|
self._connect_name = connect_name
|
|
1153
1155
|
self._saved_config = saved_config
|
|
@@ -1158,6 +1160,7 @@ class RunScreen(Screen):
|
|
|
1158
1160
|
self._log_buffer: deque[str] = deque(maxlen=500)
|
|
1159
1161
|
self._alert_buffer: deque[str] = deque(maxlen=200)
|
|
1160
1162
|
self._filter_index = 0
|
|
1163
|
+
self._quitting = False
|
|
1161
1164
|
|
|
1162
1165
|
def compose(self) -> ComposeResult:
|
|
1163
1166
|
yield Header()
|
|
@@ -1186,7 +1189,7 @@ class RunScreen(Screen):
|
|
|
1186
1189
|
self._connect_to_existing()
|
|
1187
1190
|
elif self._saved_config:
|
|
1188
1191
|
self._start_from_saved()
|
|
1189
|
-
elif self._config:
|
|
1192
|
+
elif self._config is not None or self._config_path is not None:
|
|
1190
1193
|
self._start_container()
|
|
1191
1194
|
|
|
1192
1195
|
# -- Log buffer & filtering --
|
|
@@ -1306,6 +1309,8 @@ class RunScreen(Screen):
|
|
|
1306
1309
|
self.app.push_screen(HelpScreen())
|
|
1307
1310
|
|
|
1308
1311
|
def action_quit_and_stop(self) -> None:
|
|
1312
|
+
if self._quitting:
|
|
1313
|
+
return
|
|
1309
1314
|
# Confirm before tearing down a running MapViewer; an accidental
|
|
1310
1315
|
# `q` should not throw away the container the user just started.
|
|
1311
1316
|
if self._mv is not None and self._running:
|
|
@@ -1402,12 +1407,18 @@ class RunScreen(Screen):
|
|
|
1402
1407
|
|
|
1403
1408
|
@work(thread=True)
|
|
1404
1409
|
def _do_quit(self) -> None:
|
|
1410
|
+
self._quitting = True
|
|
1411
|
+
if self._mv:
|
|
1412
|
+
try:
|
|
1413
|
+
self._mv._close_log_stream()
|
|
1414
|
+
except Exception:
|
|
1415
|
+
pass
|
|
1405
1416
|
if self._mv and self._running:
|
|
1406
1417
|
try:
|
|
1418
|
+
self._running = False
|
|
1407
1419
|
self._mv.stop()
|
|
1408
1420
|
except Exception:
|
|
1409
1421
|
pass
|
|
1410
|
-
self._running = False
|
|
1411
1422
|
self.app.call_from_thread(self.app.exit)
|
|
1412
1423
|
|
|
1413
1424
|
def action_open_docker(self) -> None:
|
|
@@ -1444,10 +1455,15 @@ class RunScreen(Screen):
|
|
|
1444
1455
|
|
|
1445
1456
|
# Re-use _do_start which reads edition/version from TuiState
|
|
1446
1457
|
config_path = None
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1458
|
+
hot_reload = True
|
|
1459
|
+
if not self._is_demo:
|
|
1460
|
+
if self._config_path is not None:
|
|
1461
|
+
config_path = self._config_path
|
|
1462
|
+
elif self._config is not None:
|
|
1463
|
+
config_dir = write_config_to_temp(self._config)
|
|
1464
|
+
config_path = config_dir / "mapviewer.yaml"
|
|
1465
|
+
hot_reload = False
|
|
1466
|
+
self._do_start(config_path=config_path, hot_reload=hot_reload)
|
|
1451
1467
|
|
|
1452
1468
|
# -- Switch saved config --
|
|
1453
1469
|
|
|
@@ -1491,6 +1507,7 @@ class RunScreen(Screen):
|
|
|
1491
1507
|
self._saved_config = config_name
|
|
1492
1508
|
self._label = f"saved: {config_name}"
|
|
1493
1509
|
self._is_demo = False
|
|
1510
|
+
self._config_path = config_path
|
|
1494
1511
|
self._do_start(config_path=config_path)
|
|
1495
1512
|
|
|
1496
1513
|
# -- Connect to existing container --
|
|
@@ -1537,6 +1554,7 @@ class RunScreen(Screen):
|
|
|
1537
1554
|
)
|
|
1538
1555
|
return
|
|
1539
1556
|
self._label = f"saved: {self._saved_config}"
|
|
1557
|
+
self._config_path = config_path
|
|
1540
1558
|
self.app.call_from_thread(self._set_header, "starting")
|
|
1541
1559
|
self._do_start(config_path=config_path)
|
|
1542
1560
|
|
|
@@ -1549,12 +1567,14 @@ class RunScreen(Screen):
|
|
|
1549
1567
|
|
|
1550
1568
|
if self._is_demo:
|
|
1551
1569
|
self._do_start(config_path=None)
|
|
1570
|
+
elif self._config_path is not None:
|
|
1571
|
+
self._do_start(config_path=self._config_path)
|
|
1552
1572
|
else:
|
|
1553
1573
|
config_dir = write_config_to_temp(self._config)
|
|
1554
1574
|
config_file = config_dir / "mapviewer.yaml"
|
|
1555
|
-
self._do_start(config_path=config_file)
|
|
1575
|
+
self._do_start(config_path=config_file, hot_reload=False)
|
|
1556
1576
|
|
|
1557
|
-
def _do_start(self, config_path: Path | None) -> None:
|
|
1577
|
+
def _do_start(self, config_path: Path | None, hot_reload: bool = True) -> None:
|
|
1558
1578
|
"""Shared start logic — runs inside a worker thread."""
|
|
1559
1579
|
state: TuiState = self.app.tui_state
|
|
1560
1580
|
edition = state.edition
|
|
@@ -1636,6 +1656,7 @@ class RunScreen(Screen):
|
|
|
1636
1656
|
mv = MapViewer(
|
|
1637
1657
|
config=config_path, edition=edition, version=version,
|
|
1638
1658
|
port=state.port, bind_address=state.bind_address,
|
|
1659
|
+
hot_reload=hot_reload,
|
|
1639
1660
|
)
|
|
1640
1661
|
self._mv = mv
|
|
1641
1662
|
self.app.call_from_thread(
|
|
@@ -1731,6 +1752,8 @@ class RunScreen(Screen):
|
|
|
1731
1752
|
if stripped:
|
|
1732
1753
|
self.app.call_from_thread(self._log, stripped)
|
|
1733
1754
|
except Exception:
|
|
1755
|
+
if not getattr(self, "_running", False):
|
|
1756
|
+
return
|
|
1734
1757
|
self.app.call_from_thread(self._log, "Log stream ended")
|
|
1735
1758
|
|
|
1736
1759
|
|
|
@@ -2501,6 +2524,7 @@ class MapViewerApp(App):
|
|
|
2501
2524
|
bind_address: str = "127.0.0.1",
|
|
2502
2525
|
pull_policy: str = "prompt",
|
|
2503
2526
|
run_config: dict[str, Any] | None = None,
|
|
2527
|
+
run_config_path: str | Path | None = None,
|
|
2504
2528
|
run_label: str = "",
|
|
2505
2529
|
is_demo: bool = False,
|
|
2506
2530
|
) -> None:
|
|
@@ -2514,13 +2538,15 @@ class MapViewerApp(App):
|
|
|
2514
2538
|
pull_policy=pull_policy,
|
|
2515
2539
|
)
|
|
2516
2540
|
self._run_config = run_config
|
|
2541
|
+
self._run_config_path = Path(run_config_path).expanduser().resolve() if run_config_path else None
|
|
2517
2542
|
self._run_label = run_label
|
|
2518
2543
|
self._is_demo = is_demo
|
|
2519
2544
|
|
|
2520
2545
|
def on_mount(self) -> None:
|
|
2521
|
-
if self._run_config is not None:
|
|
2546
|
+
if self._run_config is not None or self._run_config_path is not None:
|
|
2522
2547
|
self.push_screen(RunScreen(
|
|
2523
2548
|
config=self._run_config,
|
|
2549
|
+
config_path=self._run_config_path,
|
|
2524
2550
|
label=self._run_label,
|
|
2525
2551
|
exit_on_back=True,
|
|
2526
2552
|
is_demo=self._is_demo,
|
{nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/viewer.py
RENAMED
|
@@ -10,10 +10,13 @@ from typing import Callable, Iterator
|
|
|
10
10
|
from . import __default_image__ as _default_image
|
|
11
11
|
from . import docker_client
|
|
12
12
|
from .config import (
|
|
13
|
+
GENERATED_CONFIG_FILE,
|
|
13
14
|
TransformedConfig,
|
|
15
|
+
VolumeMount,
|
|
14
16
|
load_config,
|
|
15
17
|
transform_config,
|
|
16
18
|
validate_paths,
|
|
19
|
+
write_config_file,
|
|
17
20
|
write_config_to_temp,
|
|
18
21
|
)
|
|
19
22
|
from .docker_client import ContainerInfo, ContainerLogStream
|
|
@@ -85,6 +88,11 @@ class MapViewer:
|
|
|
85
88
|
instance._original_config = None
|
|
86
89
|
instance._image_tag = info.image or None
|
|
87
90
|
instance._log_stream = None
|
|
91
|
+
instance._hot_reload = False
|
|
92
|
+
instance._config_output_file = None
|
|
93
|
+
instance._config_sync_stop = None
|
|
94
|
+
instance._config_sync_thread = None
|
|
95
|
+
instance._config_reload_callback = None
|
|
88
96
|
return instance
|
|
89
97
|
|
|
90
98
|
def __init__(
|
|
@@ -96,6 +104,7 @@ class MapViewer:
|
|
|
96
104
|
bind_address: str = "127.0.0.1",
|
|
97
105
|
demo: bool = False,
|
|
98
106
|
name: str = docker_client.CONTAINER_NAME,
|
|
107
|
+
hot_reload: bool = True,
|
|
99
108
|
):
|
|
100
109
|
"""
|
|
101
110
|
Initialize a MapViewer instance.
|
|
@@ -157,6 +166,11 @@ class MapViewer:
|
|
|
157
166
|
self._original_config: dict | None = None
|
|
158
167
|
self._image_tag: str | None = None
|
|
159
168
|
self._log_stream: ContainerLogStream | None = None
|
|
169
|
+
self._hot_reload = hot_reload
|
|
170
|
+
self._config_output_file: Path | None = None
|
|
171
|
+
self._config_sync_stop = None
|
|
172
|
+
self._config_sync_thread = None
|
|
173
|
+
self._config_reload_callback: Callable[[str], None] | None = None
|
|
160
174
|
|
|
161
175
|
@property
|
|
162
176
|
def url(self) -> str:
|
|
@@ -269,10 +283,12 @@ class MapViewer:
|
|
|
269
283
|
if self._transformed is not None:
|
|
270
284
|
# Write transformed config to temp directory
|
|
271
285
|
self._temp_config_dir = write_config_to_temp(self._transformed.config)
|
|
286
|
+
self._config_output_file = self._temp_config_dir / GENERATED_CONFIG_FILE
|
|
272
287
|
volumes = self._transformed.get_volume_args()
|
|
273
288
|
else:
|
|
274
289
|
# Demo mode: no config mount, container uses built-in config
|
|
275
290
|
self._temp_config_dir = None
|
|
291
|
+
self._config_output_file = None
|
|
276
292
|
volumes = {}
|
|
277
293
|
|
|
278
294
|
# Ensure image is available
|
|
@@ -318,6 +334,8 @@ class MapViewer:
|
|
|
318
334
|
f"{hint}"
|
|
319
335
|
)
|
|
320
336
|
|
|
337
|
+
self._start_config_sync(log_callback)
|
|
338
|
+
|
|
321
339
|
def stop(self, remove: bool = True) -> None:
|
|
322
340
|
"""
|
|
323
341
|
Stop the MapViewer container.
|
|
@@ -325,6 +343,7 @@ class MapViewer:
|
|
|
325
343
|
Args:
|
|
326
344
|
remove: Also remove the container after stopping
|
|
327
345
|
"""
|
|
346
|
+
self._stop_config_sync()
|
|
328
347
|
self._close_log_stream()
|
|
329
348
|
try:
|
|
330
349
|
docker_client.stop_container(self._name, remove=remove)
|
|
@@ -341,6 +360,136 @@ class MapViewer:
|
|
|
341
360
|
pass
|
|
342
361
|
self._log_stream = None
|
|
343
362
|
|
|
363
|
+
def _log_config_reload(self, message: str) -> None:
|
|
364
|
+
callback = getattr(self, "_config_reload_callback", None)
|
|
365
|
+
if callback is not None:
|
|
366
|
+
try:
|
|
367
|
+
callback(message)
|
|
368
|
+
except Exception:
|
|
369
|
+
pass
|
|
370
|
+
|
|
371
|
+
@staticmethod
|
|
372
|
+
def _file_stamp(path: Path) -> tuple[int, int] | None:
|
|
373
|
+
try:
|
|
374
|
+
stat = path.stat()
|
|
375
|
+
except OSError:
|
|
376
|
+
return None
|
|
377
|
+
return stat.st_mtime_ns, stat.st_size
|
|
378
|
+
|
|
379
|
+
@staticmethod
|
|
380
|
+
def _volume_key(volume: VolumeMount) -> tuple[Path, str]:
|
|
381
|
+
return volume.host_path.expanduser().resolve(), volume.container_path
|
|
382
|
+
|
|
383
|
+
def _unmounted_volumes(
|
|
384
|
+
self,
|
|
385
|
+
volumes: list[VolumeMount],
|
|
386
|
+
active_volumes: list[VolumeMount],
|
|
387
|
+
) -> list[VolumeMount]:
|
|
388
|
+
active = {self._volume_key(volume) for volume in active_volumes}
|
|
389
|
+
return [
|
|
390
|
+
volume for volume in volumes
|
|
391
|
+
if self._volume_key(volume) not in active
|
|
392
|
+
]
|
|
393
|
+
|
|
394
|
+
def _start_config_sync(
|
|
395
|
+
self,
|
|
396
|
+
log_callback: Callable[[str], None] | None,
|
|
397
|
+
) -> None:
|
|
398
|
+
if (
|
|
399
|
+
not self._hot_reload
|
|
400
|
+
or self._demo
|
|
401
|
+
or self._config_path is None
|
|
402
|
+
or self._config_output_file is None
|
|
403
|
+
or self._transformed is None
|
|
404
|
+
):
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
import threading
|
|
408
|
+
|
|
409
|
+
self._stop_config_sync()
|
|
410
|
+
self._config_reload_callback = log_callback
|
|
411
|
+
self._config_sync_stop = threading.Event()
|
|
412
|
+
self._config_sync_thread = threading.Thread(
|
|
413
|
+
target=self._config_sync_loop,
|
|
414
|
+
name=f"{self._name}-config-sync",
|
|
415
|
+
args=(
|
|
416
|
+
self._config_path,
|
|
417
|
+
self._config_output_file,
|
|
418
|
+
list(self._transformed.volumes),
|
|
419
|
+
self._config_sync_stop,
|
|
420
|
+
),
|
|
421
|
+
daemon=True,
|
|
422
|
+
)
|
|
423
|
+
self._config_sync_thread.start()
|
|
424
|
+
|
|
425
|
+
def _stop_config_sync(self) -> None:
|
|
426
|
+
import threading
|
|
427
|
+
|
|
428
|
+
stop_event = getattr(self, "_config_sync_stop", None)
|
|
429
|
+
thread = getattr(self, "_config_sync_thread", None)
|
|
430
|
+
if stop_event is not None:
|
|
431
|
+
stop_event.set()
|
|
432
|
+
if (
|
|
433
|
+
thread is not None
|
|
434
|
+
and thread.is_alive()
|
|
435
|
+
and thread is not threading.current_thread()
|
|
436
|
+
):
|
|
437
|
+
thread.join(timeout=2.0)
|
|
438
|
+
self._config_sync_stop = None
|
|
439
|
+
self._config_sync_thread = None
|
|
440
|
+
self._config_reload_callback = None
|
|
441
|
+
|
|
442
|
+
def _config_sync_loop(
|
|
443
|
+
self,
|
|
444
|
+
source_file: Path,
|
|
445
|
+
output_file: Path,
|
|
446
|
+
active_volumes: list[VolumeMount],
|
|
447
|
+
stop_event,
|
|
448
|
+
) -> None:
|
|
449
|
+
last_stamp = self._file_stamp(source_file)
|
|
450
|
+
while not stop_event.wait(1.0):
|
|
451
|
+
current_stamp = self._file_stamp(source_file)
|
|
452
|
+
if current_stamp is None or current_stamp == last_stamp:
|
|
453
|
+
continue
|
|
454
|
+
last_stamp = current_stamp
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
config = load_config(source_file)
|
|
458
|
+
errors = validate_paths(config, base_dir=source_file.parent)
|
|
459
|
+
if errors:
|
|
460
|
+
self._log_config_reload(
|
|
461
|
+
"Config change ignored; missing paths:\n "
|
|
462
|
+
+ "\n ".join(errors)
|
|
463
|
+
)
|
|
464
|
+
continue
|
|
465
|
+
|
|
466
|
+
transformed = transform_config(
|
|
467
|
+
config,
|
|
468
|
+
base_dir=source_file.parent,
|
|
469
|
+
existing_volumes=active_volumes,
|
|
470
|
+
)
|
|
471
|
+
missing_mounts = self._unmounted_volumes(
|
|
472
|
+
transformed.volumes,
|
|
473
|
+
active_volumes,
|
|
474
|
+
)
|
|
475
|
+
if missing_mounts:
|
|
476
|
+
mounts = ", ".join(
|
|
477
|
+
f"{mount.host_path} -> {mount.container_path}"
|
|
478
|
+
for mount in missing_mounts
|
|
479
|
+
)
|
|
480
|
+
self._log_config_reload(
|
|
481
|
+
"Config change requires new Docker volume mounts; "
|
|
482
|
+
f"restart MapViewer to apply: {mounts}"
|
|
483
|
+
)
|
|
484
|
+
continue
|
|
485
|
+
|
|
486
|
+
write_config_file(transformed.config, output_file)
|
|
487
|
+
self._original_config = config
|
|
488
|
+
self._transformed = transformed
|
|
489
|
+
self._log_config_reload("Reloaded mapviewer config")
|
|
490
|
+
except Exception as e:
|
|
491
|
+
self._log_config_reload(f"Config change ignored: {e}")
|
|
492
|
+
|
|
344
493
|
def restart(self, pull: bool = False, wait: bool = True, timeout: int = 30) -> None:
|
|
345
494
|
"""
|
|
346
495
|
Restart the MapViewer container.
|
|
@@ -465,6 +614,7 @@ class MapViewer:
|
|
|
465
614
|
|
|
466
615
|
def _cleanup(self) -> None:
|
|
467
616
|
"""Clean up temporary files."""
|
|
617
|
+
self._stop_config_sync()
|
|
468
618
|
temp_dir = getattr(self, "_temp_config_dir", None)
|
|
469
619
|
if temp_dir and temp_dir.exists():
|
|
470
620
|
try:
|
|
@@ -472,6 +622,7 @@ class MapViewer:
|
|
|
472
622
|
except OSError:
|
|
473
623
|
pass
|
|
474
624
|
self._temp_config_dir = None
|
|
625
|
+
self._config_output_file = None
|
|
475
626
|
|
|
476
627
|
def __enter__(self) -> MapViewer:
|
|
477
628
|
"""Context manager entry."""
|
|
@@ -101,6 +101,7 @@ class TestCliLegacyMigration:
|
|
|
101
101
|
def fake_run(self):
|
|
102
102
|
app_kwargs.update({
|
|
103
103
|
"run_config": self._run_config,
|
|
104
|
+
"run_config_path": self._run_config_path,
|
|
104
105
|
"run_label": self._run_label,
|
|
105
106
|
})
|
|
106
107
|
monkeypatch.setattr(
|
|
@@ -128,6 +129,7 @@ class TestCliLegacyMigration:
|
|
|
128
129
|
assert not new_config.exists()
|
|
129
130
|
# Config was loaded and passed to the app
|
|
130
131
|
assert app_kwargs["run_config"] == {"sources": []}
|
|
132
|
+
assert app_kwargs["run_config_path"] == legacy.resolve()
|
|
131
133
|
assert "mapviewer.yaml" in app_kwargs["run_label"]
|
|
132
134
|
# Deprecation warning was printed
|
|
133
135
|
captured = capsys.readouterr()
|
|
@@ -333,7 +333,7 @@ class TestTransformConfig:
|
|
|
333
333
|
"/custom-backgrounds/0/{z}/{x}/{y}.png"
|
|
334
334
|
)
|
|
335
335
|
assert layers[1]["urlTemplate"][0] == (
|
|
336
|
-
"/custom-backgrounds/
|
|
336
|
+
"/custom-backgrounds/0/tile-{z}-{x}-{y}.png"
|
|
337
337
|
)
|
|
338
338
|
assert layers[1]["urlTemplate"][1] == "https://example.com/{z}/{x}/{y}.png"
|
|
339
339
|
assert layers[1]["urlTemplate"][2] == (
|
|
@@ -342,6 +342,62 @@ class TestTransformConfig:
|
|
|
342
342
|
assert layers[2]["url"] == "service"
|
|
343
343
|
assert any(_same_path(vol.host_path, imagery_dir) for vol in result.volumes)
|
|
344
344
|
|
|
345
|
+
def test_existing_volume_mapping_is_reused(self, temp_dir):
|
|
346
|
+
"""Hot reloads keep container paths stable when entries are removed."""
|
|
347
|
+
first = temp_dir / "first"
|
|
348
|
+
second = temp_dir / "second"
|
|
349
|
+
first.mkdir()
|
|
350
|
+
second.mkdir()
|
|
351
|
+
|
|
352
|
+
initial = transform_config({
|
|
353
|
+
"sources": [
|
|
354
|
+
{"type": "ClassicFolder", "folder": str(first), "mapId": "A"},
|
|
355
|
+
{"type": "ClassicFolder", "folder": str(second), "mapId": "B"},
|
|
356
|
+
]
|
|
357
|
+
})
|
|
358
|
+
initial_mounts = {
|
|
359
|
+
vol.host_path.resolve(): vol.container_path
|
|
360
|
+
for vol in initial.volumes
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
reloaded = transform_config(
|
|
364
|
+
{
|
|
365
|
+
"sources": [
|
|
366
|
+
{
|
|
367
|
+
"type": "ClassicFolder",
|
|
368
|
+
"folder": str(second),
|
|
369
|
+
"mapId": "B",
|
|
370
|
+
}
|
|
371
|
+
]
|
|
372
|
+
},
|
|
373
|
+
existing_volumes=initial.volumes,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
assert reloaded.config["sources"][0]["folder"] == initial_mounts[second.resolve()]
|
|
377
|
+
|
|
378
|
+
def test_existing_asset_mapping_is_reused(self, temp_dir):
|
|
379
|
+
"""Hot reloads keep custom asset URLs stable when entries are reordered."""
|
|
380
|
+
styles = temp_dir / "styles"
|
|
381
|
+
styles.mkdir()
|
|
382
|
+
first = styles / "first.yaml"
|
|
383
|
+
second = styles / "second.yaml"
|
|
384
|
+
first.write_text("name: First\n")
|
|
385
|
+
second.write_text("name: Second\n")
|
|
386
|
+
|
|
387
|
+
initial = transform_config(
|
|
388
|
+
{"erdblick": {"additionalStyles": ["styles/first.yaml", "styles/second.yaml"]}},
|
|
389
|
+
base_dir=temp_dir,
|
|
390
|
+
)
|
|
391
|
+
initial_styles = initial.config["erdblick"]["additionalStyles"]
|
|
392
|
+
|
|
393
|
+
reloaded = transform_config(
|
|
394
|
+
{"erdblick": {"additionalStyles": ["styles/second.yaml"]}},
|
|
395
|
+
base_dir=temp_dir,
|
|
396
|
+
existing_volumes=initial.volumes,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
assert reloaded.config["erdblick"]["additionalStyles"][0] == initial_styles[1]
|
|
400
|
+
|
|
345
401
|
|
|
346
402
|
class TestValidatePaths:
|
|
347
403
|
"""Tests for validate_paths()."""
|
|
@@ -392,11 +392,15 @@ class TestContainerLogStream:
|
|
|
392
392
|
def test_close_terminates_and_waits(self, mocker):
|
|
393
393
|
proc = mocker.MagicMock()
|
|
394
394
|
proc.poll.return_value = None
|
|
395
|
+
proc.stdout = mocker.MagicMock()
|
|
396
|
+
proc.stderr = mocker.MagicMock()
|
|
395
397
|
proc.wait.return_value = 0
|
|
396
398
|
stream = dc.ContainerLogStream(proc)
|
|
397
399
|
|
|
398
400
|
stream.close(timeout=1.5)
|
|
399
401
|
|
|
402
|
+
proc.stdout.close.assert_called_once()
|
|
403
|
+
proc.stderr.close.assert_called_once()
|
|
400
404
|
proc.terminate.assert_called_once()
|
|
401
405
|
proc.wait.assert_called_once_with(timeout=1.5)
|
|
402
406
|
proc.kill.assert_not_called()
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
"""Tests for MapViewer class."""
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
|
+
import time
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
7
|
+
import yaml
|
|
6
8
|
|
|
7
9
|
from ndslive.mapviewer import MapViewer, __default_image__
|
|
10
|
+
from ndslive.mapviewer import docker_client
|
|
8
11
|
from ndslive.mapviewer.exceptions import (
|
|
9
12
|
ConfigError,
|
|
10
13
|
ContainerNotFound,
|
|
@@ -147,6 +150,72 @@ class TestMapViewerConfigTransformation:
|
|
|
147
150
|
assert data_dir.resolve() in host_paths
|
|
148
151
|
|
|
149
152
|
|
|
153
|
+
class TestMapViewerConfigHotReload:
|
|
154
|
+
"""Tests for syncing edited host configs into the mounted Docker config."""
|
|
155
|
+
|
|
156
|
+
def test_start_syncs_config_file_changes(self, temp_dir, mocker):
|
|
157
|
+
config_path = temp_dir / "config.yaml"
|
|
158
|
+
config_path.write_text(
|
|
159
|
+
"sources:\n"
|
|
160
|
+
" - type: SmartLayerTileService\n"
|
|
161
|
+
" uri: https://example.com/openapi.json\n"
|
|
162
|
+
" mapId: Old\n"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
mocker.patch.object(docker_client, "get_docker_client")
|
|
166
|
+
mocker.patch.object(docker_client, "ensure_logged_in")
|
|
167
|
+
mocker.patch.object(docker_client, "image_exists", return_value=True)
|
|
168
|
+
mocker.patch.object(
|
|
169
|
+
docker_client,
|
|
170
|
+
"get_image_tag",
|
|
171
|
+
return_value="registry/nds-mapviewer:test-amd64",
|
|
172
|
+
)
|
|
173
|
+
mocker.patch.object(
|
|
174
|
+
docker_client,
|
|
175
|
+
"run_container",
|
|
176
|
+
return_value=docker_client.ContainerInfo(
|
|
177
|
+
name="test-viewer",
|
|
178
|
+
id="abc",
|
|
179
|
+
status="running",
|
|
180
|
+
image="registry/nds-mapviewer:test-amd64",
|
|
181
|
+
host_port=8089,
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
mocker.patch.object(
|
|
185
|
+
docker_client,
|
|
186
|
+
"wait_for_container_ready",
|
|
187
|
+
return_value=True,
|
|
188
|
+
)
|
|
189
|
+
mocker.patch.object(docker_client, "stop_container", return_value=True)
|
|
190
|
+
|
|
191
|
+
logs: list[str] = []
|
|
192
|
+
mv = MapViewer(config=config_path, name="test-viewer")
|
|
193
|
+
mv.start(pull=False, wait=True, log_callback=logs.append)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
output_file = mv._config_output_file
|
|
197
|
+
assert output_file is not None
|
|
198
|
+
config_path.write_text(
|
|
199
|
+
"sources:\n"
|
|
200
|
+
" - type: SmartLayerTileService\n"
|
|
201
|
+
" uri: https://example.com/openapi.json\n"
|
|
202
|
+
" mapId: New\n"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
deadline = time.time() + 4
|
|
206
|
+
while time.time() < deadline:
|
|
207
|
+
synced = yaml.safe_load(output_file.read_text())
|
|
208
|
+
if synced["sources"][0]["mapId"] == "New":
|
|
209
|
+
break
|
|
210
|
+
time.sleep(0.1)
|
|
211
|
+
else:
|
|
212
|
+
pytest.fail("transformed config was not updated after host edit")
|
|
213
|
+
|
|
214
|
+
assert "Reloaded mapviewer config" in logs
|
|
215
|
+
finally:
|
|
216
|
+
mv.stop()
|
|
217
|
+
|
|
218
|
+
|
|
150
219
|
class TestMapViewerDemoMode:
|
|
151
220
|
"""Tests for demo mode."""
|
|
152
221
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/nds_mapviewer.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/__init__.py
RENAMED
|
File without changes
|
{nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/browser.py
RENAMED
|
File without changes
|
|
File without changes
|
{nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|