diff --git a/AGENTS.md b/AGENTS.md index 7c260ad..1344016 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,15 +2,15 @@ ## Current status -Rebuild initialized. +Step 1 complete. ## Completed steps -None yet. +Step 1 - Public API contract and pure core. ## Current step -Step 1 - Public API contract and pure core. +Step 2 - Thread-safe state, commands, overlays, and cache model. ## Design decisions @@ -31,7 +31,13 @@ None yet. - Read `STEPS.md`, `FEATURES.md`, and `ARCHITECTURE.md`. - Created initial package, examples, tests, and agent-log structure. +- Implemented public exports, exceptions, common types, tile provider registry, projection helpers, cache dataclasses, and GUI-dependent API stubs. +- Added Step 1 tests for imports, providers, projection, and cache dataclasses. +- Ran `uv run pytest`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format --check .`. +- Ran `uv run pyright`. ## Next action -Implement Step 1. +Implement Step 2. diff --git a/README.md b/README.md index e69de29..c20800c 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,26 @@ +# dpg-map + +`dpg-map` is a Dear PyGui map widget package under rebuild. + +The Step 1 public API contract is in place: + +```python +import dpg_map as dpgm + +provider = dpgm.TileProvider( + name="custom", + url_template="https://example.com/{z}/{x}/{y}.png", + attribution="Tiles (c) Example", +) +dpgm.register_provider(provider) +``` + +Implemented in Step 1: + +- public package exports +- tile provider definitions and registry +- Web Mercator projection helpers +- initial cache dataclasses +- explicit stubs for GUI-dependent public functions + +GUI widget rendering and runtime state updates are planned for later rebuild steps. diff --git a/src/dpg_map/__init__.py b/src/dpg_map/__init__.py index 2ff2eb0..dd40723 100644 --- a/src/dpg_map/__init__.py +++ b/src/dpg_map/__init__.py @@ -1,2 +1,87 @@ +"""Public API for dpg-map.""" + +from .api import ( + add_layer, + add_marker, + add_polyline, + add_trajectory, + clear_disk_cache, + clear_layer, + clear_map, + clear_memory_cache, + configure, + delete_overlay, + fit_bounds, + get_cache_stats, + get_center, + get_map_debug_state, + get_zoom, + hide_layer, + latlon_to_screen, + screen_to_latlon, + set_center, + set_marker_label, + set_marker_position, + set_overlay_show, + set_polyline_points, + set_provider, + set_view, + set_zoom, + show_layer, + update_marker, + update_polyline, + update_trajectory, +) +from .providers import ( + TileProvider, + get_provider, + list_providers, + register_provider, + unregister_provider, +) +from .widget import map_widget + +__all__ = [ + "TileProvider", + "add_layer", + "add_marker", + "add_polyline", + "add_trajectory", + "clear_disk_cache", + "clear_layer", + "clear_map", + "clear_memory_cache", + "configure", + "delete_overlay", + "fit_bounds", + "get_cache_stats", + "get_center", + "get_map_debug_state", + "get_provider", + "get_zoom", + "hide_layer", + "latlon_to_screen", + "list_providers", + "map_widget", + "register_provider", + "screen_to_latlon", + "set_center", + "set_marker_label", + "set_marker_position", + "set_overlay_show", + "set_polyline_points", + "set_provider", + "set_view", + "set_zoom", + "show_layer", + "unregister_provider", + "update_marker", + "update_polyline", + "update_trajectory", +] + + def main() -> None: - print("Hello from dpg-map!") + """Console entry point placeholder.""" + + print("dpg-map") diff --git a/src/dpg_map/api.py b/src/dpg_map/api.py index 7b4a3b9..1ae60fe 100644 --- a/src/dpg_map/api.py +++ b/src/dpg_map/api.py @@ -1 +1,211 @@ """Public API wrappers for dpg-map.""" + +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any, NoReturn + +from .cache import CacheStats +from .exceptions import DpgMapNotImplementedError +from .providers import TileProvider +from .types import Bounds, LatLon, Point, Tag + + +def _stub(name: str) -> NoReturn: + raise DpgMapNotImplementedError(f"dpg_map.{name} is not implemented until a later rebuild step") + + +def configure( + *, + user_agent: str | None = None, + cache_dir: str | Path | None = None, + default_provider: str | TileProvider = "osm", + memory_cache_max_tiles: int = 512, + disk_cache_max_bytes: int | None = 2_000_000_000, + prefetch_margin_tiles: int = 1, + tile_worker_count: int = 4, + overlay_update_policy: str = "coalesce", + debug: bool = False, +) -> None: + _stub("configure") + + +def set_center(lat: float, lon: float, *, map_tag: Tag | None = None) -> None: + _stub("set_center") + + +def get_center(*, map_tag: Tag | None = None) -> LatLon: + _stub("get_center") + + +def set_zoom(zoom: int, *, map_tag: Tag | None = None) -> None: + _stub("set_zoom") + + +def get_zoom(*, map_tag: Tag | None = None) -> int: + _stub("get_zoom") + + +def set_view( + *, + center: LatLon | None = None, + zoom: int | None = None, + map_tag: Tag | None = None, +) -> None: + _stub("set_view") + + +def fit_bounds(bounds: Bounds, *, map_tag: Tag | None = None) -> None: + _stub("fit_bounds") + + +def screen_to_latlon(x: float, y: float, *, map_tag: Tag | None = None) -> LatLon: + _stub("screen_to_latlon") + + +def latlon_to_screen(lat: float, lon: float, *, map_tag: Tag | None = None) -> Point: + _stub("latlon_to_screen") + + +def add_marker( + tag: Tag, + *, + lat: float, + lon: float, + label: str | None = None, + layer: str = "default", + show: bool = True, + map_tag: Tag | None = None, + **kwargs: Any, +) -> Tag: + _stub("add_marker") + + +def add_polyline( + tag: Tag, + *, + points: Sequence[LatLon] | None = None, + lats: Sequence[float] | None = None, + lons: Sequence[float] | None = None, + layer: str = "default", + show: bool = True, + map_tag: Tag | None = None, + **kwargs: Any, +) -> Tag: + _stub("add_polyline") + + +def add_trajectory( + tag: Tag, + *, + points: Sequence[LatLon] | None = None, + lats: Sequence[float] | None = None, + lons: Sequence[float] | None = None, + layer: str = "default", + show: bool = True, + map_tag: Tag | None = None, + **kwargs: Any, +) -> Tag: + _stub("add_trajectory") + + +def update_marker( + tag: Tag, + *, + lat: float | None = None, + lon: float | None = None, + label: str | None = None, + map_tag: Tag | None = None, + **kwargs: Any, +) -> None: + _stub("update_marker") + + +def update_polyline( + tag: Tag, + *, + points: Sequence[LatLon] | None = None, + lats: Sequence[float] | None = None, + lons: Sequence[float] | None = None, + map_tag: Tag | None = None, + **kwargs: Any, +) -> None: + _stub("update_polyline") + + +def update_trajectory( + tag: Tag, + *, + points: Sequence[LatLon] | None = None, + lats: Sequence[float] | None = None, + lons: Sequence[float] | None = None, + map_tag: Tag | None = None, + **kwargs: Any, +) -> None: + _stub("update_trajectory") + + +def set_marker_position(tag: Tag, lat: float, lon: float, *, map_tag: Tag | None = None) -> None: + _stub("set_marker_position") + + +def set_marker_label(tag: Tag, label: str, *, map_tag: Tag | None = None) -> None: + _stub("set_marker_label") + + +def set_polyline_points( + tag: Tag, + points: Sequence[LatLon], + *, + map_tag: Tag | None = None, +) -> None: + _stub("set_polyline_points") + + +def set_overlay_show(tag: Tag, show: bool, *, map_tag: Tag | None = None) -> None: + _stub("set_overlay_show") + + +def delete_overlay(tag: Tag, *, map_tag: Tag | None = None) -> None: + _stub("delete_overlay") + + +def add_layer(name: str, *, show: bool = True, map_tag: Tag | None = None) -> None: + _stub("add_layer") + + +def show_layer(name: str, *, map_tag: Tag | None = None) -> None: + _stub("show_layer") + + +def hide_layer(name: str, *, map_tag: Tag | None = None) -> None: + _stub("hide_layer") + + +def clear_layer(name: str, *, map_tag: Tag | None = None) -> None: + _stub("clear_layer") + + +def clear_map(*, map_tag: Tag | None = None) -> None: + _stub("clear_map") + + +def set_provider(provider: str | TileProvider, *, map_tag: Tag | None = None) -> None: + _stub("set_provider") + + +def clear_memory_cache(*, map_tag: Tag | None = None) -> None: + _stub("clear_memory_cache") + + +def clear_disk_cache(*, map_tag: Tag | None = None) -> None: + _stub("clear_disk_cache") + + +def get_cache_stats(*, map_tag: Tag | None = None) -> CacheStats: + _stub("get_cache_stats") + + +def get_map_debug_state(*, map_tag: Tag | None = None) -> dict[str, Any]: + _stub("get_map_debug_state") diff --git a/src/dpg_map/cache.py b/src/dpg_map/cache.py index 4417749..23043af 100644 --- a/src/dpg_map/cache.py +++ b/src/dpg_map/cache.py @@ -1 +1,36 @@ """Memory and disk cache helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class CacheStats: + """Public cache statistics snapshot.""" + + memory_tiles: int = 0 + memory_max_tiles: int = 0 + memory_hits: int = 0 + memory_misses: int = 0 + disk_bytes: int = 0 + disk_max_bytes: int | None = None + disk_hits: int = 0 + disk_misses: int = 0 + disk_path: Path | None = None + + +@dataclass(slots=True) +class MemoryCacheConfig: + """Initial memory cache configuration.""" + + max_tiles: int = 512 + + +@dataclass(slots=True) +class DiskCacheConfig: + """Initial persistent disk cache configuration.""" + + path: Path | None = None + max_bytes: int | None = 2_000_000_000 diff --git a/src/dpg_map/exceptions.py b/src/dpg_map/exceptions.py index de2e52c..8e8d51e 100644 --- a/src/dpg_map/exceptions.py +++ b/src/dpg_map/exceptions.py @@ -1 +1,29 @@ -"""Public exception types.""" +"""Public exception types for dpg-map.""" + + +class DpgMapError(Exception): + """Base exception for all public dpg-map errors.""" + + +class DpgMapNotImplementedError(DpgMapError, NotImplementedError): + """Raised by public APIs that are intentionally stubbed during the rebuild.""" + + +class ProviderError(DpgMapError): + """Base exception for tile provider errors.""" + + +class ProviderExistsError(ProviderError): + """Raised when registering a provider name that already exists.""" + + +class ProviderNotFoundError(ProviderError): + """Raised when a requested tile provider is not registered.""" + + +class InvalidProviderError(ProviderError, ValueError): + """Raised when a tile provider definition is invalid.""" + + +class ProjectionError(DpgMapError, ValueError): + """Raised when geographic projection input is invalid.""" diff --git a/src/dpg_map/projection.py b/src/dpg_map/projection.py index 2debb42..2d893cd 100644 --- a/src/dpg_map/projection.py +++ b/src/dpg_map/projection.py @@ -1 +1,92 @@ """Web Mercator projection helpers.""" + +from __future__ import annotations + +import math + +from .exceptions import ProjectionError +from .types import LatLon, Point + +WEB_MERCATOR_MAX_LAT = 85.05112878 +DEFAULT_TILE_SIZE = 256 + + +def clamp_latitude(lat: float) -> float: + """Clamp latitude to the Web Mercator representable range.""" + + return min(max(lat, -WEB_MERCATOR_MAX_LAT), WEB_MERCATOR_MAX_LAT) + + +def map_size(zoom: int, tile_size: int = DEFAULT_TILE_SIZE) -> int: + """Return square pixel size of the full world at a zoom level.""" + + if zoom < 0: + raise ProjectionError("zoom must be >= 0") + if tile_size <= 0: + raise ProjectionError("tile_size must be > 0") + return tile_size * (2**zoom) + + +def latlon_to_world(lat: float, lon: float, zoom: int, tile_size: int = DEFAULT_TILE_SIZE) -> Point: + """Project latitude/longitude to world pixel coordinates.""" + + size = map_size(zoom, tile_size) + lat = clamp_latitude(lat) + lon = ((lon + 180.0) % 360.0) - 180.0 + + sin_lat = math.sin(math.radians(lat)) + x = (lon + 180.0) / 360.0 * size + y = (0.5 - math.log((1.0 + sin_lat) / (1.0 - sin_lat)) / (4.0 * math.pi)) * size + return (x, y) + + +def world_to_latlon(x: float, y: float, zoom: int, tile_size: int = DEFAULT_TILE_SIZE) -> LatLon: + """Unproject world pixel coordinates to latitude/longitude.""" + + size = map_size(zoom, tile_size) + lon = x / size * 360.0 - 180.0 + n = math.pi - 2.0 * math.pi * y / size + lat = math.degrees(math.atan(math.sinh(n))) + return (lat, lon) + + +def latlon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int, int]: + """Return the XYZ tile coordinate containing a latitude/longitude point.""" + + x, y = latlon_to_world(lat, lon, zoom) + scale = 2**zoom + tile_x = min(max(int(x // DEFAULT_TILE_SIZE), 0), scale - 1) + tile_y = min(max(int(y // DEFAULT_TILE_SIZE), 0), scale - 1) + return (tile_x, tile_y, zoom) + + +def world_to_screen( + world_x: float, + world_y: float, + *, + center: LatLon, + zoom: int, + width: int, + height: int, + tile_size: int = DEFAULT_TILE_SIZE, +) -> Point: + """Convert world pixels to screen pixels for a centered viewport.""" + + center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size) + return (world_x - center_x + width / 2.0, world_y - center_y + height / 2.0) + + +def screen_to_world( + screen_x: float, + screen_y: float, + *, + center: LatLon, + zoom: int, + width: int, + height: int, + tile_size: int = DEFAULT_TILE_SIZE, +) -> Point: + """Convert screen pixels to world pixels for a centered viewport.""" + + center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size) + return (screen_x + center_x - width / 2.0, screen_y + center_y - height / 2.0) diff --git a/src/dpg_map/providers.py b/src/dpg_map/providers.py index 291b047..94143f1 100644 --- a/src/dpg_map/providers.py +++ b/src/dpg_map/providers.py @@ -1 +1,145 @@ """Tile provider definitions and registry.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from string import Formatter +from threading import RLock + +from .exceptions import InvalidProviderError, ProviderExistsError, ProviderNotFoundError + +_REQUIRED_TEMPLATE_FIELDS = frozenset({"x", "y", "z"}) +_OPTIONAL_TEMPLATE_FIELDS = frozenset({"s", "r", "ext"}) + + +@dataclass(frozen=True, slots=True) +class TileProvider: + """XYZ raster tile provider definition.""" + + name: str + url_template: str + min_zoom: int = 0 + max_zoom: int = 19 + tile_size: int = 256 + attribution: str = "" + headers: dict[str, str] = field(default_factory=dict) + subdomains: tuple[str, ...] = () + retina: bool = False + file_extension: str | None = None + + def __post_init__(self) -> None: + name = self.name.strip() + if not name: + raise InvalidProviderError("provider name must not be empty") + if name != self.name: + object.__setattr__(self, "name", name) + + if not self.url_template.strip(): + raise InvalidProviderError("provider url_template must not be empty") + + fields = { + field_name + for _, field_name, _, _ in Formatter().parse(self.url_template) + if field_name is not None and field_name != "" + } + missing = _REQUIRED_TEMPLATE_FIELDS - fields + if missing: + missing_text = ", ".join(sorted(missing)) + raise InvalidProviderError(f"provider url_template missing field(s): {missing_text}") + + unknown = fields - _REQUIRED_TEMPLATE_FIELDS - _OPTIONAL_TEMPLATE_FIELDS + if unknown: + unknown_text = ", ".join(sorted(unknown)) + raise InvalidProviderError( + f"provider url_template contains unknown field(s): {unknown_text}" + ) + + if self.min_zoom < 0: + raise InvalidProviderError("provider min_zoom must be >= 0") + if self.max_zoom < self.min_zoom: + raise InvalidProviderError("provider max_zoom must be >= min_zoom") + if self.tile_size <= 0: + raise InvalidProviderError("provider tile_size must be > 0") + + object.__setattr__(self, "headers", dict(self.headers)) + object.__setattr__(self, "subdomains", tuple(self.subdomains)) + + def build_url(self, *, x: int, y: int, z: int) -> str: + """Build a concrete tile URL for an XYZ tile coordinate.""" + + if z < self.min_zoom or z > self.max_zoom: + raise InvalidProviderError( + f"zoom {z} is outside provider range {self.min_zoom}-{self.max_zoom}" + ) + + subdomain = "" + if self.subdomains: + subdomain = self.subdomains[(x + y + z) % len(self.subdomains)] + + retina_suffix = "@2x" if self.retina else "" + extension = self.file_extension or "" + return self.url_template.format( + x=x, + y=y, + z=z, + s=subdomain, + r=retina_suffix, + ext=extension, + ) + + +OSM = TileProvider( + name="osm", + url_template="https://tile.openstreetmap.org/{z}/{x}/{y}.png", + min_zoom=0, + max_zoom=19, + tile_size=256, + attribution="\u00a9 OpenStreetMap contributors", +) + +_providers: dict[str, TileProvider] = {OSM.name: OSM} +_providers_lock = RLock() + + +def register_provider(provider: TileProvider, *, replace: bool = False) -> None: + """Register a tile provider by name.""" + + if not isinstance(provider, TileProvider): + raise InvalidProviderError("provider must be a TileProvider") + + with _providers_lock: + if provider.name in _providers and not replace: + raise ProviderExistsError(f"provider already registered: {provider.name}") + _providers[provider.name] = provider + + +def unregister_provider(name: str) -> None: + """Remove a registered tile provider.""" + + with _providers_lock: + if name not in _providers: + raise ProviderNotFoundError(f"provider not registered: {name}") + del _providers[name] + + +def get_provider(name: str) -> TileProvider: + """Return a registered tile provider.""" + + with _providers_lock: + try: + return _providers[name] + except KeyError as exc: + raise ProviderNotFoundError(f"provider not registered: {name}") from exc + + +def get_default_provider() -> TileProvider: + """Return the default OpenStreetMap provider.""" + + return get_provider(OSM.name) + + +def list_providers() -> list[str]: + """List registered provider names in stable sorted order.""" + + with _providers_lock: + return sorted(_providers) diff --git a/src/dpg_map/types.py b/src/dpg_map/types.py index 7b1b16f..97a107e 100644 --- a/src/dpg_map/types.py +++ b/src/dpg_map/types.py @@ -1 +1,31 @@ """Shared type aliases and small value objects.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypeAlias + +Tag: TypeAlias = str | int +LatLon: TypeAlias = tuple[float, float] +Point: TypeAlias = tuple[float, float] +Bounds: TypeAlias = tuple[LatLon, LatLon] +TileCoord: TypeAlias = tuple[int, int, int] +Color: TypeAlias = tuple[int, int, int] | tuple[int, int, int, int] + + +@dataclass(frozen=True, slots=True) +class ScreenPoint: + """A pixel position in map widget coordinates.""" + + x: float + y: float + + +@dataclass(frozen=True, slots=True) +class GeoBounds: + """Latitude/longitude bounds with south-west and north-east corners.""" + + south: float + west: float + north: float + east: float diff --git a/src/dpg_map/widget.py b/src/dpg_map/widget.py index 327d3c0..2f58b36 100644 --- a/src/dpg_map/widget.py +++ b/src/dpg_map/widget.py @@ -1 +1,31 @@ """Dear PyGui map widget construction.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager + +from .exceptions import DpgMapNotImplementedError +from .providers import TileProvider +from .types import LatLon, Tag + + +@contextmanager +def map_widget( + *, + tag: Tag | None = None, + center: LatLon = (0.0, 0.0), + zoom: int = 2, + provider: str | TileProvider | None = None, + width: int = 0, + height: int = 0, + autosize_x: bool = False, + autosize_y: bool = False, + **kwargs: object, +) -> Iterator[Tag | None]: + """Stub context manager for the future Dear PyGui widget.""" + + raise DpgMapNotImplementedError( + "dpg_map.map_widget is not implemented until the GUI rebuild steps" + ) + yield tag diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..912d7e4 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from pathlib import Path + +from dpg_map.cache import CacheStats, DiskCacheConfig, MemoryCacheConfig + + +def test_cache_stats_dataclass_construction() -> None: + stats = CacheStats( + memory_tiles=3, + memory_max_tiles=512, + memory_hits=10, + memory_misses=2, + disk_bytes=1024, + disk_max_bytes=None, + disk_hits=7, + disk_misses=1, + disk_path=Path("/tmp/dpg-map-cache"), + ) + + assert stats.memory_tiles == 3 + assert stats.disk_max_bytes is None + assert stats.disk_path == Path("/tmp/dpg-map-cache") + + +def test_initial_cache_config_dataclasses() -> None: + memory_config = MemoryCacheConfig() + disk_config = DiskCacheConfig() + + assert memory_config.max_tiles == 512 + assert disk_config.max_bytes == 2_000_000_000 diff --git a/tests/test_projection.py b/tests/test_projection.py new file mode 100644 index 0000000..38c6f97 --- /dev/null +++ b/tests/test_projection.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import pytest + +from dpg_map.projection import ( + WEB_MERCATOR_MAX_LAT, + clamp_latitude, + latlon_to_tile, + latlon_to_world, + screen_to_world, + world_to_latlon, + world_to_screen, +) + + +@pytest.mark.parametrize( + ("lat", "lon", "zoom"), + [ + (0.0, 0.0, 0), + (47.9029, 1.9093, 15), + (-33.8688, 151.2093, 10), + (WEB_MERCATOR_MAX_LAT, 179.999, 4), + ], +) +def test_projection_roundtrip(lat: float, lon: float, zoom: int) -> None: + x, y = latlon_to_world(lat, lon, zoom) + roundtrip_lat, roundtrip_lon = world_to_latlon(x, y, zoom) + + assert roundtrip_lat == pytest.approx(clamp_latitude(lat), abs=1e-7) + assert roundtrip_lon == pytest.approx(lon, abs=1e-7) + + +def test_latlon_to_tile_returns_xyz_coordinate() -> None: + assert latlon_to_tile(0.0, 0.0, 1) == (1, 1, 1) + + +def test_world_screen_roundtrip() -> None: + center = (47.9029, 1.9093) + world = latlon_to_world(47.91, 1.92, 14) + screen = world_to_screen(*world, center=center, zoom=14, width=800, height=600) + + assert screen_to_world(*screen, center=center, zoom=14, width=800, height=600) == pytest.approx( + world + ) diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..3e37bfd --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import pytest + +import dpg_map as dpgm +from dpg_map.exceptions import InvalidProviderError, ProviderExistsError, ProviderNotFoundError + + +def test_default_provider_is_registered() -> None: + provider = dpgm.get_provider("osm") + + assert provider.name == "osm" + assert "osm" in dpgm.list_providers() + assert provider.build_url(x=1, y=2, z=3) == "https://tile.openstreetmap.org/3/1/2.png" + + +def test_provider_registration_roundtrip() -> None: + provider = dpgm.TileProvider( + name="unit-test-provider", + url_template="https://tiles.example.test/{z}/{x}/{y}.png", + attribution="Example", + ) + + dpgm.register_provider(provider) + try: + assert dpgm.get_provider("unit-test-provider") == provider + assert "unit-test-provider" in dpgm.list_providers() + finally: + dpgm.unregister_provider("unit-test-provider") + + with pytest.raises(ProviderNotFoundError): + dpgm.get_provider("unit-test-provider") + + +def test_duplicate_provider_registration_fails() -> None: + provider = dpgm.TileProvider( + name="unit-test-duplicate", + url_template="https://tiles.example.test/{z}/{x}/{y}.png", + ) + + dpgm.register_provider(provider) + try: + with pytest.raises(ProviderExistsError): + dpgm.register_provider(provider) + finally: + dpgm.unregister_provider("unit-test-duplicate") + + +def test_provider_url_building_with_subdomains_retina_and_extension() -> None: + provider = dpgm.TileProvider( + name="unit-test-template", + url_template="https://{s}.tiles.example.test/{z}/{x}/{y}{r}.{ext}", + subdomains=("a", "b", "c"), + retina=True, + file_extension="webp", + ) + + assert provider.build_url(x=1, y=2, z=3) == "https://a.tiles.example.test/3/1/2@2x.webp" + + +@pytest.mark.parametrize( + "template", + [ + "https://tiles.example.test/{z}/{x}.png", + "https://tiles.example.test/{z}/{x}/{y}/{quadkey}.png", + "", + ], +) +def test_invalid_provider_templates_fail(template: str) -> None: + with pytest.raises(InvalidProviderError): + dpgm.TileProvider(name="bad-template", url_template=template) diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..26d781d --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,48 @@ +from __future__ import annotations + + +def test_package_exports_required_public_api() -> None: + import dpg_map as dpgm + + expected = { + "configure", + "TileProvider", + "register_provider", + "unregister_provider", + "get_provider", + "list_providers", + "map_widget", + "set_center", + "get_center", + "set_zoom", + "get_zoom", + "set_view", + "fit_bounds", + "screen_to_latlon", + "latlon_to_screen", + "add_marker", + "add_polyline", + "add_trajectory", + "update_marker", + "update_polyline", + "update_trajectory", + "set_marker_position", + "set_marker_label", + "set_polyline_points", + "set_overlay_show", + "delete_overlay", + "add_layer", + "show_layer", + "hide_layer", + "clear_layer", + "clear_map", + "set_provider", + "clear_memory_cache", + "clear_disk_cache", + "get_cache_stats", + "get_map_debug_state", + } + + assert set(dpgm.__all__) == expected + for name in expected: + assert hasattr(dpgm, name)