"""Pipeline that runs a sequence of :class:`LocationResolver` instances.""" from __future__ import annotations from typing import Protocol, Sequence from .models import ( LocationCandidate, LocationQuery, ResolutionDiagnostic, ResolutionResult, ResolverOutput, ) class LocationResolver(Protocol): """Pluggable location resolution step. Implementations: ``SourceCoordinatesResolver``, ``RegistryResolver``, ``NominatimResolver``, ``InheritFromAnotherEntityResolver`` — see the ``resolvers`` subpackage. New algorithms (peeringdb / IXP / user-confirmed coordinates) plug in by implementing this protocol; the pipeline does not care how candidates are produced. """ name: str def resolve(self, query: LocationQuery) -> ResolverOutput: ... def default_candidate_sort_key( candidate: LocationCandidate, ) -> tuple[int, int, float]: precision_rank = {"precise": 0, "site": 1, "city": 2}.get( candidate.precision, 9 ) source_rank = { "source_coordinates": 0, "stored_compute_center_location": 1, "stored_collector_location": 1, "ror_organization_registry": 2, "inherited": 3, "nominatim_online_geocode": 4, "local_registry": 8, "local_registry_city": 9, }.get(candidate.source, 9) return (source_rank, precision_rank, -float(candidate.confidence or 0)) class LocationPipeline: """Orchestrate a sequence of resolvers. ``collect_candidates`` runs every resolver and returns *all* deduped candidates plus the queries each resolver attempted (useful for user-facing "why didn't this work?" diagnostics). ``resolve_best`` returns the top candidate per :func:`default_candidate_sort_key` (or a custom sort). """ def __init__( self, resolvers: Sequence[LocationResolver], *, sort_key=default_candidate_sort_key, failure_reason: str = ( "Could not resolve to renderable coordinates from any configured resolver." ), ) -> None: self._resolvers = list(resolvers) self._sort_key = sort_key self._failure_reason = failure_reason @property def resolvers(self) -> tuple[LocationResolver, ...]: return tuple(self._resolvers) def collect_candidates( self, query: LocationQuery ) -> tuple[list[LocationCandidate], list[str]]: candidates: list[LocationCandidate] = [] attempted: list[str] = [] seen_keys: set[tuple[str, str, str]] = set() for resolver in self._resolvers: output = resolver.resolve(query) for q in output.attempted_queries: if q and q not in attempted: attempted.append(q) for candidate in output.candidates: key = ( candidate.source, f"{candidate.latitude:.4f}", f"{candidate.longitude:.4f}", ) if key in seen_keys: continue seen_keys.add(key) candidates.append(candidate) candidates.sort(key=self._sort_key) return candidates, attempted def resolve_best(self, query: LocationQuery) -> ResolutionResult: candidates, attempted = self.collect_candidates(query) if candidates: return ResolutionResult( location=candidates[0], diagnostic=None, attempted_queries=tuple(attempted), ) return ResolutionResult( location=None, diagnostic=ResolutionDiagnostic( failure_reason=self._failure_reason, attempted_queries=tuple(attempted), name=query.name, country=query.country, city=query.city, site=str(query.extra.get("site")) if query.extra.get("site") else None, operator=str(query.extra.get("operator")) if query.extra.get("operator") else None, ), attempted_queries=tuple(attempted), )