nds-mapviewer 2026.4.3.dev256__tar.gz → 2026.4.3.dev258__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 (32) hide show
  1. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/CHANGELOG.md +5 -0
  2. {nds_mapviewer-2026.4.3.dev256/src/nds_mapviewer.egg-info → nds_mapviewer-2026.4.3.dev258}/PKG-INFO +13 -4
  3. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/README.md +12 -3
  4. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258/src/nds_mapviewer.egg-info}/PKG-INFO +13 -4
  5. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/src/ndslive/mapviewer/cli.py +16 -6
  6. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/src/ndslive/mapviewer/config.py +70 -5
  7. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/src/ndslive/mapviewer/tui.py +3 -0
  8. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/tests/conftest.py +24 -0
  9. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/tests/test_cli.py +36 -0
  10. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/tests/test_config.py +87 -2
  11. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/.gitignore +0 -0
  12. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/pyproject.toml +0 -0
  13. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/setup.cfg +0 -0
  14. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/setup.py +0 -0
  15. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/src/nds_mapviewer.egg-info/SOURCES.txt +0 -0
  16. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/src/nds_mapviewer.egg-info/dependency_links.txt +0 -0
  17. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/src/nds_mapviewer.egg-info/entry_points.txt +0 -0
  18. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/src/nds_mapviewer.egg-info/requires.txt +0 -0
  19. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/src/nds_mapviewer.egg-info/scm_file_list.json +0 -0
  20. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/src/nds_mapviewer.egg-info/top_level.txt +0 -0
  21. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/src/ndslive/mapviewer/__init__.py +0 -0
  22. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/src/ndslive/mapviewer/browser.py +0 -0
  23. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/src/ndslive/mapviewer/clipboard.py +0 -0
  24. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/src/ndslive/mapviewer/container_runtime.py +0 -0
  25. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/src/ndslive/mapviewer/docker_client.py +0 -0
  26. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/src/ndslive/mapviewer/exceptions.py +0 -0
  27. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/src/ndslive/mapviewer/ui.py +0 -0
  28. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/src/ndslive/mapviewer/viewer.py +0 -0
  29. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/tests/test_clipboard.py +0 -0
  30. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/tests/test_docker_client.py +0 -0
  31. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/tests/test_tui.py +0 -0
  32. {nds_mapviewer-2026.4.3.dev256 → nds_mapviewer-2026.4.3.dev258}/tests/test_viewer.py +0 -0
@@ -29,6 +29,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
29
29
 
30
30
  ### Fixed
31
31
 
32
+ - A `.ndslive` filestore, SmartLayer folder or service URL holding object
33
+ data is now configured as `SmartLayerObjectService` instead of always
34
+ `SmartLayerTileService`, which the viewer rejected at startup; detection
35
+ reads the filestore tables, an `objects/` folder, or the service's OpenAPI
36
+ spec (falls back to tile when the spec cannot be fetched)
32
37
  - Clipboard copy in the Docker screen now uses the native OS clipboard
33
38
  (`clip.exe` / `pbcopy` / `wl-copy`/`xclip`/`xsel`) instead of relying on the
34
39
  OSC 52 terminal escape, which is unreliable on Windows; warns when a copy
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nds-mapviewer
3
- Version: 2026.4.3.dev256
3
+ Version: 2026.4.3.dev258
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
@@ -49,7 +49,7 @@ Full documentation: **https://developer.nds.live/tools/mapviewer**
49
49
  - **Multiple data formats** — NDS.Live, GeoJSON, NDS.Classic (member edition)
50
50
  - **Live style editing** — modify map styles in real time with YAML-based rules
51
51
  - **Feature inspection** — click any map feature to explore its attributes and relations
52
- - **Local and remote data** — view SmartLayer folders, FileStore files, or remote services
52
+ - **Local and remote data** — view SmartLayer folders, FileStore files, or remote services, as tile or object maps
53
53
  - **Guided setup** — interactive wizard for creating data source configs
54
54
  - **Config management** — save, switch, and delete configs from the RunScreen (`c`)
55
55
 
@@ -71,7 +71,7 @@ Either Docker or [Podman](https://podman.io) works as the container runtime —
71
71
  ```bash
72
72
  mapviewer # HomeScreen (or auto-loads ~/.nds/config.yaml)
73
73
 
74
- mapviewer /path/to/data.ndslive # View a local FileStore
74
+ mapviewer /path/to/data.ndslive # View a local FileStore (tile or object map)
75
75
  mapviewer /path/to/folder # View a local SmartLayer folder
76
76
  mapviewer /path/to/geojson/ # View a GeoJSON folder
77
77
  mapviewer https://host/openapi.json # Connect to a remote service (unauthenticated)
@@ -87,10 +87,19 @@ mapviewer --demo --network host # Run the container on the host network
87
87
 
88
88
  All commands accept `-e community|member` and `-i VERSION`.
89
89
 
90
+ ### Tile and object maps
91
+
92
+ Local sources are detected as tile or object maps: a `.ndslive` filestore with an
93
+ `objectTable`, or a SmartLayer folder with an `objects/` directory, becomes a
94
+ `SmartLayerObjectService`; everything else a `SmartLayerTileService`. Object maps
95
+ need MapViewer image 2026.4.2 or newer.
96
+
90
97
  ### Remote service URLs
91
98
 
92
99
  A source can be an http(s) service URL (e.g. `mapviewer https://my-host/openapi.json`),
93
- which is turned into a `SmartLayerTileService`. The bare-URL form configures **no
100
+ which is turned into a `SmartLayerTileService`, or a `SmartLayerObjectService` when its
101
+ OpenAPI spec offers `getObjectByReference`. If the spec cannot be fetched, the tile
102
+ service is assumed. The bare-URL form configures **no
94
103
  authentication** — for protected services, proxy them through `ndslive-server` or
95
104
  declare the service in a `config.yaml` with an `http-settings` block (api-key, basic,
96
105
  bearer, OAuth2, proxy).
@@ -16,7 +16,7 @@ Full documentation: **https://developer.nds.live/tools/mapviewer**
16
16
  - **Multiple data formats** — NDS.Live, GeoJSON, NDS.Classic (member edition)
17
17
  - **Live style editing** — modify map styles in real time with YAML-based rules
18
18
  - **Feature inspection** — click any map feature to explore its attributes and relations
19
- - **Local and remote data** — view SmartLayer folders, FileStore files, or remote services
19
+ - **Local and remote data** — view SmartLayer folders, FileStore files, or remote services, as tile or object maps
20
20
  - **Guided setup** — interactive wizard for creating data source configs
21
21
  - **Config management** — save, switch, and delete configs from the RunScreen (`c`)
22
22
 
@@ -38,7 +38,7 @@ Either Docker or [Podman](https://podman.io) works as the container runtime —
38
38
  ```bash
39
39
  mapviewer # HomeScreen (or auto-loads ~/.nds/config.yaml)
40
40
 
41
- mapviewer /path/to/data.ndslive # View a local FileStore
41
+ mapviewer /path/to/data.ndslive # View a local FileStore (tile or object map)
42
42
  mapviewer /path/to/folder # View a local SmartLayer folder
43
43
  mapviewer /path/to/geojson/ # View a GeoJSON folder
44
44
  mapviewer https://host/openapi.json # Connect to a remote service (unauthenticated)
@@ -54,10 +54,19 @@ mapviewer --demo --network host # Run the container on the host network
54
54
 
55
55
  All commands accept `-e community|member` and `-i VERSION`.
56
56
 
57
+ ### Tile and object maps
58
+
59
+ Local sources are detected as tile or object maps: a `.ndslive` filestore with an
60
+ `objectTable`, or a SmartLayer folder with an `objects/` directory, becomes a
61
+ `SmartLayerObjectService`; everything else a `SmartLayerTileService`. Object maps
62
+ need MapViewer image 2026.4.2 or newer.
63
+
57
64
  ### Remote service URLs
58
65
 
59
66
  A source can be an http(s) service URL (e.g. `mapviewer https://my-host/openapi.json`),
60
- which is turned into a `SmartLayerTileService`. The bare-URL form configures **no
67
+ which is turned into a `SmartLayerTileService`, or a `SmartLayerObjectService` when its
68
+ OpenAPI spec offers `getObjectByReference`. If the spec cannot be fetched, the tile
69
+ service is assumed. The bare-URL form configures **no
61
70
  authentication** — for protected services, proxy them through `ndslive-server` or
62
71
  declare the service in a `config.yaml` with an `http-settings` block (api-key, basic,
63
72
  bearer, OAuth2, proxy).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nds-mapviewer
3
- Version: 2026.4.3.dev256
3
+ Version: 2026.4.3.dev258
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
@@ -49,7 +49,7 @@ Full documentation: **https://developer.nds.live/tools/mapviewer**
49
49
  - **Multiple data formats** — NDS.Live, GeoJSON, NDS.Classic (member edition)
50
50
  - **Live style editing** — modify map styles in real time with YAML-based rules
51
51
  - **Feature inspection** — click any map feature to explore its attributes and relations
52
- - **Local and remote data** — view SmartLayer folders, FileStore files, or remote services
52
+ - **Local and remote data** — view SmartLayer folders, FileStore files, or remote services, as tile or object maps
53
53
  - **Guided setup** — interactive wizard for creating data source configs
54
54
  - **Config management** — save, switch, and delete configs from the RunScreen (`c`)
55
55
 
@@ -71,7 +71,7 @@ Either Docker or [Podman](https://podman.io) works as the container runtime —
71
71
  ```bash
72
72
  mapviewer # HomeScreen (or auto-loads ~/.nds/config.yaml)
73
73
 
74
- mapviewer /path/to/data.ndslive # View a local FileStore
74
+ mapviewer /path/to/data.ndslive # View a local FileStore (tile or object map)
75
75
  mapviewer /path/to/folder # View a local SmartLayer folder
76
76
  mapviewer /path/to/geojson/ # View a GeoJSON folder
77
77
  mapviewer https://host/openapi.json # Connect to a remote service (unauthenticated)
@@ -87,10 +87,19 @@ mapviewer --demo --network host # Run the container on the host network
87
87
 
88
88
  All commands accept `-e community|member` and `-i VERSION`.
89
89
 
90
+ ### Tile and object maps
91
+
92
+ Local sources are detected as tile or object maps: a `.ndslive` filestore with an
93
+ `objectTable`, or a SmartLayer folder with an `objects/` directory, becomes a
94
+ `SmartLayerObjectService`; everything else a `SmartLayerTileService`. Object maps
95
+ need MapViewer image 2026.4.2 or newer.
96
+
90
97
  ### Remote service URLs
91
98
 
92
99
  A source can be an http(s) service URL (e.g. `mapviewer https://my-host/openapi.json`),
93
- which is turned into a `SmartLayerTileService`. The bare-URL form configures **no
100
+ which is turned into a `SmartLayerTileService`, or a `SmartLayerObjectService` when its
101
+ OpenAPI spec offers `getObjectByReference`. If the spec cannot be fetched, the tile
102
+ service is assumed. The bare-URL form configures **no
94
103
  authentication** — for protected services, proxy them through `ndslive-server` or
95
104
  declare the service in a `config.yaml` with an `http-settings` block (api-key, basic,
96
105
  bearer, OAuth2, proxy).
@@ -118,7 +118,9 @@ def cmd_main(args: argparse.Namespace) -> int:
118
118
  create_geojson_config,
119
119
  create_classic_config,
120
120
  create_service_config,
121
+ detect_service_type,
121
122
  get_demo_config,
123
+ SMART_TILE_SERVICE,
122
124
  )
123
125
  from .tui import MapViewerApp
124
126
 
@@ -220,13 +222,21 @@ def cmd_main(args: argparse.Namespace) -> int:
220
222
  source_config_path: Path | None = None
221
223
 
222
224
  for source_arg in source_args:
223
- # Remote service: an http(s) URL is turned into a SmartLayerTileService
224
- # source directly. Checked before the path:mapId split below because
225
- # URLs contain colons. No auth is configured for the bare-URL form —
226
- # use ndslive-server as a proxy or a config.yaml for authenticated
225
+ # Remote service: an http(s) URL is turned into a SmartLayer tile or
226
+ # object service source directly, depending on its OpenAPI spec.
227
+ # Checked before the path:mapId split below because URLs contain
228
+ # colons. No auth is configured for the bare-URL form — use
229
+ # ndslive-server as a proxy or a config.yaml for authenticated
227
230
  # services.
228
231
  if source_arg.startswith(("http://", "https://")):
229
- config = create_service_config(source_arg)
232
+ service_type = detect_service_type(source_arg)
233
+ if service_type is None:
234
+ service_type = SMART_TILE_SERVICE
235
+ console.print(
236
+ f"[yellow]Note:[/yellow] could not read the service spec of "
237
+ f"{source_arg}, assuming {SMART_TILE_SERVICE}"
238
+ )
239
+ config = create_service_config(source_arg, service_type=service_type)
230
240
  descriptions.append(f"service: {source_arg}")
231
241
  all_sources.extend(config.get("sources", []))
232
242
  all_http_settings.extend(config.get("http-settings", []))
@@ -320,7 +330,7 @@ Examples:
320
330
  # Interactive HomeScreen
321
331
  mapviewer
322
332
 
323
- # Quick start with local data
333
+ # Quick start with local data (tile or object maps are detected)
324
334
  mapviewer /path/to/data.ndslive
325
335
  mapviewer /path/to/smartlayer/folder
326
336
  mapviewer /path/to/geojson/folder
@@ -4,7 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
  import re
7
+ import sqlite3
7
8
  import tempfile
9
+ import urllib.request
8
10
  from dataclasses import dataclass, field
9
11
  from pathlib import Path, PurePath
10
12
  from typing import Any
@@ -29,6 +31,12 @@ CUSTOM_STYLES_ROOT = "/custom-styles"
29
31
  CUSTOM_BACKGROUNDS_ROOT = "/custom-backgrounds"
30
32
  COORDINATE_LEGAL_TERMS_ROOT = "/coordinates-legal-terms"
31
33
 
34
+ # NDS.Live SmartLayer datasource types; the viewer rejects a source whose
35
+ # type does not match the data's addressing mode.
36
+ SMART_TILE_SERVICE = "SmartLayerTileService"
37
+ SMART_OBJECT_SERVICE = "SmartLayerObjectService"
38
+ SERVICE_SPEC_TIMEOUT_S = 5
39
+
32
40
  # Default config for demo mode
33
41
  DEFAULT_DEMO_CONFIG = {
34
42
  "http-settings": [
@@ -688,6 +696,61 @@ def detect_directory_type(path: Path) -> str:
688
696
  return "folder"
689
697
 
690
698
 
699
+ # --- SmartLayer kind detection ---
700
+
701
+ def detect_filestore_type(filestore_path: str | Path) -> str:
702
+ """Return the SmartLayer datasource type for a ``.ndslive`` filestore.
703
+
704
+ Object filestores carry an ``objectTable``, tile filestores a
705
+ ``tileTable``. Unreadable files fall back to the tile type and leave the
706
+ error reporting to the viewer.
707
+ """
708
+ uri = f"{Path(filestore_path).resolve().as_uri()}?mode=ro"
709
+ try:
710
+ conn = sqlite3.connect(uri, uri=True)
711
+ try:
712
+ row = conn.execute(
713
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='objectTable'"
714
+ ).fetchone()
715
+ finally:
716
+ conn.close()
717
+ except sqlite3.Error:
718
+ return SMART_TILE_SERVICE
719
+ return SMART_OBJECT_SERVICE if row else SMART_TILE_SERVICE
720
+
721
+
722
+ def detect_folder_type(folder_path: str | Path) -> str:
723
+ """Return the SmartLayer datasource type for an unpacked SmartLayer folder.
724
+
725
+ Object folders keep their records in an ``objects/`` subdirectory.
726
+ """
727
+ if (Path(folder_path) / "objects").is_dir():
728
+ return SMART_OBJECT_SERVICE
729
+ return SMART_TILE_SERVICE
730
+
731
+
732
+ def _fetch_service_spec(url: str) -> str | None:
733
+ """Fetch a service's OpenAPI spec as text, or ``None`` if unreachable."""
734
+ try:
735
+ with urllib.request.urlopen(url, timeout=SERVICE_SPEC_TIMEOUT_S) as response:
736
+ return response.read().decode("utf-8", errors="replace")
737
+ except (OSError, ValueError):
738
+ return None
739
+
740
+
741
+ def detect_service_type(url: str) -> str | None:
742
+ """Return the SmartLayer datasource type for a remote service URL.
743
+
744
+ Object services expose ``getObjectByReference`` in their OpenAPI spec.
745
+ Returns ``None`` when the spec cannot be fetched (e.g. it requires auth),
746
+ so the caller can choose a fallback.
747
+ """
748
+ spec = _fetch_service_spec(url)
749
+ if spec is None:
750
+ return None
751
+ return SMART_OBJECT_SERVICE if "getObjectByReference" in spec else SMART_TILE_SERVICE
752
+
753
+
691
754
  # --- Config generation functions ---
692
755
 
693
756
  def create_geojson_config(folder_path: str | Path) -> dict[str, Any]:
@@ -718,12 +781,12 @@ def create_classic_config(
718
781
  def create_folder_config(
719
782
  folder_path: str | Path, map_id: str | None = None,
720
783
  ) -> dict[str, Any]:
721
- """Create a config dict for a SmartLayer folder."""
784
+ """Create a config dict for a SmartLayer folder (tile or object)."""
722
785
  folder_path = Path(folder_path)
723
786
  return {
724
787
  "sources": [
725
788
  {
726
- "type": "SmartLayerTileService",
789
+ "type": detect_folder_type(folder_path),
727
790
  "uri": f"folder:{folder_path}",
728
791
  "mapId": map_id or folder_path.name,
729
792
  }
@@ -734,12 +797,12 @@ def create_folder_config(
734
797
  def create_filestore_config(
735
798
  filestore_path: str | Path, map_id: str | None = None,
736
799
  ) -> dict[str, Any]:
737
- """Create a config dict for a FileStore."""
800
+ """Create a config dict for a FileStore (tile or object)."""
738
801
  filestore_path = Path(filestore_path)
739
802
  return {
740
803
  "sources": [
741
804
  {
742
- "type": "SmartLayerTileService",
805
+ "type": detect_filestore_type(filestore_path),
743
806
  "uri": f"filestore:{filestore_path}",
744
807
  "mapId": map_id or filestore_path.stem,
745
808
  }
@@ -764,11 +827,13 @@ def create_service_config(
764
827
  proxy_port: int | None = None,
765
828
  proxy_user: str | None = None,
766
829
  proxy_password: str | None = None,
830
+ service_type: str = SMART_TILE_SERVICE,
767
831
  ) -> dict[str, Any]:
768
832
  """Create a config dict for connecting to a remote service.
769
833
 
770
834
  Args:
771
835
  url: Service URL (e.g., https://api.example.com/openapi.json)
836
+ service_type: SmartLayer datasource type, see ``detect_service_type``
772
837
  auth_method: One of "none", "apiKey", "queryParam", "basic", "bearer", "oauth2"
773
838
  api_key: API key value (for auth_method="apiKey")
774
839
  query_params: List of {"key": k, "value": v} dicts (for auth_method="queryParam")
@@ -794,7 +859,7 @@ def create_service_config(
794
859
  config: dict[str, Any] = {
795
860
  "sources": [
796
861
  {
797
- "type": "SmartLayerTileService",
862
+ "type": service_type,
798
863
  "uri": url,
799
864
  "mapId": parsed.netloc.replace(".", "-"),
800
865
  }
@@ -33,6 +33,7 @@ from . import docker_client
33
33
  from .config import (
34
34
  CONFIGS_DIR,
35
35
  DEFAULT_CONFIG,
36
+ SMART_TILE_SERVICE,
36
37
  create_classic_config,
37
38
  create_filestore_config,
38
39
  create_folder_config,
@@ -40,6 +41,7 @@ from .config import (
40
41
  create_service_config,
41
42
  delete_config,
42
43
  detect_directory_type,
44
+ detect_service_type,
43
45
  get_demo_config,
44
46
  list_saved_configs,
45
47
  load_config,
@@ -712,6 +714,7 @@ class WizardScreen(Screen):
712
714
  url=self._service_url,
713
715
  **auth_kwargs,
714
716
  **proxy_kwargs,
717
+ service_type=detect_service_type(self._service_url) or SMART_TILE_SERVICE,
715
718
  )
716
719
  elif self._source_type == "demo":
717
720
  self._config = get_demo_config()
@@ -37,6 +37,30 @@ def edition():
37
37
  return DEFAULT_EDITION
38
38
 
39
39
 
40
+ @pytest.fixture(autouse=True)
41
+ def offline_service_spec(monkeypatch):
42
+ """Keep tests offline: service spec fetches fail unless a test stubs them."""
43
+ monkeypatch.setattr(
44
+ "ndslive.mapviewer.config._fetch_service_spec", lambda url: None,
45
+ )
46
+
47
+
48
+ @pytest.fixture
49
+ def make_filestore():
50
+ """Factory for minimal SQLite ``.ndslive`` filestores with given tables."""
51
+ import sqlite3
52
+
53
+ def make(path, *tables):
54
+ conn = sqlite3.connect(path)
55
+ with conn:
56
+ for table in tables:
57
+ conn.execute(f"CREATE TABLE {table} (id INTEGER)")
58
+ conn.close()
59
+ return path
60
+
61
+ return make
62
+
63
+
40
64
  @pytest.fixture
41
65
  def temp_dir():
42
66
  """Create a temporary directory for test files."""
@@ -177,6 +177,42 @@ class TestCliUrlSource:
177
177
  assert rc == 0
178
178
  assert cap["config"]["sources"][0]["uri"] == url
179
179
 
180
+ def test_object_service_url(self, monkeypatch):
181
+ monkeypatch.setattr(
182
+ "ndslive.mapviewer.config._fetch_service_spec",
183
+ lambda url: '{"paths": {"/getObjectByReference": {}}}',
184
+ )
185
+ rc, cap = self._run_cmd_main(monkeypatch, ["https://my-host/openapi.json"])
186
+ assert rc == 0
187
+ assert cap["config"]["sources"][0]["type"] == "SmartLayerObjectService"
188
+
189
+ def test_unreadable_spec_falls_back_to_tile_with_note(self, monkeypatch, capsys):
190
+ rc, cap = self._run_cmd_main(monkeypatch, ["https://my-host/openapi.json"])
191
+ assert rc == 0
192
+ assert cap["config"]["sources"][0]["type"] == "SmartLayerTileService"
193
+ assert "could not read the service spec" in capsys.readouterr().out
194
+
195
+
196
+ class TestCliObjectSources:
197
+ """Tests for detecting SmartLayer object sources from local paths."""
198
+
199
+ def test_mixed_tile_and_object_sources(self, monkeypatch, tmp_path, make_filestore):
200
+ tiles = make_filestore(tmp_path / "tiles.ndslive", "metadataTable", "tileTable")
201
+ objects = make_filestore(tmp_path / "objects.ndslive", "metadataTable", "objectTable")
202
+ folder = tmp_path / "objfolder"
203
+ (folder / "objects").mkdir(parents=True)
204
+
205
+ rc, cap = TestCliUrlSource()._run_cmd_main(
206
+ monkeypatch, [str(tiles), str(objects), str(folder)],
207
+ )
208
+ assert rc == 0
209
+ types = {s["mapId"]: s["type"] for s in cap["config"]["sources"]}
210
+ assert types == {
211
+ "tiles": "SmartLayerTileService",
212
+ "objects": "SmartLayerObjectService",
213
+ "objfolder": "SmartLayerObjectService",
214
+ }
215
+
180
216
 
181
217
  class TestCliLegacyMigration:
182
218
  """Tests for legacy mapviewer.yaml migration hint."""
@@ -7,6 +7,8 @@ import yaml
7
7
 
8
8
  from ndslive.mapviewer.config import (
9
9
  CONFIGS_DIR,
10
+ SMART_OBJECT_SERVICE,
11
+ SMART_TILE_SERVICE,
10
12
  _extract_local_path,
11
13
  _is_localhost,
12
14
  _join_container_path,
@@ -19,6 +21,9 @@ from ndslive.mapviewer.config import (
19
21
  create_service_config,
20
22
  delete_config,
21
23
  detect_directory_type,
24
+ detect_filestore_type,
25
+ detect_folder_type,
26
+ detect_service_type,
22
27
  list_saved_configs,
23
28
  load_config,
24
29
  resolve_config_source,
@@ -798,16 +803,29 @@ class TestCreateClassicConfig:
798
803
 
799
804
 
800
805
  class TestCreateFolderConfig:
801
- """Tests for create_folder_config() custom mapId."""
806
+ """Tests for create_folder_config()."""
802
807
 
803
808
  def test_custom_map_id(self, temp_dir):
804
809
  """Custom mapId overrides the default."""
805
810
  config = create_folder_config(temp_dir, map_id="CustomFolder")
806
811
  assert config["sources"][0]["mapId"] == "CustomFolder"
807
812
 
813
+ def test_tile_folder(self, temp_dir):
814
+ """A folder without objects/ is a tile service."""
815
+ (temp_dir / "tiles").mkdir()
816
+ config = create_folder_config(temp_dir)
817
+ assert config["sources"][0]["type"] == SMART_TILE_SERVICE
818
+
819
+ def test_object_folder(self, temp_dir):
820
+ """A folder with objects/ is an object service."""
821
+ (temp_dir / "objects").mkdir()
822
+ config = create_folder_config(temp_dir)
823
+ assert config["sources"][0]["type"] == SMART_OBJECT_SERVICE
824
+ assert config["sources"][0]["uri"] == f"folder:{temp_dir}"
825
+
808
826
 
809
827
  class TestCreateFilestoreConfig:
810
- """Tests for create_filestore_config() custom mapId."""
828
+ """Tests for create_filestore_config()."""
811
829
 
812
830
  def test_custom_map_id(self, temp_dir):
813
831
  """Custom mapId overrides the default."""
@@ -816,6 +834,66 @@ class TestCreateFilestoreConfig:
816
834
  config = create_filestore_config(store, map_id="CustomStore")
817
835
  assert config["sources"][0]["mapId"] == "CustomStore"
818
836
 
837
+ def test_tile_filestore(self, temp_dir, make_filestore):
838
+ """A filestore with a tileTable is a tile service."""
839
+ store = make_filestore(temp_dir / "tiles.ndslive", "metadataTable", "tileTable")
840
+ config = create_filestore_config(store)
841
+ assert config["sources"][0]["type"] == SMART_TILE_SERVICE
842
+
843
+ def test_object_filestore(self, temp_dir, make_filestore):
844
+ """A filestore with an objectTable is an object service."""
845
+ store = make_filestore(
846
+ temp_dir / "objects.ndslive",
847
+ "metadataTable", "objectTable", "tileObjectAssociationTable",
848
+ )
849
+ config = create_filestore_config(store)
850
+ source = config["sources"][0]
851
+ assert source["type"] == SMART_OBJECT_SERVICE
852
+ assert source["uri"] == f"filestore:{store}"
853
+ assert source["mapId"] == "objects"
854
+
855
+
856
+ class TestDetectSmartLayerType:
857
+ """Tests for the SmartLayer tile/object detection helpers."""
858
+
859
+ def test_unreadable_filestore_falls_back_to_tile(self, temp_dir):
860
+ """A file that is not SQLite is left to the viewer to reject."""
861
+ store = temp_dir / "broken.ndslive"
862
+ store.write_text("not a database")
863
+ assert detect_filestore_type(store) == SMART_TILE_SERVICE
864
+
865
+ def test_filestore_detection_leaves_file_unchanged(self, temp_dir, make_filestore):
866
+ """Detection opens the filestore read-only."""
867
+ store = make_filestore(temp_dir / "objects.ndslive", "objectTable")
868
+ before = store.read_bytes()
869
+ assert detect_filestore_type(store) == SMART_OBJECT_SERVICE
870
+ assert store.read_bytes() == before
871
+
872
+ def test_folder_with_objects_file_is_tile(self, temp_dir):
873
+ """Only an objects/ directory marks an object folder."""
874
+ (temp_dir / "objects").touch()
875
+ assert detect_folder_type(temp_dir) == SMART_TILE_SERVICE
876
+
877
+ def test_object_service_spec(self, monkeypatch):
878
+ """A spec with getObjectByReference is an object service."""
879
+ monkeypatch.setattr(
880
+ "ndslive.mapviewer.config._fetch_service_spec",
881
+ lambda url: '{"paths": {"/getObjectByReference": {}}}',
882
+ )
883
+ assert detect_service_type("https://h/openapi.json") == SMART_OBJECT_SERVICE
884
+
885
+ def test_tile_service_spec(self, monkeypatch):
886
+ """A spec without object methods is a tile service."""
887
+ monkeypatch.setattr(
888
+ "ndslive.mapviewer.config._fetch_service_spec",
889
+ lambda url: '{"paths": {"/getSmartLayerTile": {}}}',
890
+ )
891
+ assert detect_service_type("https://h/openapi.json") == SMART_TILE_SERVICE
892
+
893
+ def test_unreachable_spec_is_unknown(self):
894
+ """An unreachable spec leaves the choice to the caller."""
895
+ assert detect_service_type("https://h/openapi.json") is None
896
+
819
897
 
820
898
  class TestResolveConfigSourceDetection:
821
899
  """Tests for resolve_config_source() directory type detection."""
@@ -852,6 +930,13 @@ class TestCreateServiceConfig:
852
930
  assert config["sources"][0]["uri"] == "https://api.example.com/openapi.json"
853
931
  assert "http-settings" not in config
854
932
 
933
+ def test_object_service_type(self):
934
+ """The given service type is written to the source."""
935
+ config = create_service_config(
936
+ "https://api.example.com/openapi.json", service_type=SMART_OBJECT_SERVICE,
937
+ )
938
+ assert config["sources"][0]["type"] == SMART_OBJECT_SERVICE
939
+
855
940
  def test_api_key_auth(self):
856
941
  """API key auth populates http-settings with api-key."""
857
942
  config = create_service_config(