# geoelevation/map_viewer/map_services.py """ Defines an abstract base class for map tile services and provides concrete implementations for specific map providers (e.g., OpenStreetMap). This allows the application to interact with different map sources through a common, well-defined interface, facilitating extensibility to other services. """ # Standard library imports import abc # Abstract Base Classes import logging from urllib.parse import urlparse # For basic URL validation from typing import Optional, Tuple, Dict, Any # Module-level logger logger = logging.getLogger(__name__) class BaseMapService(abc.ABC): """ Abstract Base Class for map tile service providers. Subclasses must implement the 'name', 'attribution', and 'get_tile_url' properties and methods. """ DEFAULT_TILE_PIXEL_SIZE: int = 256 DEFAULT_MAX_ZOOM_LEVEL: int = 19 # Common for many services like OSM def __init__( self, service_api_key: Optional[str] = None, tile_pixel_dim: int = DEFAULT_TILE_PIXEL_SIZE, max_supported_zoom: int = DEFAULT_MAX_ZOOM_LEVEL ) -> None: """ Initializes the BaseMapService. Args: service_api_key: API key required by the service, if any. tile_pixel_dim: The pixel dimension (width/height) of map tiles. max_supported_zoom: The maximum zoom level supported by this service. """ # Use class name of the concrete subclass for logging self._service_log_prefix = f"[{self.__class__.__name__}]" logger.debug(f"{self._service_log_prefix} Initializing base map service.") self.api_key: Optional[str] = service_api_key self.tile_size: int = tile_pixel_dim self.max_zoom: int = max_supported_zoom # Validate provided tile_size and max_zoom if not (isinstance(self.tile_size, int) and self.tile_size > 0): logger.warning( f"{self._service_log_prefix} Invalid tile_size '{self.tile_size}'. " f"Using default: {self.DEFAULT_TILE_PIXEL_SIZE}px." ) self.tile_size = self.DEFAULT_TILE_PIXEL_SIZE # Practical limits for Web Mercator zoom levels if not (isinstance(self.max_zoom, int) and 0 <= self.max_zoom <= 25): logger.warning( f"{self._service_log_prefix} Invalid max_zoom '{self.max_zoom}'. " f"Using default: {self.DEFAULT_MAX_ZOOM_LEVEL}." ) self.max_zoom = self.DEFAULT_MAX_ZOOM_LEVEL @property @abc.abstractmethod def name(self) -> str: """ Returns the unique, short name of the map service (e.g., 'osm'). This is used for identification and potentially for cache directory naming. """ pass @property @abc.abstractmethod def attribution(self) -> str: """ Returns the required attribution text for the map service. This text should be displayed whenever map tiles from this service are shown. """ pass @abc.abstractmethod def get_tile_url(self, z: int, x: int, y: int) -> Optional[str]: """ Generates the full URL for a specific map tile based on its ZXY coordinates. Args: z: The zoom level of the tile. x: The X coordinate of the tile. y: The Y coordinate of the tile. Returns: The fully formed URL string for the tile, or None if the zoom level is invalid for this service or if URL construction fails. """ pass def is_zoom_level_valid(self, zoom_level: int) -> bool: """ Checks if the requested zoom level is within the valid range for this service. Args: zoom_level: The zoom level to validate. Returns: True if the zoom level is valid, False otherwise. """ is_valid = 0 <= zoom_level <= self.max_zoom if not is_valid: logger.warning( f"{self._service_log_prefix} Requested zoom level {zoom_level} is outside " f"the valid range [0, {self.max_zoom}] for this service." ) return is_valid def _is_generated_url_structurally_valid(self, url_string: str) -> bool: """Performs a basic structural validation of a generated URL string.""" if not url_string: # Check for empty string logger.error(f"{self._service_log_prefix} Generated URL is empty.") return False try: parsed_url = urlparse(url_string) # A valid URL typically has a scheme (http/https) and a netloc (domain name). has_scheme_and_netloc = bool(parsed_url.scheme and parsed_url.netloc) if not has_scheme_and_netloc: logger.error(f"{self._service_log_prefix} Generated URL '{url_string}' appears malformed (missing scheme or netloc).") return has_scheme_and_netloc except Exception as e_url_parse: # Catch potential errors from urlparse itself logger.error( f"{self._service_log_prefix} Error parsing generated URL '{url_string}': {e_url_parse}" ) return False def __repr__(self) -> str: """Provides a concise and informative string representation of the service object.""" return ( f"<{self.__class__.__name__}(Name: '{self.name}', MaxZoom: {self.max_zoom}, TileSize: {self.tile_size})>" ) class OpenStreetMapService(BaseMapService): """ Concrete implementation for fetching map tiles from OpenStreetMap (OSM). This service does not require an API key. """ SERVICE_IDENTIFIER_NAME: str = "osm" SERVICE_ATTRIBUTION_TEXT: str = \ "© OpenStreetMap contributors (openstreetmap.org/copyright)" # Standard URL template for OSM tiles. Subdomains (a,b,c) can be used for load balancing. TILE_URL_TEMPLATE: str = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" # OSM standard tile servers typically support up to zoom level 19. OSM_MAX_ZOOM_LEVEL: int = 19 # Optional: cycle through subdomains for better load distribution SUBDOMAINS: Tuple[str, ...] = ('a', 'b', 'c') _subdomain_index: int = 0 # Class variable for simple round-robin def __init__(self) -> None: """Initializes the OpenStreetMap tile service.""" super().__init__( service_api_key=None, # OSM does not require an API key max_supported_zoom=self.OSM_MAX_ZOOM_LEVEL ) logger.info(f"{self._service_log_prefix} OpenStreetMap service instance ready.") @property def name(self) -> str: """Returns the unique name for the OpenStreetMap service.""" return self.SERVICE_IDENTIFIER_NAME @property def attribution(self) -> str: """Returns the required attribution text for OpenStreetMap.""" return self.SERVICE_ATTRIBUTION_TEXT def get_tile_url(self, z: int, x: int, y: int) -> Optional[str]: """ Generates the tile URL for an OpenStreetMap tile. Args: z: The zoom level. x: The tile X coordinate. y: The tile Y coordinate. Returns: The tile URL string, or None if the zoom level is invalid. """ if not self.is_zoom_level_valid(z): # Warning logged by is_zoom_level_valid return None # Simple round-robin for subdomains subdomain = self.SUBDOMAINS[OpenStreetMapService._subdomain_index % len(self.SUBDOMAINS)] OpenStreetMapService._subdomain_index += 1 try: # Format the URL using the class template and selected subdomain tile_url = self.TILE_URL_TEMPLATE.format(s=subdomain, z=z, x=x, y=y) if not self._is_generated_url_structurally_valid(tile_url): # Error logged by _is_generated_url_structurally_valid return None logger.debug(f"{self._service_log_prefix} Generated URL for ({z},{x},{y}): {tile_url}") return tile_url except Exception as e_url_format: # Catch potential errors during .format() logger.error( f"{self._service_log_prefix} Error formatting tile URL for ({z},{x},{y}): {e_url_format}" ) return None # --- Factory Function to Get Map Service Instances --- def get_map_service_instance( service_name_key: str, api_key_value: Optional[str] = None ) -> Optional[BaseMapService]: """ Factory function to create and return an instance of a specific map service. Args: service_name_key: The unique string identifier for the desired service (e.g., 'osm'). api_key_value: The API key, if required by the selected service. Returns: An instance of a BaseMapService subclass, or None if the service_name_key is unknown or if a required API key is missing. """ log_prefix_factory = "[MapServiceFactory]" normalized_service_name = service_name_key.lower().strip() logger.debug(f"{log_prefix_factory} Requesting map service instance for '{normalized_service_name}'.") if normalized_service_name == OpenStreetMapService.SERVICE_IDENTIFIER_NAME: return OpenStreetMapService() # Example for a future service requiring an API key: # elif normalized_service_name == "some_other_service_key": # if api_key_value: # return SomeOtherMapService(api_key=api_key_value) # else: # logger.error(f"{log_prefix_factory} API key is required for '{normalized_service_name}' but was not provided.") # return None else: logger.error(f"{log_prefix_factory} Unknown map service name specified: '{service_name_key}'.") return None