# --- START OF FILE map_services.py --- # map_services.py """ Defines the abstract base class for map tile services and concrete implementations for specific providers like OpenStreetMap. This allows the application to interact with different map sources through a common interface. """ # Standard library imports import abc import logging import os from urllib.parse import urlparse # To check URL validity # Third-party imports # import requests # Will be used by the manager, not directly here usually # --- Abstract Base Class --- class BaseMapService(abc.ABC): """ Abstract Base Class for map tile service providers. Defines the common interface for retrieving tile URLs and providing essential metadata like attribution and maximum zoom level. """ # Class attributes for default values (can be overridden by subclasses) DEFAULT_TILE_SIZE = 256 DEFAULT_MAX_ZOOM = 19 # A common value, adjust per service def __init__( self, api_key: str | None = None, tile_size: int = DEFAULT_TILE_SIZE, max_zoom: int = DEFAULT_MAX_ZOOM, ): """ Initializes the BaseMapService. Args: api_key (str | None): API key required by the service, if any. Defaults to None. tile_size (int): The pixel dimension (width/height) of the map tiles. Defaults to 256. max_zoom (int): The maximum supported zoom level for this service. Defaults to 19. """ self._log_prefix = f"[{self.__class__.__name__}]" # Use subclass name in logs logging.debug(f"{self._log_prefix} Initializing map service.") self.api_key = api_key self.tile_size = tile_size self.max_zoom = max_zoom if not (isinstance(tile_size, int) and tile_size > 0): logging.warning( f"{self._log_prefix} Invalid tile_size {tile_size}. Using default {self.DEFAULT_TILE_SIZE}." ) self.tile_size = self.DEFAULT_TILE_SIZE if not ( isinstance(max_zoom, int) and 0 < max_zoom < 30 ): # Realistic zoom range logging.warning( f"{self._log_prefix} Invalid max_zoom {max_zoom}. Using default {self.DEFAULT_MAX_ZOOM}." ) self.max_zoom = self.DEFAULT_MAX_ZOOM @property @abc.abstractmethod def name(self) -> str: """ Returns the unique, short name of the map service (e.g., 'osm', 'google_roadmap'). Used for identifying the service and potentially for cache directory naming. """ pass @property @abc.abstractmethod def attribution(self) -> str: """ Returns the required attribution text for the map service. This should be displayed whenever the map tiles are shown. """ pass @abc.abstractmethod def get_tile_url(self, z: int, x: int, y: int) -> str | None: """ Generates the URL for a specific map tile. Args: z (int): The zoom level. x (int): The tile X coordinate. y (int): The tile Y coordinate. Returns: str | None: The fully formed URL for the tile, or None if the zoom/coords are invalid for this service or URL construction fails. """ pass def validate_zoom(self, z: int) -> bool: """ Checks if the requested zoom level is valid for this service. Args: z (int): The zoom level to check. Returns: bool: True if the zoom level is valid, False otherwise. """ is_valid = 0 <= z <= self.max_zoom if not is_valid: logging.warning( f"{self._log_prefix} Requested zoom level {z} is outside the valid range [0, {self.max_zoom}]." ) return is_valid def _validate_generated_url(self, url: str) -> bool: """Basic check if the generated URL seems valid.""" try: result = urlparse(url) # Check if scheme and netloc (domain) are present is_valid = all([result.scheme, result.netloc]) if not is_valid: logging.error(f"{self._log_prefix} Generated URL '{url}' is malformed.") return is_valid except Exception as e: logging.error( f"{self._log_prefix} Error parsing generated URL '{url}': {e}" ) return False def __repr__(self) -> str: """Provides a useful representation of the service object.""" return ( f"{self.__class__.__name__}(name='{self.name}', max_zoom={self.max_zoom})" ) # --- Concrete Implementations --- class OpenStreetMapService(BaseMapService): """ Concrete implementation for fetching tiles from OpenStreetMap (OSM). Does not require an API key. """ SERVICE_NAME = "osm" # Unique identifier ATTRIBUTION_TEXT = ( "© OpenStreetMap contributors (https://www.openstreetmap.org/copyright)" ) # OSM URL template (can use subdomains a, b, c for load balancing if desired) URL_TEMPLATE = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" # Known max zoom for standard OSM tile layer MAX_ZOOM_OSM = 19 def __init__(self): """Initializes the OpenStreetMap service.""" # Call parent init, explicitly stating no API key needed and providing OSM max zoom super().__init__(api_key=None, max_zoom=self.MAX_ZOOM_OSM) logging.info(f"{self._log_prefix} OpenStreetMap service ready.") @property def name(self) -> str: """Returns the service name.""" return self.SERVICE_NAME @property def attribution(self) -> str: """Returns the OSM attribution text.""" return self.ATTRIBUTION_TEXT def get_tile_url(self, z: int, x: int, y: int) -> str | None: """ Generates the tile URL for OpenStreetMap. Args: z (int): The zoom level. x (int): The tile X coordinate. y (int): The tile Y coordinate. Returns: str | None: The tile URL or None if zoom is invalid. """ # Validate zoom level using parent method if not self.validate_zoom(z): logging.warning( f"{self._log_prefix} Cannot generate URL for invalid zoom {z}." ) return None # Format the URL using the class template try: url = self.URL_TEMPLATE.format(z=z, x=x, y=y) # Perform basic validation on the generated URL if not self._validate_generated_url(url): return None # Error logged in validation method logging.debug(f"{self._log_prefix} Generated URL for ({z},{x},{y}): {url}") return url except Exception as e: logging.error( f"{self._log_prefix} Error formatting URL for ({z},{x},{y}): {e}" ) return None # --- Placeholder for future services --- # class GoogleMapsRoadmapService(BaseMapService): # SERVICE_NAME = "google_roadmap" # ATTRIBUTION_TEXT = "© Google Maps" # # Example URL - Requires API Key and potentially session token, style etc. # # Consult Google Maps Tile API documentation for the correct format. # URL_TEMPLATE = "https://tile.googleapis.com/v1/2dtiles/{z}/{x}/{y}?key={api_key}&style=roadmap" # Example only! # MAX_ZOOM_GOOGLE = 22 # Example value # def __init__(self, api_key: str): # if not api_key: # raise ValueError("API key is required for Google Maps service.") # super().__init__(api_key=api_key, max_zoom=self.MAX_ZOOM_GOOGLE) # logging.info(f"{self._log_prefix} Google Maps Roadmap service ready.") # @property # def name(self) -> str: # return self.SERVICE_NAME # @property # def attribution(self) -> str: # return self.ATTRIBUTION_TEXT # def get_tile_url(self, z: int, x: int, y: int) -> str | None: # if not self.validate_zoom(z): # return None # try: # # Ensure API key is included # url = self.URL_TEMPLATE.format(z=z, x=x, y=y, api_key=self.api_key) # if not self._validate_generated_url(url): # return None # logging.debug(f"{self._log_prefix} Generated URL for ({z},{x},{y}): {url}") # return url # except Exception as e: # logging.error(f"{self._log_prefix} Error formatting URL for ({z},{x},{y}): {e}") # return None # --- Factory Function (Optional but helpful) --- def get_map_service( service_name: str, api_key: str | None = None ) -> BaseMapService | None: """ Factory function to get an instance of a specific map service. Args: service_name (str): The unique name of the service (e.g., 'osm', 'google_roadmap'). api_key (str | None): API key, if required by the service. Returns: BaseMapService | None: An instance of the requested service, or None if not found. """ log_prefix = "[MapService Factory]" service_name_lower = service_name.lower() logging.debug(f"{log_prefix} Requesting map service '{service_name_lower}'.") if service_name_lower == OpenStreetMapService.SERVICE_NAME: return OpenStreetMapService() # elif service_name_lower == GoogleMapsRoadmapService.SERVICE_NAME: # if api_key: # return GoogleMapsRoadmapService(api_key=api_key) # else: # logging.error(f"{log_prefix} API key required for service '{service_name}'.") # return None # --- Add other services here --- # elif service_name_lower == "bing_aerial": # # ... implementation ... # pass else: logging.error(f"{log_prefix} Unknown map service name: '{service_name}'.") return None # --- END OF FILE map_services.py ---