nds-mapviewer 2026.2.1.dev92__tar.gz → 2026.2.1.dev94__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 (31) hide show
  1. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/CHANGELOG.md +24 -0
  2. {nds_mapviewer-2026.2.1.dev92/src/nds_mapviewer.egg-info → nds_mapviewer-2026.2.1.dev94}/PKG-INFO +39 -1
  3. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/README.md +38 -0
  4. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94/src/nds_mapviewer.egg-info}/PKG-INFO +39 -1
  5. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/src/nds_mapviewer.egg-info/SOURCES.txt +3 -0
  6. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/src/ndslive/mapviewer/cli.py +82 -1
  7. nds_mapviewer-2026.2.1.dev94/src/ndslive/mapviewer/clipboard.py +81 -0
  8. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/src/ndslive/mapviewer/config.py +31 -0
  9. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/src/ndslive/mapviewer/docker_client.py +36 -14
  10. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/src/ndslive/mapviewer/tui.py +110 -13
  11. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/src/ndslive/mapviewer/viewer.py +43 -4
  12. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/tests/test_cli.py +103 -0
  13. nds_mapviewer-2026.2.1.dev94/tests/test_clipboard.py +76 -0
  14. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/tests/test_config.py +71 -0
  15. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/tests/test_docker_client.py +57 -0
  16. nds_mapviewer-2026.2.1.dev94/tests/test_tui.py +67 -0
  17. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/tests/test_viewer.py +179 -0
  18. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/.gitignore +0 -0
  19. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/pyproject.toml +0 -0
  20. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/setup.cfg +0 -0
  21. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/setup.py +0 -0
  22. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/src/nds_mapviewer.egg-info/dependency_links.txt +0 -0
  23. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/src/nds_mapviewer.egg-info/entry_points.txt +0 -0
  24. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/src/nds_mapviewer.egg-info/requires.txt +0 -0
  25. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/src/nds_mapviewer.egg-info/top_level.txt +0 -0
  26. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/src/ndslive/mapviewer/__init__.py +0 -0
  27. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/src/ndslive/mapviewer/browser.py +0 -0
  28. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/src/ndslive/mapviewer/container_runtime.py +0 -0
  29. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/src/ndslive/mapviewer/exceptions.py +0 -0
  30. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/src/ndslive/mapviewer/ui.py +0 -0
  31. {nds_mapviewer-2026.2.1.dev92 → nds_mapviewer-2026.2.1.dev94}/tests/conftest.py +0 -0
@@ -9,12 +9,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ### Added
11
11
 
12
+ - `--debug` flag to enable container debug logging (sets `HTTP_LOG_LEVEL=debug`)
13
+ - `--env KEY=VALUE` (repeatable, short `-E`) to pass custom environment
14
+ variables into the container; overrides `--debug` on key collision
15
+ - `--network NAME` to attach the container to a specific network (e.g. `host`);
16
+ in `host` mode port publishing is skipped and the viewer is reached on the
17
+ container's internal port
18
+ - Run screen now shows a config line below the info banner with the source
19
+ (config file or directly-passed data-source path, e.g. a `*.ndslive`
20
+ filestore) and the mapped config mounted into the container
21
+ - Accept an http(s) service URL directly as a source (e.g.
22
+ `mapviewer https://my-host/openapi.json`), turned into a
23
+ `SmartLayerTileService`; the bare-URL form configures no auth — use
24
+ ndslive-server as a proxy or a config.yaml with `http-settings` for
25
+ authenticated services
12
26
  - Default Docker image version pinned to package (`--image-version` defaults to
13
27
  matching calver instead of `"latest"`)
14
28
  - SCM-based versioning — package version derived from MapViewer git tags
15
29
 
16
30
  ### Fixed
17
31
 
32
+ - Clipboard copy in the Docker screen now uses the native OS clipboard
33
+ (`clip.exe` / `pbcopy` / `wl-copy`/`xclip`/`xsel`) instead of relying on the
34
+ OSC 52 terminal escape, which is unreliable on Windows; warns when a copy
35
+ cannot be confirmed
36
+ - Quitting the TUI no longer hangs — the log-follow stream is torn down by
37
+ terminating the process before closing pipes, and container shutdown is
38
+ bounded so the terminal is always returned to the user
39
+ - `localhost`/`127.0.0.1` in service `uri` values and `http-settings` `scope`
40
+ values are now translated to `host.docker.internal`, so a service running on
41
+ the host machine is reachable from the container without `--network host`
18
42
  - Container readiness check uses log sentinel before HTTP polling to avoid
19
43
  startup crash (mapget#151)
20
44
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nds-mapviewer
3
- Version: 2026.2.1.dev92
3
+ Version: 2026.2.1.dev94
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
@@ -74,14 +74,48 @@ mapviewer # HomeScreen (or auto-loads ~/.nds/config.yaml
74
74
  mapviewer /path/to/data.ndslive # View a local FileStore
75
75
  mapviewer /path/to/folder # View a local SmartLayer folder
76
76
  mapviewer /path/to/geojson/ # View a GeoJSON folder
77
+ mapviewer https://host/openapi.json # Connect to a remote service (unauthenticated)
77
78
  mapviewer --demo # Try it with NDS Islands sample data
78
79
 
79
80
  mapviewer mywork # Load a saved config by name
80
81
  mapviewer /path/a.ndslive /path/b # Multiple sources at once
82
+
83
+ mapviewer --demo --debug # Enable debug logging in the container
84
+ mapviewer --demo --env KEY=VALUE # Set a custom container env var (repeatable)
85
+ mapviewer --demo --network host # Run the container on the host network
81
86
  ```
82
87
 
83
88
  All commands accept `-e community|member` and `-i VERSION`.
84
89
 
90
+ ### Remote service URLs
91
+
92
+ 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
94
+ authentication** — for protected services, proxy them through `ndslive-server` or
95
+ declare the service in a `config.yaml` with an `http-settings` block (api-key, basic,
96
+ bearer, OAuth2, proxy).
97
+
98
+ For a service running on your own machine, use `localhost`/`127.0.0.1`
99
+ (e.g. `mapviewer http://127.0.0.1:8080/openapi.json`) — it is automatically
100
+ rewritten to `host.docker.internal` so the container can reach it. You do **not**
101
+ need `--network host` for this.
102
+
103
+ ### Container environment variables
104
+
105
+ `--debug` turns on verbose logging in the MapViewer container (shorthand for
106
+ `--env HTTP_LOG_LEVEL=debug`). Use `--env KEY=VALUE` (repeatable, short form
107
+ `-E`) to pass arbitrary environment variables into the container; explicit
108
+ `--env` entries override `--debug` on key collision. The resulting `-e` flags
109
+ appear in the Docker screen's "Copy Command" output.
110
+
111
+ ### Container network
112
+
113
+ `--network NAME` attaches the container to a specific network (e.g. a custom
114
+ bridge or `host`). With `--network host` the container binds the host's port
115
+ directly, so `--port`/`--bind` are ignored and the MapViewer is reached on its
116
+ internal port (8089). Host networking behaves differently across platforms and
117
+ runtimes — it is intended as a connectivity workaround.
118
+
85
119
  If `~/.nds/config.yaml` exists, `mapviewer` (with no arguments) loads it
86
120
  automatically and goes straight to the RunScreen.
87
121
 
@@ -89,6 +123,10 @@ If you edit `~/.nds/config.yaml` manually, use host paths there, not container
89
123
  paths. The CLI translates them into the correct container mounts when it starts
90
124
  the container runtime.
91
125
 
126
+ The run screen shows, below the info banner, the source you passed (a config
127
+ file or a directly-given data-source path such as a `*.ndslive` filestore) and
128
+ the mapped config that was generated and mounted into the container.
129
+
92
130
  ## Editions
93
131
 
94
132
  | Edition | Formats | Access |
@@ -41,14 +41,48 @@ mapviewer # HomeScreen (or auto-loads ~/.nds/config.yaml
41
41
  mapviewer /path/to/data.ndslive # View a local FileStore
42
42
  mapviewer /path/to/folder # View a local SmartLayer folder
43
43
  mapviewer /path/to/geojson/ # View a GeoJSON folder
44
+ mapviewer https://host/openapi.json # Connect to a remote service (unauthenticated)
44
45
  mapviewer --demo # Try it with NDS Islands sample data
45
46
 
46
47
  mapviewer mywork # Load a saved config by name
47
48
  mapviewer /path/a.ndslive /path/b # Multiple sources at once
49
+
50
+ mapviewer --demo --debug # Enable debug logging in the container
51
+ mapviewer --demo --env KEY=VALUE # Set a custom container env var (repeatable)
52
+ mapviewer --demo --network host # Run the container on the host network
48
53
  ```
49
54
 
50
55
  All commands accept `-e community|member` and `-i VERSION`.
51
56
 
57
+ ### Remote service URLs
58
+
59
+ 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
61
+ authentication** — for protected services, proxy them through `ndslive-server` or
62
+ declare the service in a `config.yaml` with an `http-settings` block (api-key, basic,
63
+ bearer, OAuth2, proxy).
64
+
65
+ For a service running on your own machine, use `localhost`/`127.0.0.1`
66
+ (e.g. `mapviewer http://127.0.0.1:8080/openapi.json`) — it is automatically
67
+ rewritten to `host.docker.internal` so the container can reach it. You do **not**
68
+ need `--network host` for this.
69
+
70
+ ### Container environment variables
71
+
72
+ `--debug` turns on verbose logging in the MapViewer container (shorthand for
73
+ `--env HTTP_LOG_LEVEL=debug`). Use `--env KEY=VALUE` (repeatable, short form
74
+ `-E`) to pass arbitrary environment variables into the container; explicit
75
+ `--env` entries override `--debug` on key collision. The resulting `-e` flags
76
+ appear in the Docker screen's "Copy Command" output.
77
+
78
+ ### Container network
79
+
80
+ `--network NAME` attaches the container to a specific network (e.g. a custom
81
+ bridge or `host`). With `--network host` the container binds the host's port
82
+ directly, so `--port`/`--bind` are ignored and the MapViewer is reached on its
83
+ internal port (8089). Host networking behaves differently across platforms and
84
+ runtimes — it is intended as a connectivity workaround.
85
+
52
86
  If `~/.nds/config.yaml` exists, `mapviewer` (with no arguments) loads it
53
87
  automatically and goes straight to the RunScreen.
54
88
 
@@ -56,6 +90,10 @@ If you edit `~/.nds/config.yaml` manually, use host paths there, not container
56
90
  paths. The CLI translates them into the correct container mounts when it starts
57
91
  the container runtime.
58
92
 
93
+ The run screen shows, below the info banner, the source you passed (a config
94
+ file or a directly-given data-source path such as a `*.ndslive` filestore) and
95
+ the mapped config that was generated and mounted into the container.
96
+
59
97
  ## Editions
60
98
 
61
99
  | Edition | Formats | Access |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nds-mapviewer
3
- Version: 2026.2.1.dev92
3
+ Version: 2026.2.1.dev94
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
@@ -74,14 +74,48 @@ mapviewer # HomeScreen (or auto-loads ~/.nds/config.yaml
74
74
  mapviewer /path/to/data.ndslive # View a local FileStore
75
75
  mapviewer /path/to/folder # View a local SmartLayer folder
76
76
  mapviewer /path/to/geojson/ # View a GeoJSON folder
77
+ mapviewer https://host/openapi.json # Connect to a remote service (unauthenticated)
77
78
  mapviewer --demo # Try it with NDS Islands sample data
78
79
 
79
80
  mapviewer mywork # Load a saved config by name
80
81
  mapviewer /path/a.ndslive /path/b # Multiple sources at once
82
+
83
+ mapviewer --demo --debug # Enable debug logging in the container
84
+ mapviewer --demo --env KEY=VALUE # Set a custom container env var (repeatable)
85
+ mapviewer --demo --network host # Run the container on the host network
81
86
  ```
82
87
 
83
88
  All commands accept `-e community|member` and `-i VERSION`.
84
89
 
90
+ ### Remote service URLs
91
+
92
+ 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
94
+ authentication** — for protected services, proxy them through `ndslive-server` or
95
+ declare the service in a `config.yaml` with an `http-settings` block (api-key, basic,
96
+ bearer, OAuth2, proxy).
97
+
98
+ For a service running on your own machine, use `localhost`/`127.0.0.1`
99
+ (e.g. `mapviewer http://127.0.0.1:8080/openapi.json`) — it is automatically
100
+ rewritten to `host.docker.internal` so the container can reach it. You do **not**
101
+ need `--network host` for this.
102
+
103
+ ### Container environment variables
104
+
105
+ `--debug` turns on verbose logging in the MapViewer container (shorthand for
106
+ `--env HTTP_LOG_LEVEL=debug`). Use `--env KEY=VALUE` (repeatable, short form
107
+ `-E`) to pass arbitrary environment variables into the container; explicit
108
+ `--env` entries override `--debug` on key collision. The resulting `-e` flags
109
+ appear in the Docker screen's "Copy Command" output.
110
+
111
+ ### Container network
112
+
113
+ `--network NAME` attaches the container to a specific network (e.g. a custom
114
+ bridge or `host`). With `--network host` the container binds the host's port
115
+ directly, so `--port`/`--bind` are ignored and the MapViewer is reached on its
116
+ internal port (8089). Host networking behaves differently across platforms and
117
+ runtimes — it is intended as a connectivity workaround.
118
+
85
119
  If `~/.nds/config.yaml` exists, `mapviewer` (with no arguments) loads it
86
120
  automatically and goes straight to the RunScreen.
87
121
 
@@ -89,6 +123,10 @@ If you edit `~/.nds/config.yaml` manually, use host paths there, not container
89
123
  paths. The CLI translates them into the correct container mounts when it starts
90
124
  the container runtime.
91
125
 
126
+ The run screen shows, below the info banner, the source you passed (a config
127
+ file or a directly-given data-source path such as a `*.ndslive` filestore) and
128
+ the mapped config that was generated and mounted into the container.
129
+
92
130
  ## Editions
93
131
 
94
132
  | Edition | Formats | Access |
@@ -12,6 +12,7 @@ src/nds_mapviewer.egg-info/top_level.txt
12
12
  src/ndslive/mapviewer/__init__.py
13
13
  src/ndslive/mapviewer/browser.py
14
14
  src/ndslive/mapviewer/cli.py
15
+ src/ndslive/mapviewer/clipboard.py
15
16
  src/ndslive/mapviewer/config.py
16
17
  src/ndslive/mapviewer/container_runtime.py
17
18
  src/ndslive/mapviewer/docker_client.py
@@ -21,6 +22,8 @@ src/ndslive/mapviewer/ui.py
21
22
  src/ndslive/mapviewer/viewer.py
22
23
  tests/conftest.py
23
24
  tests/test_cli.py
25
+ tests/test_clipboard.py
24
26
  tests/test_config.py
25
27
  tests/test_docker_client.py
28
+ tests/test_tui.py
26
29
  tests/test_viewer.py
@@ -73,6 +73,37 @@ def _get_pull_policy_default() -> str:
73
73
  return "prompt"
74
74
 
75
75
 
76
+ def _build_environment(args: argparse.Namespace) -> dict[str, str]:
77
+ """Build the container environment dict from ``--debug`` and ``--env``.
78
+
79
+ ``--debug`` is shorthand for ``HTTP_LOG_LEVEL=debug``. Explicit ``--env``
80
+ entries are applied afterwards, so they win on key collision.
81
+
82
+ Raises:
83
+ SystemExit: If an ``--env`` entry is not in ``KEY=VALUE`` form.
84
+ """
85
+ env: dict[str, str] = {}
86
+ if getattr(args, "debug", False):
87
+ env["HTTP_LOG_LEVEL"] = "debug"
88
+ for item in getattr(args, "env", None) or []:
89
+ if "=" not in item:
90
+ console.print(
91
+ f"[red]Error:[/red] Invalid --env value '{item}', "
92
+ "expected KEY=VALUE"
93
+ )
94
+ raise SystemExit(2)
95
+ key, _, value = item.partition("=")
96
+ key = key.strip()
97
+ if not key:
98
+ console.print(
99
+ f"[red]Error:[/red] Invalid --env value '{item}', "
100
+ "empty key"
101
+ )
102
+ raise SystemExit(2)
103
+ env[key] = value
104
+ return env
105
+
106
+
76
107
  # ---------------------------------------------------------------------------
77
108
  # Command handler
78
109
  # ---------------------------------------------------------------------------
@@ -86,6 +117,7 @@ def cmd_main(args: argparse.Namespace) -> int:
86
117
  create_folder_config,
87
118
  create_geojson_config,
88
119
  create_classic_config,
120
+ create_service_config,
89
121
  get_demo_config,
90
122
  )
91
123
  from .tui import MapViewerApp
@@ -97,6 +129,8 @@ def cmd_main(args: argparse.Namespace) -> int:
97
129
  bind_address = args.bind
98
130
  pull_policy = args.pull_policy
99
131
  source_args = args.sources
132
+ environment = _build_environment(args)
133
+ network = getattr(args, "network", None)
100
134
 
101
135
  # --demo → demo config
102
136
  if args.demo:
@@ -108,6 +142,8 @@ def cmd_main(args: argparse.Namespace) -> int:
108
142
  port=port,
109
143
  bind_address=bind_address,
110
144
  pull_policy=pull_policy,
145
+ environment=environment,
146
+ network=network,
111
147
  run_config=config,
112
148
  run_label="Demo (NDS Islands)",
113
149
  is_demo=True,
@@ -128,6 +164,8 @@ def cmd_main(args: argparse.Namespace) -> int:
128
164
  port=port,
129
165
  bind_address=bind_address,
130
166
  pull_policy=pull_policy,
167
+ environment=environment,
168
+ network=network,
131
169
  run_config=config,
132
170
  run_config_path=DEFAULT_CONFIG,
133
171
  run_label="~/.nds/config.yaml",
@@ -151,6 +189,8 @@ def cmd_main(args: argparse.Namespace) -> int:
151
189
  port=port,
152
190
  bind_address=bind_address,
153
191
  pull_policy=pull_policy,
192
+ environment=environment,
193
+ network=network,
154
194
  run_config=config,
155
195
  run_config_path=LEGACY_CONFIG,
156
196
  run_label="~/.nds/mapviewer.yaml",
@@ -167,6 +207,8 @@ def cmd_main(args: argparse.Namespace) -> int:
167
207
  port=port,
168
208
  bind_address=bind_address,
169
209
  pull_policy=pull_policy,
210
+ environment=environment,
211
+ network=network,
170
212
  )
171
213
  app.run()
172
214
  return 0
@@ -178,6 +220,18 @@ def cmd_main(args: argparse.Namespace) -> int:
178
220
  source_config_path: Path | None = None
179
221
 
180
222
  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
227
+ # services.
228
+ if source_arg.startswith(("http://", "https://")):
229
+ config = create_service_config(source_arg)
230
+ descriptions.append(f"service: {source_arg}")
231
+ all_sources.extend(config.get("sources", []))
232
+ all_http_settings.extend(config.get("http-settings", []))
233
+ continue
234
+
181
235
  # Parse optional path:mapId syntax
182
236
  custom_map_id = None
183
237
  if ":" in source_arg:
@@ -240,9 +294,12 @@ def cmd_main(args: argparse.Namespace) -> int:
240
294
  port=port,
241
295
  bind_address=bind_address,
242
296
  pull_policy=pull_policy,
297
+ environment=environment,
298
+ network=network,
243
299
  run_config=merged,
244
300
  run_config_path=source_config_path,
245
301
  run_label=source_desc,
302
+ run_source_paths=source_args,
246
303
  )
247
304
  app.run()
248
305
  return 0
@@ -269,6 +326,9 @@ Examples:
269
326
  mapviewer /path/to/geojson/folder
270
327
  mapviewer --demo
271
328
 
329
+ # Connect to a remote service (unauthenticated)
330
+ mapviewer https://my-host/openapi.json
331
+
272
332
  # Multiple sources at once
273
333
  mapviewer /path/a.ndslive /path/to/geojson/
274
334
 
@@ -291,7 +351,7 @@ Examples:
291
351
  parser.add_argument(
292
352
  "sources",
293
353
  nargs="*",
294
- help="One or more data source paths or saved config names",
354
+ help="One or more data source paths, http(s) service URLs, or saved config names",
295
355
  )
296
356
  parser.add_argument(
297
357
  "--demo",
@@ -323,6 +383,27 @@ Examples:
323
383
  "--bind", default="127.0.0.1",
324
384
  help="Host address to bind (default: 127.0.0.1, use 0.0.0.0 for network access)",
325
385
  )
386
+ parser.add_argument(
387
+ "--env", "-E",
388
+ action="append",
389
+ metavar="KEY=VALUE",
390
+ dest="env",
391
+ default=[],
392
+ help="Set a container environment variable (repeatable), "
393
+ "e.g. --env HTTP_LOG_LEVEL=debug",
394
+ )
395
+ parser.add_argument(
396
+ "--debug",
397
+ action="store_true",
398
+ help="Enable debug logging in the container (sets HTTP_LOG_LEVEL=debug)",
399
+ )
400
+ parser.add_argument(
401
+ "--network",
402
+ metavar="NAME",
403
+ default=None,
404
+ help="Container network to attach (e.g. 'host'). With 'host' the "
405
+ "MapViewer binds the host port directly and --port/--bind are ignored.",
406
+ )
326
407
 
327
408
  pull_group = parser.add_mutually_exclusive_group()
328
409
  pull_group.add_argument(
@@ -0,0 +1,81 @@
1
+ """Cross-platform clipboard helper.
2
+
3
+ Textual's ``App.copy_to_clipboard`` relies on the OSC 52 terminal escape
4
+ sequence, which many Windows terminals (conhost, some Windows Terminal
5
+ configs, PowerShell hosts) silently drop — so the copy buttons appear to do
6
+ nothing. This module copies via the native OS clipboard utility first and
7
+ only falls back to OSC 52 when no native tool is available.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import platform
13
+ import shutil
14
+ import subprocess
15
+
16
+
17
+ def _run_copy(cmd: list[str], text: str) -> bool:
18
+ """Pipe *text* into *cmd* on stdin; return True on success."""
19
+ try:
20
+ proc = subprocess.run(
21
+ cmd,
22
+ input=text,
23
+ text=True,
24
+ capture_output=True,
25
+ timeout=5,
26
+ )
27
+ return proc.returncode == 0
28
+ except (OSError, subprocess.SubprocessError):
29
+ return False
30
+
31
+
32
+ def _native_copy(text: str) -> bool:
33
+ """Copy *text* using the platform-native clipboard tool.
34
+
35
+ Returns True if a tool was found and reported success.
36
+ """
37
+ system = platform.system()
38
+
39
+ if system == "Windows":
40
+ # clip.exe ships with every supported Windows version.
41
+ clip = shutil.which("clip") or shutil.which("clip.exe")
42
+ if clip:
43
+ return _run_copy([clip], text)
44
+ return False
45
+
46
+ if system == "Darwin":
47
+ if shutil.which("pbcopy"):
48
+ return _run_copy(["pbcopy"], text)
49
+ return False
50
+
51
+ # Linux / other Unix — try Wayland then X11 helpers.
52
+ if shutil.which("wl-copy"):
53
+ if _run_copy(["wl-copy"], text):
54
+ return True
55
+ if shutil.which("xclip"):
56
+ if _run_copy(["xclip", "-selection", "clipboard"], text):
57
+ return True
58
+ if shutil.which("xsel"):
59
+ if _run_copy(["xsel", "--clipboard", "--input"], text):
60
+ return True
61
+ return False
62
+
63
+
64
+ def copy_text(text: str, app=None) -> bool:
65
+ """Copy *text* to the clipboard, returning True on confirmed success.
66
+
67
+ Tries the native OS clipboard utility first (reliable on Windows where
68
+ OSC 52 is commonly unsupported). If no native tool is available and an
69
+ *app* is given, falls back to Textual's OSC 52 escape — which cannot be
70
+ confirmed, so this returns False to let callers warn the user.
71
+ """
72
+ if _native_copy(text):
73
+ return True
74
+
75
+ if app is not None:
76
+ try:
77
+ app.copy_to_clipboard(text)
78
+ except Exception:
79
+ pass
80
+
81
+ return False
@@ -138,6 +138,22 @@ def _transform_localhost(host: str) -> str:
138
138
  return host
139
139
 
140
140
 
141
+ def _transform_localhost_url(url: str) -> str:
142
+ """Rewrite a localhost host in an http(s) URL to host.docker.internal.
143
+
144
+ Used for source ``uri`` values and ``http-settings`` ``scope`` values so a
145
+ service running on the host machine (e.g. ``http://127.0.0.1:8080/...``) is
146
+ reachable from inside the container. Non-http(s) values and non-localhost
147
+ hosts are returned unchanged.
148
+ """
149
+ parsed = urlparse(url)
150
+ if parsed.scheme not in ("http", "https"):
151
+ return url
152
+ if not _is_localhost(parsed.netloc):
153
+ return url
154
+ return parsed._replace(netloc=_transform_localhost(parsed.netloc)).geturl()
155
+
156
+
141
157
  def _is_external_or_bundle_url(value: str) -> bool:
142
158
  """Return True for URLs that should not be rewritten as local files."""
143
159
  return value.startswith(("http://", "https://", "bundle"))
@@ -313,6 +329,12 @@ def transform_config(
313
329
  if local_path:
314
330
  local_paths.append(local_path)
315
331
  path_locations.append(("sources", idx, "uri", uri))
332
+ else:
333
+ # Remote service on the host: localhost/127.0.0.1 must become
334
+ # host.docker.internal to be reachable from inside the container.
335
+ new_uri = _transform_localhost_url(uri)
336
+ if new_uri != uri:
337
+ transformed["sources"][idx]["uri"] = new_uri
316
338
 
317
339
  # Check folder field (ClassicFolder, GeoJsonFolder)
318
340
  folder = source.get("folder", "")
@@ -327,6 +349,15 @@ def transform_config(
327
349
  if host and _is_localhost(host):
328
350
  transformed["sources"][idx]["host"] = _transform_localhost(host)
329
351
 
352
+ # Translate localhost in http-settings scopes so auth settings keep
353
+ # matching the rewritten source URIs.
354
+ for setting in transformed.get("http-settings", []) or []:
355
+ scope = setting.get("scope", "")
356
+ if scope:
357
+ new_scope = _transform_localhost_url(scope)
358
+ if new_scope != scope:
359
+ setting["scope"] = new_scope
360
+
330
361
  # Find optimal mount points
331
362
  path_to_mount_parent = _find_common_parent(local_paths)
332
363
 
@@ -36,6 +36,9 @@ IMAGE_NAME = "nds-mapviewer"
36
36
 
37
37
  KNOWN_TAGS = ["latest", "dev", "dev-rc"]
38
38
 
39
+ # Port the MapViewer HTTP server listens on inside the container.
40
+ CONTAINER_PORT = 8089
41
+
39
42
 
40
43
  # ---------------------------------------------------------------------------
41
44
  # Container info dataclass (replaces Docker SDK Container object)
@@ -543,6 +546,7 @@ def run_container(
543
546
  config_dir: str | Path | None = None,
544
547
  volumes: list[str] | None = None,
545
548
  environment: dict[str, str] | None = None,
549
+ network: str | None = None,
546
550
  name: str = CONTAINER_NAME,
547
551
  detach: bool = True,
548
552
  remove_existing: bool = False,
@@ -556,6 +560,9 @@ def run_container(
556
560
  config_dir: Config directory to mount at ``/config``.
557
561
  volumes: Additional volume mounts as ``"host:container:mode"`` strings.
558
562
  environment: Environment variables.
563
+ network: Container network to attach (e.g. ``"host"``). When ``"host"``,
564
+ port publishing is skipped because the container binds the host's
565
+ port directly.
559
566
  name: Container name.
560
567
  detach: Run in detached mode.
561
568
  remove_existing: Remove existing container if running.
@@ -571,12 +578,20 @@ def run_container(
571
578
  rt.run("stop", "-t", "2", name, timeout=30)
572
579
  rt.run("rm", name, check=False, timeout=10)
573
580
 
581
+ use_host_net = network == "host"
582
+
574
583
  # Build command
575
584
  cmd: list[str] = ["run"]
576
585
  if detach:
577
586
  cmd.append("-d")
578
587
  cmd.extend(["--name", name])
579
- cmd.extend(["-p", f"{bind_address}:{port}:8089"])
588
+ if network:
589
+ cmd.extend(["--network", network])
590
+ # Host networking ignores published ports — the container binds the
591
+ # host's port directly — so skip the -p mapping to avoid the runtime
592
+ # warning/error.
593
+ if not use_host_net:
594
+ cmd.extend(["-p", f"{bind_address}:{port}:{CONTAINER_PORT}"])
580
595
 
581
596
  # Volume mounts
582
597
  if config_dir:
@@ -592,8 +607,10 @@ def run_container(
592
607
  for key, value in environment.items():
593
608
  cmd.extend(["-e", f"{key}={value}"])
594
609
 
595
- # Extra hosts for host.docker.internal on Linux
596
- cmd.extend(rt.get_extra_host_args())
610
+ # Extra hosts for host.docker.internal on Linux. The host-gateway mapping
611
+ # conflicts with host networking, so skip it in that mode.
612
+ if not use_host_net:
613
+ cmd.extend(rt.get_extra_host_args())
597
614
 
598
615
  # Labels
599
616
  cmd.extend(["--label", "app=ndslive-mapviewer"])
@@ -673,6 +690,22 @@ class ContainerLogStream:
673
690
  def close(self, timeout: float = 3.0) -> None:
674
691
  if self._proc.poll() is not None:
675
692
  return
693
+ # Terminate the process FIRST so the OS closes the write end of the
694
+ # pipe and any thread blocked in `for line in proc.stdout` unblocks
695
+ # via EOF. Closing the pipe from another thread while it is being
696
+ # read is unsafe in CPython and can leave the reader (and thus app
697
+ # shutdown) hung indefinitely.
698
+ self._proc.terminate()
699
+ try:
700
+ self._proc.wait(timeout=timeout)
701
+ except subprocess.TimeoutExpired:
702
+ self._proc.kill()
703
+ try:
704
+ self._proc.wait(timeout=timeout)
705
+ except subprocess.TimeoutExpired:
706
+ pass
707
+ # Now that the process is gone, closing the pipes is safe and frees
708
+ # the file descriptors.
676
709
  for pipe in (getattr(self._proc, "stdout", None), getattr(self._proc, "stderr", None)):
677
710
  if pipe is None:
678
711
  continue
@@ -680,17 +713,6 @@ class ContainerLogStream:
680
713
  pipe.close()
681
714
  except Exception:
682
715
  pass
683
- self._proc.terminate()
684
- try:
685
- self._proc.wait(timeout=timeout)
686
- return
687
- except subprocess.TimeoutExpired:
688
- pass
689
- self._proc.kill()
690
- try:
691
- self._proc.wait(timeout=timeout)
692
- except subprocess.TimeoutExpired:
693
- pass
694
716
 
695
717
 
696
718
  _READY_SENTINEL = "NDS MapViewer is ready!"