"""Compact result models returned by searches, rankings, and routes."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
import json
from typing import TYPE_CHECKING, Any
from ._serialize import _jsonable
from ._display import friendly_repr
from .geography import Coordinate
from .places import Capital, City, Place, WorldHeritageSite
from .units import Distance
if TYPE_CHECKING:
from .country import Country
[docs]
@dataclass(frozen=True, slots=True)
class CountryReference:
"""A compact, immutable country identifier used in educational results.
The reference contains display and lookup identifiers only. It deliberately
avoids nesting a complete :class:`Country` profile inside flashcards and
discovery results.
"""
name: str
alpha2: str
alpha3: str | None
numeric: str | None
@property
def country_code(self) -> str:
"""Return the two-letter code, the same value other results call ``country_code``."""
return self.alpha2
def __str__(self) -> str:
return self.name
def __repr__(self) -> str:
return friendly_repr(self, f"name={self.name!r}, alpha2={self.alpha2!r}")
[docs]
@dataclass(frozen=True, slots=True)
class CountryRanking:
"""One deterministic position in a country ranking.
``source`` names where the value comes from, such as ``"GeoNames country
population snapshot"``. Different sources can disagree: the statistics
pack's World Bank population (``atlas.rank("population", year=2020)``)
is newer and dated by year. ``year`` is the year of a statistic, and
``None`` for profile metrics such as area.
"""
position: int
country: CountryReference
metric: str
value: int | float
unit: str
source: str | None = None
year: int | None = None
def __str__(self) -> str:
# Up to two decimals without trailing zeros: "86.5", not "86.50".
value = f"{self.value:,}" if float(self.value).is_integer() else f"{self.value:,.2f}".rstrip("0").rstrip(".")
# Percent signs sit next to the number ("12.5%"); other units get a space.
text = f"#{self.position} {self.country.name}: {value}{'' if self.unit.startswith('%') else ' '}{self.unit}"
return text if self.year is None else f"{text} ({self.year})"
def __repr__(self) -> str:
return friendly_repr(self, f"{str(self)!r}")
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return JSON-compatible primitives for this ranking row."""
data: dict[str, Any] = _jsonable(self)
return data
[docs]
def to_json(self, indent: int | None = None) -> str:
"""Serialize this ranking row as JSON."""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
[docs]
@dataclass(frozen=True, slots=True)
class CapitalDistance:
"""A capital ordered by great-circle distance from an origin."""
country: CountryReference
capital: Capital
distance: float
unit: str
def __post_init__(self) -> None:
# Store the distance as a Distance, a float that prints as "1,153 km".
object.__setattr__(self, "distance", Distance(self.distance, self.unit))
def __str__(self) -> str:
return f"{self.capital.name}, {self.country.name} · {self.distance}"
def __repr__(self) -> str:
return friendly_repr(self, f"{str(self)!r}")
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return JSON-compatible primitives for this distance result."""
data: dict[str, Any] = _jsonable(self)
return data
[docs]
def to_json(self, indent: int | None = None) -> str:
"""Serialize this distance result as JSON."""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
[docs]
@dataclass(frozen=True, slots=True)
class CityDistance:
"""A nearby populated place and its great-circle distance from an origin.
The compact country reference disambiguates city names. ``unit`` records
the unit requested from :meth:`pyworldatlas.Atlas.cities_near`.
"""
country: CountryReference
city: City
distance: float
unit: str
def __post_init__(self) -> None:
# Store the distance as a Distance, a float that prints as "1,153 km".
object.__setattr__(self, "distance", Distance(self.distance, self.unit))
def __str__(self) -> str:
return f"{self.city.label} · {self.distance}"
def __repr__(self) -> str:
return friendly_repr(self, f"{str(self)!r}")
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return JSON-compatible primitives for this nearby-city result."""
data: dict[str, Any] = _jsonable(self)
return data
[docs]
def to_json(self, indent: int | None = None) -> str:
"""Serialize this nearby-city result without escaping Unicode text."""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
[docs]
@dataclass(frozen=True, slots=True)
class PlaceDistance:
"""A nearby place and its great-circle distance from an origin.
``unit`` records the unit requested from :meth:`pyworldatlas.Atlas.places_near`.
"""
place: Place
distance: float
unit: str
def __post_init__(self) -> None:
# Store the distance as a Distance, a float that prints as "1,153 km".
object.__setattr__(self, "distance", Distance(self.distance, self.unit))
def __str__(self) -> str:
return f"{self.place.label} · {self.distance}"
def __repr__(self) -> str:
return friendly_repr(self, f"{str(self)!r}")
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return JSON-compatible primitives for this nearby-place result."""
data: dict[str, Any] = _jsonable(self)
return data
[docs]
def to_json(self, indent: int | None = None) -> str:
"""Serialize this nearby-place result without escaping Unicode text."""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
[docs]
@dataclass(frozen=True, slots=True)
class SiteDistance:
"""A World Heritage site and its great-circle distance from an origin."""
site: WorldHeritageSite
distance: float
unit: str
def __post_init__(self) -> None:
# Store the distance as a Distance, a float that prints as "1,153 km".
object.__setattr__(self, "distance", Distance(self.distance, self.unit))
def __str__(self) -> str:
return f"{self.site.label} · {self.distance}"
def __repr__(self) -> str:
return friendly_repr(self, f"{str(self)!r}")
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return JSON-compatible primitives for this nearby-site result."""
data: dict[str, Any] = _jsonable(self)
return data
[docs]
def to_json(self, indent: int | None = None) -> str:
"""Serialize this nearby-site result without escaping Unicode text."""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
[docs]
@dataclass(frozen=True, slots=True)
class Antipode:
"""The point on the exact opposite side of the Earth from an origin.
``nearest`` lists well-known places within 500 km of the antipode first,
then other places, each group nearest first. Most antipodes are at sea, so
the nearest place can be far away.
"""
origin: Coordinate
antipode: Coordinate
nearest: tuple[PlaceDistance, ...]
@property
def summary(self) -> str:
"""Return a plain-language description of the antipode."""
text = (
f"Straight through the centre of the Earth from {self.origin.format(precision=2)} "
f"you would come out at {self.antipode.format(precision=2)}."
)
if self.nearest:
first = self.nearest[0]
text += f" The nearest well-known place is {first.place}, {first.distance} away."
return text
def __str__(self) -> str:
return self.summary
def __repr__(self) -> str:
return friendly_repr(self, f"{self.antipode.format(precision=2)!r}")
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return JSON-compatible primitives for this antipode."""
data: dict[str, Any] = _jsonable(self)
return data
[docs]
def to_json(self, indent: int | None = None) -> str:
"""Serialize this antipode as JSON."""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
[docs]
@dataclass(frozen=True, slots=True)
class SunTimes:
"""Sunrise, sunset, and daylight for one place on one date.
``sunrise``, ``sunset``, and ``solar_noon`` are in UTC. When the place's
time zone is known (``timezone_id``, such as ``"Europe/Paris"``) and the
computer has time zone data, :attr:`local_sunrise` and
:attr:`local_sunset` give the local clock times and the summary uses them.
``sunrise`` and ``sunset`` are ``None`` during the midnight sun (day length
24 hours) and the polar night (day length 0). A UTC time can fall on the
day before or after ``date`` far from Greenwich. ``noon_sun_height`` is the
sun's angle above the horizon at solar noon, in degrees; it is negative
when the sun stays below the horizon.
"""
coordinates: Coordinate
date: date
solar_noon: datetime
sunrise: datetime | None
sunset: datetime | None
day_length_hours: float
noon_sun_height: float
timezone_id: str | None = None
def _local(self, moment: datetime | None) -> datetime | None:
if moment is None or self.timezone_id is None:
return None
try:
from zoneinfo import ZoneInfo
# Windows has no built-in time zone data unless the optional
# tzdata package is installed, so a missing zone means "unknown".
return moment.astimezone(ZoneInfo(self.timezone_id))
except (ImportError, LookupError, ValueError):
return None
@property
def local_sunrise(self) -> datetime | None:
"""Return sunrise on the local clock, or ``None`` when the time zone is unknown."""
return self._local(self.sunrise)
@property
def local_sunset(self) -> datetime | None:
"""Return sunset on the local clock, or ``None`` when the time zone is unknown."""
return self._local(self.sunset)
@property
def daylight(self) -> str:
"""Return the day length as text, such as ``"16 h 38 min"``."""
minutes = round(self.day_length_hours * 60)
return f"{minutes // 60} h {minutes % 60:02d} min"
@property
def summary(self) -> str:
"""Return a plain-language description of the day."""
when = f"{self.date.day} {self.date:%B %Y}"
if self.sunrise is None or self.sunset is None:
if self.day_length_hours >= 24:
return f"On {when} the sun does not set: this is the midnight sun."
return f"On {when} the sun does not rise: this is the polar night."
local = self.local_sunrise is not None and self.local_sunset is not None
def clock(moment: datetime) -> str:
shown = self._local(moment) if local else moment
assert shown is not None
text = f"{shown:%H:%M} {'local time' if local else 'UTC'}"
if shown.date() != self.date:
text += f" on {shown.day} {shown:%B}"
return text
return (
f"On {when} the sun rises at {clock(self.sunrise)} and sets at "
f"{clock(self.sunset)}, giving {self.daylight} of daylight. At noon "
f"it is {self.noon_sun_height:.0f}° above the horizon."
)
def __str__(self) -> str:
return self.summary
def __repr__(self) -> str:
if self.sunrise is None or self.sunset is None:
return friendly_repr(self, f"{self.date.isoformat()}, daylight={self.daylight!r}")
return friendly_repr(
self,
f"{self.date.isoformat()}, sunrise='{self.sunrise:%H:%M} UTC', "
f"sunset='{self.sunset:%H:%M} UTC', daylight={self.daylight!r}",
)
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return JSON-compatible primitives for these sun times."""
data: dict[str, Any] = _jsonable(self)
return data
[docs]
def to_json(self, indent: int | None = None) -> str:
"""Serialize these sun times as JSON."""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
[docs]
@dataclass(frozen=True, slots=True)
class BorderPathResult:
"""A shortest path through the reviewed land-border graph.
``countries`` includes both endpoints in travel order. ``crossings`` is
therefore one fewer than the number of country references. The value is
detached from the database and remains usable after its :class:`Atlas`
closes.
"""
countries: tuple[CountryReference, ...]
crossings: int
def __post_init__(self) -> None:
if not self.countries:
raise ValueError("a border path must contain at least one country")
if self.crossings != len(self.countries) - 1:
raise ValueError("crossings must be one fewer than the country count")
@property
def origin(self) -> CountryReference:
"""Return the first country in the path."""
return self.countries[0]
@property
def destination(self) -> CountryReference:
"""Return the last country in the path."""
return self.countries[-1]
@property
def names(self) -> tuple[str, ...]:
"""Return country display names in path order."""
return tuple(country.name for country in self.countries)
@property
def alpha2_codes(self) -> tuple[str, ...]:
"""Return alpha-2 country codes in path order."""
return tuple(country.alpha2 for country in self.countries)
def __str__(self) -> str:
crossings = "crossing" if self.crossings == 1 else "crossings"
return f"{' → '.join(self.names)} ({self.crossings} border {crossings})"
def __repr__(self) -> str:
return friendly_repr(self, f"{str(self)!r}")
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return JSON-compatible primitives for this path."""
data: dict[str, Any] = _jsonable(self)
return data
[docs]
def to_json(self, indent: int | None = None) -> str:
"""Serialize this path as JSON without escaping Unicode text."""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
[docs]
@dataclass(frozen=True, slots=True)
class CountryMatch:
"""A ranked country-search result."""
country: Country
matched_name: str
score: int
def __str__(self) -> str:
if self.matched_name == self.country.name:
return self.country.name
return f"{self.country.name} (matched {self.matched_name!r})"
def __repr__(self) -> str:
return friendly_repr(self, f"{str(self)!r}")