127 lines
4.1 KiB
Python
127 lines
4.1 KiB
Python
"""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),
|
|
)
|