moos-map 1.0.1__py3-none-any.whl

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.
moos_map/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """MOOS-IvP background map builder."""
2
+
3
+ __version__ = "1.0.1"
moos_map/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ import threading
5
+ import time
6
+ from concurrent.futures import Future, ThreadPoolExecutor, as_completed
7
+ from io import BytesIO
8
+ from typing import Callable, Protocol
9
+
10
+ import httpx
11
+ from PIL import Image, UnidentifiedImageError
12
+
13
+ from . import __version__
14
+ from .cache import TileCache
15
+ from .errors import FetchError
16
+ from .models import TileRange
17
+ from .sources import MapSource
18
+
19
+
20
+ ProgressCallback = Callable[[int, int, int, int], None]
21
+
22
+
23
+ class TileProvider(Protocol):
24
+ cache_hits: int
25
+ downloaded_tiles: int
26
+
27
+ def fetch(self, zoom: int, x: int, y: int, *, force: bool = False) -> bytes: ...
28
+
29
+ def close(self) -> None: ...
30
+
31
+
32
+ def validate_tile_bytes(data: bytes, tile_size: int, description: str) -> None:
33
+ if not data:
34
+ raise FetchError(f"Empty tile response for {description}")
35
+ try:
36
+ with Image.open(BytesIO(data)) as image:
37
+ image.load()
38
+ if image.size != (tile_size, tile_size):
39
+ raise FetchError(
40
+ f"Unexpected tile dimensions for {description}: "
41
+ f"{image.width}x{image.height}, expected {tile_size}x{tile_size}"
42
+ )
43
+ except (UnidentifiedImageError, OSError) as exc:
44
+ raise FetchError(
45
+ f"Response was not a readable image for {description}"
46
+ ) from exc
47
+
48
+
49
+ class HttpTileProvider:
50
+ RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
51
+
52
+ def __init__(
53
+ self,
54
+ source: MapSource,
55
+ *,
56
+ cache: TileCache | None = None,
57
+ timeout_seconds: float = 20.0,
58
+ retries: int = 3,
59
+ ) -> None:
60
+ self.source = source
61
+ self.cache = cache or TileCache(source)
62
+ self.retries = retries
63
+ self.cache_hits = 0
64
+ self.downloaded_tiles = 0
65
+ self._stats_lock = threading.Lock()
66
+ self._client = httpx.Client(
67
+ timeout=httpx.Timeout(timeout_seconds),
68
+ follow_redirects=True,
69
+ headers={
70
+ "User-Agent": f"moos-map/{__version__} (+local MOOS-IvP map builder)",
71
+ "Accept": "image/*",
72
+ },
73
+ limits=httpx.Limits(max_connections=8, max_keepalive_connections=8),
74
+ )
75
+
76
+ def fetch(self, zoom: int, x: int, y: int, *, force: bool = False) -> bytes:
77
+ description = f"{self.source.id} z={zoom} x={x} y={y}"
78
+ if not force:
79
+ cached = self.cache.read(zoom, x, y)
80
+ if cached is not None:
81
+ try:
82
+ validate_tile_bytes(cached, self.source.tile_size, description)
83
+ except FetchError:
84
+ self.cache.path_for(zoom, x, y).unlink(missing_ok=True)
85
+ else:
86
+ with self._stats_lock:
87
+ self.cache_hits += 1
88
+ return cached
89
+
90
+ url = self.source.tile_url(zoom, x, y)
91
+ last_error: Exception | None = None
92
+ for attempt in range(self.retries + 1):
93
+ try:
94
+ response = self._client.get(url)
95
+ if response.status_code == 200:
96
+ data = response.content
97
+ if len(data) > 10 * 1024 * 1024:
98
+ raise FetchError(
99
+ f"Tile response exceeded 10 MB for {description}"
100
+ )
101
+ validate_tile_bytes(data, self.source.tile_size, description)
102
+ self.cache.write(zoom, x, y, data)
103
+ with self._stats_lock:
104
+ self.downloaded_tiles += 1
105
+ return data
106
+
107
+ message = f"HTTP {response.status_code} fetching {description}"
108
+ if response.status_code not in self.RETRYABLE_STATUS_CODES:
109
+ raise FetchError(message)
110
+
111
+ retry_after = response.headers.get("Retry-After", "")
112
+ try:
113
+ delay = min(float(retry_after), 10.0)
114
+ except ValueError:
115
+ delay = min(0.5 * (2**attempt), 8.0)
116
+ last_error = FetchError(message)
117
+ except (httpx.HTTPError, FetchError) as exc:
118
+ last_error = exc
119
+ if isinstance(exc, FetchError) and "HTTP " in str(exc):
120
+ status_text = str(exc).split("HTTP ", 1)[1].split(" ", 1)[0]
121
+ if (
122
+ status_text.isdigit()
123
+ and int(status_text) not in self.RETRYABLE_STATUS_CODES
124
+ ):
125
+ raise
126
+ delay = min(0.5 * (2**attempt), 8.0)
127
+
128
+ if attempt < self.retries:
129
+ time.sleep(delay)
130
+
131
+ raise FetchError(
132
+ f"Failed after {self.retries + 1} attempts: {description}"
133
+ ) from last_error
134
+
135
+ def close(self) -> None:
136
+ self._client.close()
137
+
138
+
139
+ class MBTilesProvider:
140
+ def __init__(self, source: MapSource) -> None:
141
+ if not source.mbtiles_path:
142
+ raise FetchError("MBTiles source has no archive path")
143
+ self.source = source
144
+ self.path = source.mbtiles_path
145
+ self.cache_hits = 0
146
+ self.downloaded_tiles = 0
147
+ self._lock = threading.Lock()
148
+ try:
149
+ self._connection = sqlite3.connect(self.path, check_same_thread=False)
150
+ self._connection.execute("SELECT 1 FROM tiles LIMIT 1")
151
+ except sqlite3.Error as exc:
152
+ raise FetchError(f"Invalid MBTiles archive: {self.path}") from exc
153
+
154
+ def fetch(self, zoom: int, x: int, y: int, *, force: bool = False) -> bytes:
155
+ del force
156
+ tms_y = (1 << zoom) - 1 - y
157
+ with self._lock:
158
+ row = self._connection.execute(
159
+ "SELECT tile_data FROM tiles "
160
+ "WHERE zoom_level=? AND tile_column=? AND tile_row=?",
161
+ (zoom, x, tms_y),
162
+ ).fetchone()
163
+ if row is None:
164
+ raise FetchError(
165
+ f"Tile is absent from {self.path.name}: z={zoom} x={x} y={y}"
166
+ )
167
+ data = bytes(row[0])
168
+ validate_tile_bytes(
169
+ data, self.source.tile_size, f"{self.path.name} z={zoom} x={x} y={y}"
170
+ )
171
+ with self._lock:
172
+ self.cache_hits += 1
173
+ return data
174
+
175
+ def close(self) -> None:
176
+ self._connection.close()
177
+
178
+
179
+ def create_provider(source: MapSource) -> TileProvider:
180
+ if source.kind == "xyz":
181
+ return HttpTileProvider(source)
182
+ if source.kind == "mbtiles":
183
+ return MBTilesProvider(source)
184
+ raise FetchError(f"Unsupported source kind: {source.kind}")
185
+
186
+
187
+ def fetch_tile_range(
188
+ provider: TileProvider,
189
+ tiles: TileRange,
190
+ *,
191
+ force: bool = False,
192
+ workers: int = 4,
193
+ progress: ProgressCallback | None = None,
194
+ ) -> dict[tuple[int, int], bytes]:
195
+ coordinates = list(tiles.coordinates())
196
+ results: dict[tuple[int, int], bytes] = {}
197
+ completed = 0
198
+
199
+ with ThreadPoolExecutor(max_workers=max(1, min(workers, 16))) as executor:
200
+ futures: dict[Future[bytes], tuple[int, int]] = {
201
+ executor.submit(provider.fetch, tiles.zoom, x, y, force=force): (x, y)
202
+ for x, y in coordinates
203
+ }
204
+ try:
205
+ for future in as_completed(futures):
206
+ x, y = futures[future]
207
+ results[(x, y)] = future.result()
208
+ completed += 1
209
+ if progress:
210
+ progress(completed, len(coordinates), x, y)
211
+ except Exception:
212
+ for future in futures:
213
+ future.cancel()
214
+ raise
215
+
216
+ return results
moos_map/cache.py ADDED
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import re
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from .sources import MapSource
10
+
11
+
12
+ def default_cache_dir() -> Path:
13
+ root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
14
+ return root / "moos-map" / "tiles"
15
+
16
+
17
+ def source_cache_namespace(source: MapSource) -> str:
18
+ identity = source.url_template or str(source.mbtiles_path or source.id)
19
+ digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:12]
20
+ safe_id = re.sub(r"[^A-Za-z0-9_.-]+", "-", source.id)
21
+ return f"{safe_id}-{digest}"
22
+
23
+
24
+ class TileCache:
25
+ def __init__(self, source: MapSource, root: Path | None = None) -> None:
26
+ self.root = (root or default_cache_dir()).expanduser()
27
+ self.namespace = source_cache_namespace(source)
28
+
29
+ def path_for(self, zoom: int, x: int, y: int) -> Path:
30
+ return self.root / self.namespace / str(zoom) / str(x) / f"{y}.tile"
31
+
32
+ def read(self, zoom: int, x: int, y: int) -> bytes | None:
33
+ path = self.path_for(zoom, x, y)
34
+ try:
35
+ return path.read_bytes()
36
+ except FileNotFoundError:
37
+ return None
38
+
39
+ def write(self, zoom: int, x: int, y: int, data: bytes) -> Path:
40
+ path = self.path_for(zoom, x, y)
41
+ path.parent.mkdir(parents=True, exist_ok=True)
42
+ handle, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
43
+ temp_path = Path(temp_name)
44
+ try:
45
+ with os.fdopen(handle, "wb") as stream:
46
+ stream.write(data)
47
+ stream.flush()
48
+ os.fsync(stream.fileno())
49
+ os.replace(temp_path, path)
50
+ finally:
51
+ temp_path.unlink(missing_ok=True)
52
+ return path
moos_map/cli.py ADDED
@@ -0,0 +1,352 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, Sequence
8
+
9
+ from . import __version__
10
+ from .errors import MoosMapError
11
+ from .models import Bounds, MapRequest, Origin
12
+ from .service import build_map, plan_map
13
+ from .sources import list_sources
14
+ from .verification import verify_bundle
15
+
16
+
17
+ def _json_print(value: Any) -> None:
18
+ print(json.dumps(value, indent=2, sort_keys=True))
19
+
20
+
21
+ def _add_machine_output(parser: argparse.ArgumentParser) -> None:
22
+ parser.add_argument(
23
+ "--json", action="store_true", help="Emit machine-readable JSON"
24
+ )
25
+
26
+
27
+ def _add_map_arguments(parser: argparse.ArgumentParser) -> None:
28
+ parser.add_argument(
29
+ "--corners",
30
+ nargs=4,
31
+ type=float,
32
+ metavar=("LAT1", "LON1", "LAT2", "LON2"),
33
+ required=True,
34
+ help=(
35
+ "Two diagonally opposite WGS84 corners as latitude/longitude pairs; "
36
+ "either corner order is accepted"
37
+ ),
38
+ )
39
+ parser.add_argument(
40
+ "--origin",
41
+ nargs=2,
42
+ type=float,
43
+ metavar=("LAT", "LON"),
44
+ help="MOOS LatOrigin and LongOrigin (default: map center)",
45
+ )
46
+ parser.add_argument(
47
+ "--zoom", type=int, default=17, help="XYZ zoom level (default: 17)"
48
+ )
49
+ parser.add_argument(
50
+ "--source",
51
+ default="esri-world-imagery",
52
+ help="Built-in source ID (default: esri-world-imagery)",
53
+ )
54
+ source_group = parser.add_mutually_exclusive_group()
55
+ source_group.add_argument(
56
+ "--url-template",
57
+ help="Custom XYZ URL containing {z}, {x}, and {y}",
58
+ )
59
+ source_group.add_argument(
60
+ "--mbtiles",
61
+ type=Path,
62
+ help="Read tiles from a local MBTiles archive",
63
+ )
64
+ parser.add_argument(
65
+ "--accept-source-terms",
66
+ action="store_true",
67
+ help="Confirm that a custom source permits static/offline export",
68
+ )
69
+ parser.add_argument("--max-tiles", type=int, default=1024)
70
+ parser.add_argument("--max-pixels", type=int, default=67_108_864)
71
+
72
+
73
+ def _request_from_args(args: argparse.Namespace) -> MapRequest:
74
+ latitude_1, longitude_1, latitude_2, longitude_2 = args.corners
75
+ bounds = Bounds(
76
+ west=min(longitude_1, longitude_2),
77
+ south=min(latitude_1, latitude_2),
78
+ east=max(longitude_1, longitude_2),
79
+ north=max(latitude_1, latitude_2),
80
+ )
81
+ if args.origin is None:
82
+ origin = Origin(
83
+ latitude=(bounds.south + bounds.north) / 2,
84
+ longitude=(bounds.west + bounds.east) / 2,
85
+ )
86
+ else:
87
+ latitude, longitude = args.origin
88
+ origin = Origin(latitude=latitude, longitude=longitude)
89
+ return MapRequest(
90
+ bounds=bounds,
91
+ origin=origin,
92
+ zoom=args.zoom,
93
+ source_id=args.source,
94
+ name=getattr(args, "name", "moos_map"),
95
+ output_dir=getattr(args, "output_dir", Path.home() / "moos-maps"),
96
+ emit_moos=getattr(args, "emit_moos", True),
97
+ force=getattr(args, "force", False),
98
+ overwrite=getattr(args, "overwrite", True),
99
+ refresh_tiles=getattr(args, "refresh_tiles", False),
100
+ custom_url_template=args.url_template,
101
+ accept_custom_source_terms=args.accept_source_terms,
102
+ mbtiles_path=args.mbtiles,
103
+ max_tiles=args.max_tiles,
104
+ max_pixels=args.max_pixels,
105
+ )
106
+
107
+
108
+ def _format_file_size(size_bytes: int, *, precise: bool = False) -> str:
109
+ if size_bytes < 1_000:
110
+ return f"{size_bytes:,} bytes"
111
+ if size_bytes < 1_000_000:
112
+ return f"{size_bytes / 1_000:.{1 if precise else 0}f} KB"
113
+ return f"{size_bytes / 1_000_000:.{2 if precise else 1}f} MB"
114
+
115
+
116
+ def _print_plan_human(
117
+ plan: dict[str, Any], *, actual_tiff_size_bytes: int | None = None
118
+ ) -> None:
119
+ source = plan["source"]
120
+ tiles = plan["tiles"]
121
+ actual = plan["actual_bounds"]
122
+ origin = plan["origin"]
123
+ print(f"Source: {source['name']} ({source['id']})")
124
+ print(
125
+ f"Source tiles: {tiles['count']} "
126
+ f"({tiles['columns']} x {tiles['rows']}), zoom {tiles['zoom']}"
127
+ )
128
+ print(f"TIFF dimensions: {plan['pixel_width']} x {plan['pixel_height']} pixels")
129
+ if actual_tiff_size_bytes is None:
130
+ print(
131
+ "Estimated TIFF size: "
132
+ f"~{_format_file_size(plan['estimated_tiff_size_bytes'])} "
133
+ "before content-dependent LZW compression"
134
+ )
135
+ else:
136
+ print(
137
+ "TIFF file size: "
138
+ f"{_format_file_size(actual_tiff_size_bytes, precise=True)} "
139
+ f"({actual_tiff_size_bytes:,} bytes)"
140
+ )
141
+ print(
142
+ "Exact bounds: "
143
+ f"W {actual['west']:.10f}, S {actual['south']:.10f}, "
144
+ f"E {actual['east']:.10f}, N {actual['north']:.10f}"
145
+ )
146
+ print(
147
+ "Mission origin: "
148
+ f"LatOrigin {origin['latitude']:.10f}, "
149
+ f"LongOrigin {origin['longitude']:.10f}"
150
+ )
151
+ print(
152
+ "Ground area: "
153
+ f"{plan['approximate_ground_width_m']:.1f} x "
154
+ f"{plan['approximate_ground_height_m']:.1f} m"
155
+ )
156
+ print(f"Source resolution: {plan['approximate_meters_per_pixel']:.3f} m/pixel")
157
+ print(
158
+ "Display alignment: "
159
+ f"{plan['estimated_max_requested_area_position_error_m']:.1f} m model max "
160
+ "(theoretical, not measured)"
161
+ )
162
+ print("Output crop: exact requested bounds")
163
+ for warning in plan["warnings"]:
164
+ print(f"Warning: {warning}", file=sys.stderr)
165
+
166
+
167
+ def build_parser() -> argparse.ArgumentParser:
168
+ parser = argparse.ArgumentParser(
169
+ prog="moos-map",
170
+ description="Build exact-crop TIFF background maps for MOOS-IvP",
171
+ )
172
+ parser.add_argument(
173
+ "--version", action="version", version=f"%(prog)s {__version__}"
174
+ )
175
+ subparsers = parser.add_subparsers(dest="command", required=True)
176
+
177
+ sources_parser = subparsers.add_parser("sources", help="List available map sources")
178
+ _add_machine_output(sources_parser)
179
+
180
+ plan_parser = subparsers.add_parser(
181
+ "plan", help="Plan an export without downloading"
182
+ )
183
+ _add_map_arguments(plan_parser)
184
+ _add_machine_output(plan_parser)
185
+
186
+ build_command = subparsers.add_parser("build", help="Download and build a MOOS map")
187
+ _add_map_arguments(build_command)
188
+ build_command.add_argument(
189
+ "--name", required=True, help="Output basename without suffix"
190
+ )
191
+ build_command.add_argument(
192
+ "--output-dir",
193
+ type=Path,
194
+ default=Path.home() / "moos-maps",
195
+ help="Output directory (default: ~/moos-maps)",
196
+ )
197
+ build_command.add_argument(
198
+ "--no-moos",
199
+ dest="emit_moos",
200
+ action="store_false",
201
+ default=True,
202
+ help="Do not write the default .moos mission snippet",
203
+ )
204
+ build_command.add_argument(
205
+ "--emit-moos",
206
+ dest="emit_moos",
207
+ action="store_true",
208
+ help=argparse.SUPPRESS,
209
+ )
210
+ build_command.add_argument(
211
+ "--no-overwrite",
212
+ dest="overwrite",
213
+ action="store_false",
214
+ default=True,
215
+ help="Refuse to replace existing output files with the same map name",
216
+ )
217
+ build_command.add_argument(
218
+ "--overwrite",
219
+ dest="overwrite",
220
+ action="store_true",
221
+ help=argparse.SUPPRESS,
222
+ )
223
+ build_command.add_argument(
224
+ "--refresh-tiles",
225
+ action="store_true",
226
+ help="Redownload source tiles instead of reusing the local cache",
227
+ )
228
+ build_command.add_argument(
229
+ "--force",
230
+ action="store_true",
231
+ help=argparse.SUPPRESS,
232
+ )
233
+ _add_machine_output(build_command)
234
+
235
+ verify_parser = subparsers.add_parser("verify", help="Verify a TIFF/.info bundle")
236
+ verify_parser.add_argument("tiff", type=Path)
237
+ _add_machine_output(verify_parser)
238
+
239
+ ui_parser = subparsers.add_parser("ui", help="Launch the local browser UI")
240
+ ui_parser.add_argument(
241
+ "--host", default="127.0.0.1", help="Local bind address (default: 127.0.0.1)"
242
+ )
243
+ ui_parser.add_argument("--port", type=int, default=8765, help="Port (default: 8765)")
244
+ ui_parser.add_argument(
245
+ "--no-browser",
246
+ action="store_true",
247
+ help="Start the server without opening a browser window",
248
+ )
249
+
250
+ return parser
251
+
252
+
253
+ def run(args: argparse.Namespace) -> int:
254
+ if args.command == "sources":
255
+ sources = [source.as_dict() for source in list_sources()]
256
+ if args.json:
257
+ _json_print({"sources": sources})
258
+ else:
259
+ print("Sources:")
260
+ for source in sources:
261
+ capability = "export" if source["export_allowed"] else "preview only"
262
+ print(
263
+ f"{source['id']:<16} {capability:<12} "
264
+ f"zoom {source['min_zoom']}-{source['max_zoom']} {source['name']}"
265
+ )
266
+ if source["note"]:
267
+ print(f" {source['note']}")
268
+ return 0
269
+
270
+ if args.command == "plan":
271
+ plan = plan_map(_request_from_args(args)).as_dict()
272
+ if args.json:
273
+ _json_print(plan)
274
+ else:
275
+ _print_plan_human(plan)
276
+ return 0
277
+
278
+ if args.command == "build":
279
+ request = _request_from_args(args)
280
+
281
+ def progress(completed: int, total: int, x: int, y: int) -> None:
282
+ print(
283
+ f"Fetched {completed}/{total} tiles (latest x={x}, y={y})",
284
+ file=sys.stderr,
285
+ )
286
+
287
+ result = build_map(request, progress=progress)
288
+ payload = result.as_dict()
289
+ if args.json:
290
+ _json_print(payload)
291
+ else:
292
+ _print_plan_human(
293
+ payload["plan"],
294
+ actual_tiff_size_bytes=payload["verification"]["details"][
295
+ "file_size_bytes"
296
+ ],
297
+ )
298
+ print(f"TIFF: {payload['tiff_path']}")
299
+ print(f"Info: {payload['info_path']}")
300
+ if payload["moos_path"]:
301
+ print(f"MOOS snippet: {payload['moos_path']}")
302
+ print(
303
+ f"Tiles downloaded: {payload['downloaded_tiles']}; "
304
+ f"cache hits: {payload['cache_hits']}"
305
+ )
306
+ return 0
307
+
308
+ if args.command == "verify":
309
+ report = verify_bundle(args.tiff)
310
+ payload = report.as_dict()
311
+ if args.json:
312
+ _json_print(payload)
313
+ else:
314
+ print("PASS" if report.ok else "FAIL")
315
+ print(f"TIFF: {report.tiff_path}")
316
+ print(f"Info: {report.info_path}")
317
+ if "pixel_width" in report.details:
318
+ print(
319
+ "Image: "
320
+ f"{report.details['pixel_width']} x "
321
+ f"{report.details['pixel_height']} pixels, "
322
+ f"{report.details['mode']} {report.details['format']}"
323
+ )
324
+ size_bytes = report.details["file_size_bytes"]
325
+ print(
326
+ "TIFF file size: "
327
+ f"{_format_file_size(size_bytes, precise=True)} "
328
+ f"({size_bytes:,} bytes)"
329
+ )
330
+ for warning in report.warnings:
331
+ print(f"Warning: {warning}")
332
+ for error in report.errors:
333
+ print(f"Error: {error}", file=sys.stderr)
334
+ return 0 if report.ok else 1
335
+
336
+ if args.command == "ui":
337
+ from .web import run_ui
338
+
339
+ run_ui(host=args.host, port=args.port, open_browser=not args.no_browser)
340
+ return 0
341
+
342
+ raise AssertionError(f"Unhandled command: {args.command}")
343
+
344
+
345
+ def main(argv: Sequence[str] | None = None) -> None:
346
+ parser = build_parser()
347
+ args = parser.parse_args(argv)
348
+ try:
349
+ raise SystemExit(run(args))
350
+ except MoosMapError as exc:
351
+ print(f"Error: {exc}", file=sys.stderr)
352
+ raise SystemExit(2) from exc
moos_map/errors.py ADDED
@@ -0,0 +1,14 @@
1
+ class MoosMapError(Exception):
2
+ """Base exception for expected, user-facing failures."""
3
+
4
+
5
+ class ValidationError(MoosMapError):
6
+ """Input or output failed validation."""
7
+
8
+
9
+ class SourcePolicyError(MoosMapError):
10
+ """The selected provider does not permit this operation."""
11
+
12
+
13
+ class FetchError(MoosMapError):
14
+ """A source tile could not be acquired or decoded."""