Source code for ferrosoft.addresses

"""
Address data types and geocoding/routing services.

.. deprecated::
    This module is superseded by
    :mod:`ferrosoft.apps.ferromaps.services.addresses`, which should be used
    for all new code.  The replacement offers several improvements:

    * :class:`~ferrosoft.apps.ferromaps.services.addresses.Address` is a
      mutable ``@dataclass`` instead of an immutable ``NamedTuple``.
    * Coordinates and distances use :class:`~decimal.Decimal` instead of
      ``float``, avoiding floating-point rounding in emissions calculations.
    * Geocoding and routing are split into separate abstract base classes
      (:class:`~ferrosoft.apps.ferromaps.services.addresses.Geocoder` and
      :class:`~ferrosoft.apps.ferromaps.services.addresses.DistanceRouter`)
      that can be composed via
      :class:`~ferrosoft.apps.ferromaps.services.addresses.SwitchingAddressRouter`.
    * Optional Django-cache layer via
      :class:`~ferrosoft.apps.ferromaps.services.addresses.CachingAddressRouter`.
    * Database-backed fuzzy geocoding via
      :class:`~ferrosoft.apps.ferromaps.services.addresses.FuzzyGeocoder`.
    * The active router implementation is resolved dynamically from the
      ``FERROBASE_ADDRESS_ROUTER_CLASS`` setting instead of being hardwired to
      Geoapify.
    * :class:`~ferrosoft.apps.ferromaps.services.addresses.AddressRouterGeoapify`
      falls back to city-level geocoding when a full-address lookup returns no
      results, and raises typed exceptions
      (:class:`~ferrosoft.apps.ferromaps.services.addresses.CoordinatesNotFound`,
      :class:`~ferrosoft.apps.ferromaps.services.addresses.RoutingException`)
      instead of bare :exc:`RuntimeError`.
"""

import string
from abc import ABC, abstractmethod
from typing import NamedTuple, List, Tuple

import requests

_config_key_address_router = "ADDRESS_ROUTER"
_server_key = "SERVER"

_config_key_map_config = "FERROBASE_MAP_CONFIG"


[docs] class Address(NamedTuple): """Structured postal address as an immutable value object. .. deprecated:: Use :class:`ferrosoft.apps.ferromaps.services.addresses.Address` instead. The replacement is a mutable ``@dataclass`` and is accepted directly by the new service's ``geocode`` method. All fields default to an empty string when constructed via :meth:`from_dict`. """ first_name: str last_name: str salutation: str company: str additional_lines: str street: str house_no: str city: str postal_code: str country: str
[docs] @classmethod def from_dict(cls, data: dict) -> "Address": """Construct an :class:`Address` from a dictionary, defaulting missing keys to ``""``. Args: data: Dictionary that may contain any subset of the field names as keys. Returns: A fully populated :class:`Address` with empty strings for absent fields. """ return cls( first_name=data.get("first_name", ""), last_name=data.get("last_name", ""), salutation=data.get("salutation", ""), company=data.get("company", ""), additional_lines=data.get("additional_lines", ""), street=data.get("street", ""), house_no=data.get("house_no", ""), city=data.get("city", ""), postal_code=data.get("postal_code", ""), country=data.get("country", ""), )
[docs] def format_lines(self, join: str = None) -> str | List[str]: """Format the structured address into display lines or a single string. Omits empty fields. The output order is: salutation / first name / last name, company, additional lines (split on ``"\\n"``), street + house number, postal code + city, country. Args: join: If a string, all non-empty lines are joined with this separator and returned as a single string. If ``None``, a list of individual lines is returned. Returns: A joined string when ``join`` is a :class:`str`, otherwise a list of address lines. """ lines = [] if self.first_name or self.last_name: lines.append( " ".join( [ self.salutation or "", self.first_name or "", self.last_name or "", ] ) ) if self.company: lines.append(self.company) if self.additional_lines: lines += self.additional_lines.split("\n") if self.street: lines.append( " ".join( [ self.street, self.house_no or "", ] ) ) if self.postal_code or self.city: lines.append( " ".join( [ self.postal_code or "", self.city or "", ] ) ) if self.country: lines.append(self.country) if isinstance(join, str): return join.join(lines) return lines
[docs] def geocode(self) -> "Location": """Geocode this address using the default :class:`AddressRouterService`. .. deprecated:: Use :meth:`ferrosoft.apps.ferromaps.services.addresses.Address.geocode` on the replacement ``Address`` class instead. Returns: The :class:`Location` resolved by :class:`AddressRouterGeoapify`. """ service = AddressRouterService.default() return service.geocode(self.format_lines(join=", "))
[docs] class Coordinates(NamedTuple): """Geographic coordinates as a (latitude, longitude) pair using ``float``. .. deprecated:: Use :class:`ferrosoft.apps.ferromaps.services.addresses.Coordinates` instead, which stores values as :class:`~decimal.Decimal` to preserve precision in downstream calculations. """ lat: float lon: float
[docs] class Location(NamedTuple): """A resolved geographic location combining coordinates, a display name, and an address. .. deprecated:: Use :class:`ferrosoft.apps.ferromaps.services.addresses.Location` instead. """ coords: Coordinates name: str address: Address
[docs] class RouteLeg(NamedTuple): """A single leg of a route, defined by start and destination coordinates. .. deprecated:: Use :class:`ferrosoft.apps.ferromaps.services.addresses.RouteLeg` instead. """ start: Coordinates destination: Coordinates
[docs] class Route(NamedTuple): """An ordered sequence of route legs. .. deprecated:: Use :class:`ferrosoft.apps.ferromaps.services.addresses.Route` instead. """ legs: List[RouteLeg]
[docs] class Distance(NamedTuple): """A measured distance with a unit string. .. deprecated:: Use :class:`ferrosoft.apps.ferromaps.services.addresses.Distance` instead, which stores the value as :class:`~decimal.Decimal`. """ value: float unit: str
[docs] class AddressRouterService(ABC): """Abstract geocoding and routing service. .. deprecated:: Use :class:`ferrosoft.apps.ferromaps.services.addresses.AddressRouterService` instead, which separates geocoding and routing into distinct abstract base classes and supports pluggable implementations via ``FERROBASE_ADDRESS_ROUTER_CLASS``. The :meth:`default` class method always returns an :class:`AddressRouterGeoapify` instance. """
[docs] @abstractmethod def geocode(self, address: str) -> Location: """Geocode a free-form address string to a :class:`Location`. Args: address: Free-form address string to resolve. Returns: The resolved :class:`Location`. """ raise NotImplementedError
[docs] @abstractmethod def distance(self, route: Route) -> Distance | None: """Calculate the total distance of a route. Args: route: The :class:`Route` to measure. Returns: A :class:`Distance`, or ``None`` if the service could not determine a distance. """ raise NotImplementedError
[docs] @classmethod def default(cls): """Return the default service instance (always :class:`AddressRouterGeoapify`). .. deprecated:: Use :meth:`ferrosoft.apps.ferromaps.services.addresses.AddressRouterService.get_instance` instead, which selects the implementation from Django settings. """ return AddressRouterGeoapify.default()
[docs] class AddressRouterFake(AddressRouterService): """In-memory stub implementation for use in tests. Geocoding always returns a fixed dummy location. Distances are consumed from an iterator supplied at construction time, returning ``None`` once the iterator is exhausted. .. deprecated:: Use :class:`ferrosoft.apps.ferromaps.services.addresses.AddressRouterFake` instead. Args: distances: Sequence of ``(value, unit)`` tuples to return from successive :meth:`distance` calls. """ def __init__(self, distances: List[Tuple[int, str]]): self._distances = iter(distances)
[docs] def geocode(self, address: str) -> Location: return Location( coords=Coordinates(lat=42, lon=-100), name=address, address=Address( first_name="", last_name="", salutation="", company="", additional_lines="", street="Test Street", house_no="12a", city="Test City", postal_code="10010", country="XX", ), )
[docs] def distance(self, route: Route) -> Distance | None: value, unit = next(self._distances, (None, None)) return None if value is None else Distance(value, unit)
[docs] class AddressRouterGeoapify(AddressRouterService): """Geocoding and routing backed by the Geoapify REST API. .. deprecated:: Use :class:`ferrosoft.apps.ferromaps.services.addresses.AddressRouterGeoapify` instead. The replacement validates HTTP response status codes, falls back to city-level geocoding on missing results, uses :class:`~decimal.Decimal` for distances, and raises typed exceptions. Configuration is read from ``settings.FERROBASE_MAP_CONFIG`` via :class:`Config`. """
[docs] class Config(NamedTuple): """Geoapify connection configuration. Args: base_url: Base URL of the Geoapify API (e.g. ``"https://api.geoapify.com"``). api_key: Geoapify API key. """ base_url: string api_key: string
[docs] @classmethod def from_django_settings(cls): """Read configuration from ``settings.FERROBASE_MAP_CONFIG``. Returns: A populated :class:`Config` instance. """ from django.conf import settings config = getattr(settings, _config_key_map_config) return cls( base_url=config.get("base_url", ""), api_key=config.get("api_key", ""), )
def __init__(self, config: Config): self.config = config
[docs] @classmethod def default(cls): """Return a new instance configured from Django settings.""" return cls(cls.Config.from_django_settings())
[docs] def geocode(self, address: str) -> Location: """Resolve a free-form address string via the Geoapify geocoding API. Args: address: Free-form address string to geocode. Returns: A :class:`Location` built from the first Geoapify result. Raises: requests.HTTPError: On a non-2xx HTTP response. RuntimeError: If the API response is malformed or returns no results. """ response = requests.get( f"{self.config.base_url}/geocode/search", params={ "text": address, "format": "json", "apiKey": self.config.api_key, }, ) response.raise_for_status() api_result = response.json() if not isinstance(api_result, dict): raise RuntimeError("Invalid API response") geocode_results = api_result.get("results", None) if not isinstance(geocode_results, list) or len(geocode_results) == 0: raise RuntimeError("No geocode results") first_result = geocode_results[0] return Location( Coordinates( lat=first_result.get("lat", 0), lon=first_result.get("lon", 0), ), first_result.get("formatted", address), Address( first_name="", last_name="", salutation="", company="", additional_lines="", street=first_result.get("street", ""), house_no=first_result.get("housenumber", ""), city=first_result.get("city", ""), postal_code=first_result.get("postcode"), country=first_result.get("country_code", "").upper(), ), )
[docs] def distance(self, route: Route) -> Distance | None: """Calculate the road distance of a route via the Geoapify routing API. Uses ``heavy_truck`` routing mode. Returns ``None`` if the API response contains no feature results. Args: route: The :class:`Route` whose legs to measure. Returns: A :class:`Distance` in metres, or ``None`` if no route was found. Raises: requests.HTTPError: On a non-2xx HTTP response. ValueError: If the API returns a non-metric distance unit. """ response = requests.get( f"{self.config.base_url}/routing", params={ "apiKey": self.config.api_key, "waypoints": self._geoapify_waypoints(route), "mode": "heavy_truck", # TODO this should be determined by emitter "units": "metric", "format": "geojson", }, ) response.raise_for_status() geo_result = response.json() if isinstance(geo_result, dict): features = geo_result.get("features", []) if isinstance(features, list) and len(features) > 0: first_feature = features[0] properties = first_feature.get("properties", {}) unit = properties.get("distance_units", "") if unit != "meters": raise ValueError("Non metric response: {}".format(unit)) return Distance( value=properties.get("distance", 0), unit="m", ) return None
@staticmethod def _geoapify_waypoints(route: Route) -> str: """Encode route legs as a ``|``-separated Geoapify waypoint string. Consecutive duplicate waypoints are collapsed to reduce API costs when consecutive legs share a common endpoint. Args: route: The route whose legs to encode. Returns: A ``|``-separated string of ``"lat,lon"`` waypoints. """ waypoints = [] for i, leg in enumerate(route.legs): start_waypoint = "%f,%f" % (leg.start.lat, leg.start.lon) dest_waypoint = "%f,%f" % (leg.destination.lat, leg.destination.lon) if len(waypoints) > 0: # check if last waypoint is the same as start_waypoint, in which case # do not add start_waypoint, avoiding duplicate subsequent waypoints. # This reduces API costs. last_waypoint = waypoints[-1] if last_waypoint != start_waypoint: waypoints.append(start_waypoint) else: waypoints.append(start_waypoint) waypoints.append(dest_waypoint) return "|".join(waypoints)