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.
Files changed (28) hide show
  1. {nds_mapviewer-2026.1.4.dev72/src/nds_mapviewer.egg-info → nds_mapviewer-2026.1.4.dev74}/PKG-INFO +1 -1
  2. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74/src/nds_mapviewer.egg-info}/PKG-INFO +1 -1
  3. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/cli.py +6 -0
  4. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/config.py +79 -17
  5. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/docker_client.py +7 -0
  6. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/tui.py +35 -9
  7. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/viewer.py +151 -0
  8. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/tests/test_cli.py +2 -0
  9. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/tests/test_config.py +57 -1
  10. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/tests/test_docker_client.py +4 -0
  11. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/tests/test_viewer.py +69 -0
  12. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/.gitignore +0 -0
  13. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/CHANGELOG.md +0 -0
  14. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/README.md +0 -0
  15. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/pyproject.toml +0 -0
  16. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/setup.cfg +0 -0
  17. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/setup.py +0 -0
  18. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/nds_mapviewer.egg-info/SOURCES.txt +0 -0
  19. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/nds_mapviewer.egg-info/dependency_links.txt +0 -0
  20. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/nds_mapviewer.egg-info/entry_points.txt +0 -0
  21. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/nds_mapviewer.egg-info/requires.txt +0 -0
  22. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/nds_mapviewer.egg-info/top_level.txt +0 -0
  23. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/__init__.py +0 -0
  24. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/browser.py +0 -0
  25. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/container_runtime.py +0 -0
  26. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/exceptions.py +0 -0
  27. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/src/ndslive/mapviewer/ui.py +0 -0
  28. {nds_mapviewer-2026.1.4.dev72 → nds_mapviewer-2026.1.4.dev74}/tests/conftest.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nds-mapviewer
3
- Version: 2026.1.4.dev72
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nds-mapviewer
3
- Version: 2026.1.4.dev72
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()
@@ -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 = list(set(path_to_mount_parent.values()))
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}" for idx, parent in enumerate(unique_parents)
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
- def add_asset_volume(host_path: Path, container_path: str) -> None:
336
- asset_volumes.append(
337
- VolumeMount(host_path=host_path, container_path=container_path)
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 = f"{CUSTOM_STYLES_ROOT}/{style_asset_index}"
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
- f"{CUSTOM_STYLES_ROOT}/{style_asset_index}-{host_file.name}"
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 = f"{CUSTOM_BACKGROUNDS_ROOT}/{background_asset_index}"
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
- config_file = temp_dir / "mapviewer.yaml"
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],
@@ -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
- if self._config and not self._is_demo:
1448
- config_dir = write_config_to_temp(self._config)
1449
- config_path = config_dir / "mapviewer.yaml"
1450
- self._do_start(config_path=config_path)
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,
@@ -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/1/tile-{z}-{x}-{y}.png"
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