nds-mapviewer 2026.1.3.dev43__tar.gz → 2026.1.3.dev45__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.3.dev43/src/nds_mapviewer.egg-info → nds_mapviewer-2026.1.3.dev45}/PKG-INFO +1 -1
  2. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45/src/nds_mapviewer.egg-info}/PKG-INFO +1 -1
  3. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/src/ndslive/mapviewer/docker_client.py +34 -10
  4. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/src/ndslive/mapviewer/viewer.py +21 -3
  5. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/tests/test_docker_client.py +52 -0
  6. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/.gitignore +0 -0
  7. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/CHANGELOG.md +0 -0
  8. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/README.md +0 -0
  9. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/pyproject.toml +0 -0
  10. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/setup.cfg +0 -0
  11. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/setup.py +0 -0
  12. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/src/nds_mapviewer.egg-info/SOURCES.txt +0 -0
  13. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/src/nds_mapviewer.egg-info/dependency_links.txt +0 -0
  14. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/src/nds_mapviewer.egg-info/entry_points.txt +0 -0
  15. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/src/nds_mapviewer.egg-info/requires.txt +0 -0
  16. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/src/nds_mapviewer.egg-info/top_level.txt +0 -0
  17. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/src/ndslive/mapviewer/__init__.py +0 -0
  18. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/src/ndslive/mapviewer/browser.py +0 -0
  19. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/src/ndslive/mapviewer/cli.py +0 -0
  20. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/src/ndslive/mapviewer/config.py +0 -0
  21. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/src/ndslive/mapviewer/container_runtime.py +0 -0
  22. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/src/ndslive/mapviewer/exceptions.py +0 -0
  23. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/src/ndslive/mapviewer/tui.py +0 -0
  24. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/src/ndslive/mapviewer/ui.py +0 -0
  25. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/tests/conftest.py +0 -0
  26. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/tests/test_cli.py +0 -0
  27. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/tests/test_config.py +0 -0
  28. {nds_mapviewer-2026.1.3.dev43 → nds_mapviewer-2026.1.3.dev45}/tests/test_viewer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nds-mapviewer
3
- Version: 2026.1.3.dev43
3
+ Version: 2026.1.3.dev45
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.3.dev43
3
+ Version: 2026.1.3.dev45
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
@@ -695,7 +695,7 @@ def get_container_logs(
695
695
  raise ContainerNotFound(name)
696
696
 
697
697
  if follow:
698
- return _stream_logs(name)
698
+ return ContainerLogStream(rt.stream("logs", "--follow", name))
699
699
  else:
700
700
  cmd = ["logs"]
701
701
  if tail != "all":
@@ -712,15 +712,39 @@ def get_container_logs(
712
712
  return logs
713
713
 
714
714
 
715
- def _stream_logs(name: str) -> Iterator[str]:
716
- """Generator that streams log lines from a container."""
717
- proc = rt.stream("logs", "--follow", name)
718
- try:
719
- for line in proc.stdout:
720
- yield line
721
- finally:
722
- proc.terminate()
723
- proc.wait()
715
+ class ContainerLogStream:
716
+ """Iterable log stream that can be closed from outside the iterator.
717
+
718
+ The underlying ``<runtime> logs --follow`` subprocess blocks in
719
+ ``proc.stdout`` until the container exits. Docker closes the pipe
720
+ promptly on ``stop``; Podman can keep the follower alive longer, so we
721
+ expose :meth:`close` to terminate it deterministically on shutdown.
722
+ """
723
+
724
+ def __init__(self, proc: subprocess.Popen[str]) -> None:
725
+ self._proc = proc
726
+
727
+ def __iter__(self) -> Iterator[str]:
728
+ try:
729
+ for line in self._proc.stdout:
730
+ yield line
731
+ finally:
732
+ self.close()
733
+
734
+ def close(self, timeout: float = 3.0) -> None:
735
+ if self._proc.poll() is not None:
736
+ return
737
+ self._proc.terminate()
738
+ try:
739
+ self._proc.wait(timeout=timeout)
740
+ return
741
+ except subprocess.TimeoutExpired:
742
+ pass
743
+ self._proc.kill()
744
+ try:
745
+ self._proc.wait(timeout=timeout)
746
+ except subprocess.TimeoutExpired:
747
+ pass
724
748
 
725
749
 
726
750
  _READY_SENTINEL = "NDS MapViewer is ready!"
@@ -16,7 +16,7 @@ from .config import (
16
16
  validate_paths,
17
17
  write_config_to_temp,
18
18
  )
19
- from .docker_client import ContainerInfo
19
+ from .docker_client import ContainerInfo, ContainerLogStream
20
20
  from .exceptions import ConfigError, ContainerNotFound
21
21
 
22
22
 
@@ -84,6 +84,7 @@ class MapViewer:
84
84
  instance._bind_address = "127.0.0.1"
85
85
  instance._original_config = None
86
86
  instance._image_tag = info.image or None
87
+ instance._log_stream = None
87
88
  return instance
88
89
 
89
90
  def __init__(
@@ -155,6 +156,7 @@ class MapViewer:
155
156
  self._container: ContainerInfo | None = None
156
157
  self._original_config: dict | None = None
157
158
  self._image_tag: str | None = None
159
+ self._log_stream: ContainerLogStream | None = None
158
160
 
159
161
  @property
160
162
  def url(self) -> str:
@@ -322,11 +324,22 @@ class MapViewer:
322
324
  Args:
323
325
  remove: Also remove the container after stopping
324
326
  """
327
+ self._close_log_stream()
325
328
  try:
326
329
  docker_client.stop_container(self._name, remove=remove)
327
330
  finally:
328
331
  self._cleanup()
329
332
 
333
+ def _close_log_stream(self) -> None:
334
+ stream = getattr(self, "_log_stream", None)
335
+ if stream is None:
336
+ return
337
+ try:
338
+ stream.close()
339
+ except Exception:
340
+ pass
341
+ self._log_stream = None
342
+
330
343
  def restart(self, pull: bool = False, wait: bool = True, timeout: int = 30) -> None:
331
344
  """
332
345
  Restart the MapViewer container.
@@ -361,10 +374,15 @@ class MapViewer:
361
374
  tail: Number of lines from end, or "all"
362
375
 
363
376
  Returns:
364
- If follow=True, returns an iterator of log lines.
377
+ If follow=True, returns a :class:`ContainerLogStream` (iterable,
378
+ closed automatically by :meth:`stop`).
365
379
  If follow=False, returns the full log as a string.
366
380
  """
367
- return docker_client.get_container_logs(self._name, follow=follow, tail=tail)
381
+ result = docker_client.get_container_logs(self._name, follow=follow, tail=tail)
382
+ if follow and isinstance(result, ContainerLogStream):
383
+ self._close_log_stream()
384
+ self._log_stream = result
385
+ return result
368
386
 
369
387
  def get_datasources(self) -> list[str]:
370
388
  """
@@ -198,6 +198,58 @@ class TestStopContainer:
198
198
  dc.stop_container("nonexistent-container-xyz123")
199
199
 
200
200
 
201
+ class TestContainerLogStream:
202
+ """Tests for ContainerLogStream.close() teardown behaviour."""
203
+
204
+ def test_close_skips_when_already_exited(self, mocker):
205
+ proc = mocker.MagicMock()
206
+ proc.poll.return_value = 0
207
+ stream = dc.ContainerLogStream(proc)
208
+
209
+ stream.close()
210
+
211
+ proc.terminate.assert_not_called()
212
+ proc.kill.assert_not_called()
213
+
214
+ def test_close_terminates_and_waits(self, mocker):
215
+ proc = mocker.MagicMock()
216
+ proc.poll.return_value = None
217
+ proc.wait.return_value = 0
218
+ stream = dc.ContainerLogStream(proc)
219
+
220
+ stream.close(timeout=1.5)
221
+
222
+ proc.terminate.assert_called_once()
223
+ proc.wait.assert_called_once_with(timeout=1.5)
224
+ proc.kill.assert_not_called()
225
+
226
+ def test_close_kills_when_terminate_times_out(self, mocker):
227
+ proc = mocker.MagicMock()
228
+ proc.poll.return_value = None
229
+ proc.wait.side_effect = [
230
+ subprocess.TimeoutExpired(cmd="logs", timeout=1.0),
231
+ 0,
232
+ ]
233
+ stream = dc.ContainerLogStream(proc)
234
+
235
+ stream.close(timeout=1.0)
236
+
237
+ proc.terminate.assert_called_once()
238
+ proc.kill.assert_called_once()
239
+ assert proc.wait.call_count == 2
240
+
241
+ def test_close_returns_even_if_kill_wait_times_out(self, mocker):
242
+ proc = mocker.MagicMock()
243
+ proc.poll.return_value = None
244
+ proc.wait.side_effect = subprocess.TimeoutExpired(cmd="logs", timeout=1.0)
245
+ stream = dc.ContainerLogStream(proc)
246
+
247
+ stream.close(timeout=1.0)
248
+
249
+ proc.terminate.assert_called_once()
250
+ proc.kill.assert_called_once()
251
+
252
+
201
253
  class TestPullImageErrorHandling:
202
254
  """Tests for pull_image() error handling."""
203
255