285 lines
9.8 KiB
Python
285 lines
9.8 KiB
Python
# --- 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 ---
|