nds-mapviewer 2026.1.3.dev45__tar.gz → 2026.1.3.dev47__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.dev45/src/nds_mapviewer.egg-info → nds_mapviewer-2026.1.3.dev47}/PKG-INFO +1 -1
  2. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47/src/nds_mapviewer.egg-info}/PKG-INFO +1 -1
  3. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/src/ndslive/mapviewer/container_runtime.py +41 -9
  4. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/src/ndslive/mapviewer/docker_client.py +39 -12
  5. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/src/ndslive/mapviewer/exceptions.py +11 -3
  6. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/tests/test_docker_client.py +228 -3
  7. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/.gitignore +0 -0
  8. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/CHANGELOG.md +0 -0
  9. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/README.md +0 -0
  10. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/pyproject.toml +0 -0
  11. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/setup.cfg +0 -0
  12. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/setup.py +0 -0
  13. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/src/nds_mapviewer.egg-info/SOURCES.txt +0 -0
  14. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/src/nds_mapviewer.egg-info/dependency_links.txt +0 -0
  15. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/src/nds_mapviewer.egg-info/entry_points.txt +0 -0
  16. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/src/nds_mapviewer.egg-info/requires.txt +0 -0
  17. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/src/nds_mapviewer.egg-info/top_level.txt +0 -0
  18. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/src/ndslive/mapviewer/__init__.py +0 -0
  19. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/src/ndslive/mapviewer/browser.py +0 -0
  20. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/src/ndslive/mapviewer/cli.py +0 -0
  21. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/src/ndslive/mapviewer/config.py +0 -0
  22. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/src/ndslive/mapviewer/tui.py +0 -0
  23. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/src/ndslive/mapviewer/ui.py +0 -0
  24. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/src/ndslive/mapviewer/viewer.py +0 -0
  25. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/tests/conftest.py +0 -0
  26. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/tests/test_cli.py +0 -0
  27. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/tests/test_config.py +0 -0
  28. {nds_mapviewer-2026.1.3.dev45 → nds_mapviewer-2026.1.3.dev47}/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.dev45
3
+ Version: 2026.1.3.dev47
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.dev45
3
+ Version: 2026.1.3.dev47
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
@@ -42,18 +42,44 @@ def _runtime_is_responsive(runtime: str) -> bool:
42
42
  return False
43
43
 
44
44
 
45
+ def _saved_runtime_preference() -> str | None:
46
+ """Return the ``runtime_name`` saved by ``ndslive-setup``, if any.
47
+
48
+ ``ndslive-setup`` writes ``~/.nds/setup.json`` after a successful wizard
49
+ run. When the user has explicitly configured a runtime there, we honour
50
+ that choice ahead of environment-based detection so the two tools stay
51
+ in sync.
52
+ """
53
+ config_path = Path.home() / ".nds" / "setup.json"
54
+ if not config_path.exists():
55
+ return None
56
+ try:
57
+ with open(config_path) as f:
58
+ value = json.load(f).get("runtime_name")
59
+ except (json.JSONDecodeError, OSError):
60
+ return None
61
+ if value in ("docker", "podman"):
62
+ return value
63
+ return None
64
+
65
+
45
66
  @functools.lru_cache(maxsize=1)
46
67
  def detect_runtime() -> str:
47
68
  """Return the name of the container runtime to use.
48
69
 
49
70
  Resolution order:
50
71
 
51
- 1. ``CONTAINER_RUNTIME`` environment variable (``docker`` or ``podman``).
52
- 2. ``docker`` on ``$PATH``.
53
- 3. ``podman`` on ``$PATH``.
72
+ 1. Saved ``runtime_name`` in ``~/.nds/setup.json`` (written by ``ndslive-setup``).
73
+ 2. ``CONTAINER_RUNTIME`` environment variable (``docker`` or ``podman``).
74
+ 3. ``docker`` on ``$PATH``.
75
+ 4. ``podman`` on ``$PATH``.
54
76
 
55
77
  Raises :class:`FileNotFoundError` when no runtime is found.
56
78
  """
79
+ saved = _saved_runtime_preference()
80
+ if saved and _runtime_exists(saved):
81
+ return saved
82
+
57
83
  env = os.environ.get("CONTAINER_RUNTIME", "").strip().lower()
58
84
  if env in ("docker", "podman"):
59
85
  if _runtime_exists(env):
@@ -74,7 +100,8 @@ def detect_runtime() -> str:
74
100
  "No container runtime found.\n\n"
75
101
  "Please install Docker or Podman:\n"
76
102
  " Docker: https://docs.docker.com/get-docker/\n"
77
- " Podman: https://podman.io/getting-started/installation"
103
+ " Podman: https://podman.io/getting-started/installation\n\n"
104
+ "Tip: run 'ndslive-setup doctor' for a guided diagnosis."
78
105
  )
79
106
 
80
107
 
@@ -211,9 +238,13 @@ def get_auth_config_paths() -> list[Path]:
211
238
  ``~/.docker/config.json``
212
239
 
213
240
  Podman (checked in order):
214
- ``$XDG_RUNTIME_DIR/containers/auth.json``
215
- ``~/.config/containers/auth.json``
216
- ``~/.docker/config.json`` (Podman reads this as fallback)
241
+ ``$XDG_RUNTIME_DIR/containers/auth.json`` (Linux session runtime dir)
242
+ ``~/.config/containers/auth.json`` (Linux / macOS default)
243
+ ``$APPDATA/containers/auth.json`` (Windows ``podman machine``)
244
+
245
+ Intentionally excludes ``~/.docker/config.json`` for podman: on macOS
246
+ Docker Desktop's ``credsStore`` helper runs on the host and podman
247
+ cannot invoke it, so a Docker login must not imply a Podman login.
217
248
  """
218
249
  rt = detect_runtime()
219
250
  paths: list[Path] = []
@@ -223,8 +254,9 @@ def get_auth_config_paths() -> list[Path]:
223
254
  if xdg:
224
255
  paths.append(Path(xdg) / "containers" / "auth.json")
225
256
  paths.append(Path.home() / ".config" / "containers" / "auth.json")
226
- # Podman also honours Docker's config
227
- paths.append(Path.home() / ".docker" / "config.json")
257
+ appdata = os.environ.get("APPDATA")
258
+ if appdata:
259
+ paths.append(Path(appdata) / "containers" / "auth.json")
228
260
  else:
229
261
  paths.append(Path.home() / ".docker" / "config.json")
230
262
 
@@ -222,21 +222,48 @@ def is_logged_in(edition: str) -> bool:
222
222
  except (json.JSONDecodeError, OSError):
223
223
  continue
224
224
 
225
- auths = config.get("auths", {})
226
- for key in auths:
227
- if registry_host in key or registry in key:
228
- auth_data = auths[key]
229
- if auth_data.get("auth") or auth_data.get("identitytoken"):
230
- return True
231
-
232
- cred_helpers = config.get("credHelpers", {})
233
- if any(registry_host in k for k in cred_helpers):
225
+ if _auth_config_contains(config, registry, registry_host):
234
226
  return True
235
227
 
236
- # A global credsStore is not enough evidence on its own because it
237
- # does not tell us whether this specific registry has credentials.
238
- if config.get("credsStore"):
228
+ return False
229
+
230
+
231
+ def _auth_config_contains(
232
+ config: dict, registry: str, registry_host: str,
233
+ ) -> bool:
234
+ """Return True if ``config`` has credentials for ``registry``.
235
+
236
+ A registry counts as present when:
237
+
238
+ * ``auths[key]`` has an ``auth`` or ``identitytoken`` value, or
239
+ * ``auths`` has an entry for ``key`` and the file declares a
240
+ ``credsStore`` (helper keeps the actual secret), or
241
+ * ``key`` appears in ``credHelpers`` (host or full registry path).
242
+
243
+ ``credsStore`` alone (no matching ``auths`` entry) is not enough —
244
+ it only says "some creds live in the helper", not that any exist
245
+ for this registry.
246
+ """
247
+ auths = config.get("auths", {}) or {}
248
+ creds_store = config.get("credsStore")
249
+ candidate_keys = (
250
+ registry_host,
251
+ f"https://{registry_host}",
252
+ registry,
253
+ f"https://{registry}",
254
+ )
255
+ for key in candidate_keys:
256
+ if key not in auths:
239
257
  continue
258
+ entry = auths.get(key) or {}
259
+ if entry.get("auth") or entry.get("identitytoken"):
260
+ return True
261
+ if creds_store:
262
+ return True
263
+
264
+ cred_helpers = config.get("credHelpers", {}) or {}
265
+ if any(key in cred_helpers for key in candidate_keys):
266
+ return True
240
267
 
241
268
  return False
242
269
 
@@ -1,6 +1,12 @@
1
1
  """Custom exceptions for ndslive.mapviewer."""
2
2
 
3
3
 
4
+ _DOCTOR_HINT = (
5
+ "Run 'ndslive-setup doctor' for a diagnosis, or 'ndslive-setup' to "
6
+ "reconfigure."
7
+ )
8
+
9
+
4
10
  class MapViewerError(Exception):
5
11
  """Base exception for all MapViewer errors."""
6
12
 
@@ -16,7 +22,7 @@ class RuntimeNotInstalled(MapViewerError):
16
22
  "Please install Docker or Podman:\n"
17
23
  " Docker: https://docs.docker.com/get-docker/\n"
18
24
  " Podman: https://podman.io/getting-started/installation\n\n"
19
- "After installation, run 'mapviewer setup' for guided setup."
25
+ f"{_DOCTOR_HINT}"
20
26
  )
21
27
  super().__init__(self.message)
22
28
 
@@ -34,8 +40,9 @@ class RuntimeNotRunning(MapViewerError):
34
40
  "Please start your container runtime:\n"
35
41
  " Docker (macOS/Windows): Open Docker Desktop\n"
36
42
  " Docker (Linux): sudo systemctl start docker\n"
43
+ " Podman (macOS/Windows): podman machine start\n"
37
44
  " Podman (Linux): systemctl --user start podman.socket\n\n"
38
- "Run 'mapviewer setup' for guided setup."
45
+ f"{_DOCTOR_HINT}"
39
46
  )
40
47
  super().__init__(self.message)
41
48
 
@@ -65,7 +72,7 @@ class RegistryNotLoggedIn(MapViewerError):
65
72
  " 2. Click your username (top right) -> Edit Profile\n"
66
73
  " 3. Under Identity Tokens, click Generate\n"
67
74
  f" 4. Use the token as your password for {runtime} login\n\n"
68
- "Run 'mapviewer setup' for guided setup."
75
+ f"{_DOCTOR_HINT}"
69
76
  )
70
77
  super().__init__(self.message)
71
78
 
@@ -150,6 +157,7 @@ class RegistryQueryFailed(MapViewerError):
150
157
  "\n\nPossible causes:"
151
158
  f"\n - Not logged in: run '{runtime} login {registry}'"
152
159
  "\n - Behind a proxy: configure your runtime's proxy settings"
160
+ f"\n\n{_DOCTOR_HINT}"
153
161
  )
154
162
  self.message = msg
155
163
  super().__init__(self.message)
@@ -152,6 +152,88 @@ class TestIsLoggedIn:
152
152
 
153
153
  assert dc.is_logged_in("member") is True
154
154
 
155
+ def test_creds_store_with_auths_stub_returns_true(self, mocker, temp_dir):
156
+ """credsStore + an empty auths entry for the registry counts as logged in.
157
+
158
+ Docker CLI writes ``auths[host] = {}`` and stores the real secret in
159
+ the configured credential helper. The helper alone isn't proof (could
160
+ hold creds for any registry), but the combination is.
161
+ """
162
+ config_path = temp_dir / "config.json"
163
+ config_path.write_text(json.dumps({
164
+ "auths": {"artifactory.nds-association.org": {}},
165
+ "credsStore": "osxkeychain",
166
+ }))
167
+
168
+ mock_result = mocker.MagicMock(returncode=1, stdout="")
169
+ mocker.patch.object(rt, "run", return_value=mock_result)
170
+ mocker.patch.object(rt, "get_auth_config_paths", return_value=[config_path])
171
+
172
+ assert dc.is_logged_in("member") is True
173
+
174
+ def test_creds_store_without_registry_auths_returns_false(self, mocker, temp_dir):
175
+ """credsStore + auths for an unrelated registry does not count."""
176
+ config_path = temp_dir / "config.json"
177
+ config_path.write_text(json.dumps({
178
+ "auths": {"ghcr.io": {}},
179
+ "credsStore": "osxkeychain",
180
+ }))
181
+
182
+ mock_result = mocker.MagicMock(returncode=1, stdout="")
183
+ mocker.patch.object(rt, "run", return_value=mock_result)
184
+ mocker.patch.object(rt, "get_auth_config_paths", return_value=[config_path])
185
+
186
+ assert dc.is_logged_in("member") is False
187
+
188
+ def test_namespaced_auth_key_returns_true(self, mocker, temp_dir):
189
+ """Podman stores auth under the full namespaced key."""
190
+ config_path = temp_dir / "auth.json"
191
+ config_path.write_text(json.dumps({
192
+ "auths": {
193
+ "artifactory.nds-association.org/tooling-dockerreg": {
194
+ "auth": "base64encoded",
195
+ },
196
+ },
197
+ }))
198
+
199
+ mock_result = mocker.MagicMock(returncode=1, stdout="")
200
+ mocker.patch.object(rt, "run", return_value=mock_result)
201
+ mocker.patch.object(rt, "get_auth_config_paths", return_value=[config_path])
202
+
203
+ assert dc.is_logged_in("member") is True
204
+
205
+ def test_scheme_prefixed_cred_helper_returns_true(self, mocker, temp_dir):
206
+ """``https://``-prefixed credHelpers keys are recognized."""
207
+ config_path = temp_dir / "config.json"
208
+ config_path.write_text(json.dumps({
209
+ "credHelpers": {
210
+ "https://artifactory.nds-association.org": "desktop",
211
+ },
212
+ }))
213
+
214
+ mock_result = mocker.MagicMock(returncode=1, stdout="")
215
+ mocker.patch.object(rt, "run", return_value=mock_result)
216
+ mocker.patch.object(rt, "get_auth_config_paths", return_value=[config_path])
217
+
218
+ assert dc.is_logged_in("member") is True
219
+
220
+ def test_https_prefixed_auths_key_returns_true(self, mocker, temp_dir):
221
+ """auths keys stored with an ``https://`` scheme prefix are recognized."""
222
+ config_path = temp_dir / "config.json"
223
+ config_path.write_text(json.dumps({
224
+ "auths": {
225
+ "https://artifactory.nds-association.org/tooling-dockerreg": {
226
+ "auth": "base64encoded",
227
+ },
228
+ },
229
+ }))
230
+
231
+ mock_result = mocker.MagicMock(returncode=1, stdout="")
232
+ mocker.patch.object(rt, "run", return_value=mock_result)
233
+ mocker.patch.object(rt, "get_auth_config_paths", return_value=[config_path])
234
+
235
+ assert dc.is_logged_in("member") is True
236
+
155
237
 
156
238
  class TestListContainers:
157
239
  """Tests for list_containers()."""
@@ -354,9 +436,10 @@ class TestContainerRuntime:
354
436
  runtime = rt.detect_runtime()
355
437
  assert runtime in ("docker", "podman")
356
438
 
357
- def test_detect_runtime_prefers_working_runtime(self, mocker):
439
+ def test_detect_runtime_prefers_working_runtime(self, tmp_path, mocker, monkeypatch):
358
440
  """Auto-detection should skip an installed but unhealthy runtime."""
359
441
  rt.detect_runtime.cache_clear()
442
+ monkeypatch.setattr(rt.Path, "home", lambda: tmp_path)
360
443
  mocker.patch.dict(os.environ, {"CONTAINER_RUNTIME": ""}, clear=False)
361
444
  mocker.patch.object(rt.shutil, "which", side_effect=lambda name: f"/usr/bin/{name}")
362
445
 
@@ -375,9 +458,10 @@ class TestContainerRuntime:
375
458
  finally:
376
459
  rt.detect_runtime.cache_clear()
377
460
 
378
- def test_detect_runtime_explicit_env_forces_choice(self, mocker):
461
+ def test_detect_runtime_explicit_env_forces_choice(self, tmp_path, mocker, monkeypatch):
379
462
  """CONTAINER_RUNTIME should force the selected runtime."""
380
463
  rt.detect_runtime.cache_clear()
464
+ monkeypatch.setattr(rt.Path, "home", lambda: tmp_path)
381
465
  mocker.patch.dict(os.environ, {"CONTAINER_RUNTIME": "docker"}, clear=False)
382
466
  mocker.patch.object(rt.shutil, "which", side_effect=lambda name: f"/usr/bin/{name}")
383
467
  mocker.patch.object(rt.subprocess, "run", side_effect=AssertionError("should not probe"))
@@ -386,9 +470,12 @@ class TestContainerRuntime:
386
470
  finally:
387
471
  rt.detect_runtime.cache_clear()
388
472
 
389
- def test_detect_runtime_returns_first_installed_if_none_respond(self, mocker):
473
+ def test_detect_runtime_returns_first_installed_if_none_respond(
474
+ self, tmp_path, mocker, monkeypatch,
475
+ ):
390
476
  """When no runtime is responsive, keep the first installed for later errors."""
391
477
  rt.detect_runtime.cache_clear()
478
+ monkeypatch.setattr(rt.Path, "home", lambda: tmp_path)
392
479
  mocker.patch.dict(os.environ, {"CONTAINER_RUNTIME": ""}, clear=False)
393
480
  mocker.patch.object(rt.shutil, "which", side_effect=lambda name: f"/usr/bin/{name}")
394
481
  mocker.patch.object(
@@ -416,6 +503,144 @@ class TestContainerRuntime:
416
503
  assert isinstance(paths, list)
417
504
  assert all(isinstance(p, type(paths[0])) for p in paths)
418
505
 
506
+ def test_detect_runtime_honours_setup_json(self, tmp_path, mocker, monkeypatch):
507
+ """A runtime_name in ~/.nds/setup.json wins over env/autodetect."""
508
+ rt.detect_runtime.cache_clear()
509
+ setup_dir = tmp_path / ".nds"
510
+ setup_dir.mkdir()
511
+ (setup_dir / "setup.json").write_text(json.dumps({"runtime_name": "podman"}))
512
+
513
+ monkeypatch.setattr(rt.Path, "home", lambda: tmp_path)
514
+ monkeypatch.setenv("CONTAINER_RUNTIME", "docker")
515
+ mocker.patch.object(rt.shutil, "which", side_effect=lambda name: f"/usr/bin/{name}")
516
+ mocker.patch.object(
517
+ rt.subprocess,
518
+ "run",
519
+ side_effect=AssertionError("should not probe when saved pref wins"),
520
+ )
521
+ try:
522
+ assert rt.detect_runtime() == "podman"
523
+ finally:
524
+ rt.detect_runtime.cache_clear()
525
+
526
+ def test_detect_runtime_falls_back_when_saved_missing(
527
+ self, tmp_path, mocker, monkeypatch,
528
+ ):
529
+ """If the saved runtime isn't on PATH, fall back to env/autodetect."""
530
+ rt.detect_runtime.cache_clear()
531
+ setup_dir = tmp_path / ".nds"
532
+ setup_dir.mkdir()
533
+ (setup_dir / "setup.json").write_text(json.dumps({"runtime_name": "podman"}))
534
+
535
+ monkeypatch.setattr(rt.Path, "home", lambda: tmp_path)
536
+ monkeypatch.delenv("CONTAINER_RUNTIME", raising=False)
537
+ mocker.patch.object(
538
+ rt.shutil,
539
+ "which",
540
+ side_effect=lambda name: "/usr/bin/docker" if name == "docker" else None,
541
+ )
542
+ mocker.patch.object(
543
+ rt.subprocess,
544
+ "run",
545
+ return_value=subprocess.CompletedProcess(["docker", "info"], 0, "", ""),
546
+ )
547
+ try:
548
+ assert rt.detect_runtime() == "docker"
549
+ finally:
550
+ rt.detect_runtime.cache_clear()
551
+
552
+ def test_detect_runtime_ignores_malformed_setup_json(
553
+ self, tmp_path, mocker, monkeypatch,
554
+ ):
555
+ """Garbled ~/.nds/setup.json must not break detection."""
556
+ rt.detect_runtime.cache_clear()
557
+ setup_dir = tmp_path / ".nds"
558
+ setup_dir.mkdir()
559
+ (setup_dir / "setup.json").write_text("{ not valid json")
560
+
561
+ monkeypatch.setattr(rt.Path, "home", lambda: tmp_path)
562
+ monkeypatch.delenv("CONTAINER_RUNTIME", raising=False)
563
+ mocker.patch.object(
564
+ rt.shutil,
565
+ "which",
566
+ side_effect=lambda name: "/usr/bin/docker" if name == "docker" else None,
567
+ )
568
+ mocker.patch.object(
569
+ rt.subprocess,
570
+ "run",
571
+ return_value=subprocess.CompletedProcess(["docker", "info"], 0, "", ""),
572
+ )
573
+ try:
574
+ assert rt.detect_runtime() == "docker"
575
+ finally:
576
+ rt.detect_runtime.cache_clear()
577
+
578
+ def test_detect_runtime_ignores_unknown_runtime_name(
579
+ self, tmp_path, mocker, monkeypatch,
580
+ ):
581
+ """An unrecognised runtime_name in setup.json is ignored, not rejected."""
582
+ rt.detect_runtime.cache_clear()
583
+ setup_dir = tmp_path / ".nds"
584
+ setup_dir.mkdir()
585
+ (setup_dir / "setup.json").write_text(json.dumps({"runtime_name": "rkt"}))
586
+
587
+ monkeypatch.setattr(rt.Path, "home", lambda: tmp_path)
588
+ monkeypatch.delenv("CONTAINER_RUNTIME", raising=False)
589
+ mocker.patch.object(
590
+ rt.shutil,
591
+ "which",
592
+ side_effect=lambda name: "/usr/bin/docker" if name == "docker" else None,
593
+ )
594
+ mocker.patch.object(
595
+ rt.subprocess,
596
+ "run",
597
+ return_value=subprocess.CompletedProcess(["docker", "info"], 0, "", ""),
598
+ )
599
+ try:
600
+ assert rt.detect_runtime() == "docker"
601
+ finally:
602
+ rt.detect_runtime.cache_clear()
603
+
604
+ def test_podman_auth_paths_exclude_docker_config(self, monkeypatch):
605
+ """Podman should not consider ~/.docker/config.json as a login source.
606
+
607
+ On macOS, Docker Desktop's credsStore runs on the host and podman
608
+ cannot invoke it — so a docker login must not imply a podman login.
609
+ """
610
+ rt.detect_runtime.cache_clear()
611
+ monkeypatch.setenv("CONTAINER_RUNTIME", "podman")
612
+ monkeypatch.delenv("XDG_RUNTIME_DIR", raising=False)
613
+ monkeypatch.delenv("APPDATA", raising=False)
614
+ # shutil.which needs to find podman for detect_runtime to accept env.
615
+ import shutil as _sh
616
+ monkeypatch.setattr(
617
+ rt.shutil, "which",
618
+ lambda name: "/usr/bin/podman" if name == "podman" else None,
619
+ )
620
+ try:
621
+ paths = rt.get_auth_config_paths()
622
+ docker_config = rt.Path.home() / ".docker" / "config.json"
623
+ assert docker_config not in paths
624
+ finally:
625
+ rt.detect_runtime.cache_clear()
626
+
627
+ def test_podman_auth_paths_include_appdata_on_windows(self, tmp_path, monkeypatch):
628
+ """When APPDATA is set (Windows), podman auth.json under it is probed."""
629
+ rt.detect_runtime.cache_clear()
630
+ monkeypatch.setenv("CONTAINER_RUNTIME", "podman")
631
+ monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
632
+ monkeypatch.delenv("XDG_RUNTIME_DIR", raising=False)
633
+ monkeypatch.setattr(
634
+ rt.shutil, "which",
635
+ lambda name: "/usr/bin/podman" if name == "podman" else None,
636
+ )
637
+ try:
638
+ paths = rt.get_auth_config_paths()
639
+ expected = rt.Path(str(tmp_path / "AppData" / "Roaming")) / "containers" / "auth.json"
640
+ assert expected in paths
641
+ finally:
642
+ rt.detect_runtime.cache_clear()
643
+
419
644
  def test_parse_size_string(self):
420
645
  """Parse common size format strings."""
421
646
  assert rt.parse_size_string("256MiB") == 256 * 1024 * 1024