"""Immutable public data models returned by :class:`pyworldatlas.Atlas`."""
from __future__ import annotations
from dataclasses import dataclass, fields, is_dataclass
import json
from math import asin, atan2, cos, degrees, isclose, radians, sin, sqrt
from typing import Any
[docs]
@dataclass(frozen=True, slots=True)
class DatasetInfo:
"""Independent library, schema, and dataset version metadata."""
library_version: str
schema_version: int
dataset_version: str
country_count: int
built_at: str
[docs]
@dataclass(frozen=True, slots=True)
class CountryCodes:
"""Standard identifiers for a country or area."""
alpha2: str
alpha3: str | None
numeric: str | None
wikidata: str | None = None
geonames: int | None = None
[docs]
@dataclass(frozen=True, slots=True)
class LocalizedName:
"""A sourced country or area name in a selected local language.
``kind`` is ``"national_official"`` for reviewed UNGEGN short/formal
names and ``"locale_display"`` for Unicode CLDR territory display names.
``official_name`` and the romanized fields remain ``None`` unless their
source explicitly supplies those values. ``language_status`` records why
the language was selected, such as ``"official"`` or
``"de_facto_official"``.
"""
text: str
language_code: str | None
kind: str
preferred: bool
language_name: str | None = None
script_code: str | None = None
official_name: str | None = None
romanized_short_name: str | None = None
romanized_official_name: str | None = None
is_official_language: bool = False
source: SourceReference | None = None
source_locator: str | None = None
language_status: str | None = None
@property
def short_name(self) -> str:
"""Return the short local-language form."""
return self.text
@property
def formal_name(self) -> str | None:
"""Return the formal local-language form supplied by the source."""
return self.official_name
@property
def is_national_official(self) -> bool:
"""Whether UNGEGN supplies a reviewed national official name."""
return self.kind == "national_official"
[docs]
@dataclass(frozen=True, slots=True)
class Coordinate:
"""A signed WGS84 coordinate in decimal degrees.
Use :meth:`format` for a compact classroom-friendly label, :meth:`dms` for
degrees/minutes/seconds, and the calculation methods for great-circle
distance and direction. Latitude always comes before longitude.
"""
latitude: float
longitude: float
def __post_init__(self) -> None:
if not -90 <= self.latitude <= 90:
raise ValueError("latitude must be between -90 and 90")
if not -180 <= self.longitude <= 180:
raise ValueError("longitude must be between -180 and 180")
[docs]
def as_tuple(self) -> tuple[float, float]:
"""Return ``(latitude, longitude)``."""
return (self.latitude, self.longitude)
@property
def hemispheres(self) -> tuple[str, str]:
"""Return the latitude and longitude hemispheres, such as ``("N", "E")``."""
return (
"N" if self.latitude >= 0 else "S",
"E" if self.longitude >= 0 else "W",
)
[docs]
def dms(self, *, seconds_precision: int = 1) -> str:
"""Return degrees, minutes, and seconds with hemisphere letters.
``seconds_precision`` controls decimal places on the seconds value and
must be an integer from 0 through 6.
"""
if isinstance(seconds_precision, bool) or not isinstance(seconds_precision, int):
raise TypeError("seconds_precision must be an integer")
if not 0 <= seconds_precision <= 6:
raise ValueError("seconds_precision must be between 0 and 6")
def component(value: float, hemisphere: str) -> str:
total_seconds = round(abs(value) * 3600, seconds_precision)
degrees_value = int(total_seconds // 3600)
remaining = total_seconds - degrees_value * 3600
minutes_value = int(remaining // 60)
seconds_value = remaining - minutes_value * 60
seconds = f"{seconds_value:.{seconds_precision}f}"
return f"{degrees_value}° {minutes_value}′ {seconds}″ {hemisphere}"
latitude_hemisphere, longitude_hemisphere = self.hemispheres
return (
f"{component(self.latitude, latitude_hemisphere)}, "
f"{component(self.longitude, longitude_hemisphere)}"
)
[docs]
def distance_to(self, other: Coordinate, *, unit: str = "km") -> float:
"""Return great-circle distance using the WGS84 mean Earth radius.
``unit`` accepts kilometres (``"km"``), statute miles (``"mi"``), or
nautical miles (``"nmi"``). This is a surface measurement, not a road
or flight route.
"""
lat1, lon1, lat2, lon2 = map(radians, (self.latitude, self.longitude, other.latitude, other.longitude))
delta_lat, delta_lon = lat2 - lat1, lon2 - lon1
haversine = sin(delta_lat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(delta_lon / 2) ** 2
kilometers = 2 * 6371.0088 * asin(min(1.0, sqrt(haversine)))
factors = {"km": 1.0, "mi": 0.621371192237334, "nmi": 0.539956803455724}
if unit not in factors:
raise ValueError("unit must be 'km', 'mi', or 'nmi'")
return kilometers * factors[unit]
def _relative_position(self, other: Coordinate) -> tuple[bool, bool]:
"""Return whether two coordinates are coincident or antipodal."""
def unit_vector(coordinate: Coordinate) -> tuple[float, float, float]:
latitude = radians(coordinate.latitude)
longitude = radians(coordinate.longitude)
return (
cos(latitude) * cos(longitude),
cos(latitude) * sin(longitude),
sin(latitude),
)
first = unit_vector(self)
second = unit_vector(other)
coincident = all(
isclose(left, right, rel_tol=0.0, abs_tol=1e-12)
for left, right in zip(first, second)
)
antipodal = all(
isclose(left, -right, rel_tol=0.0, abs_tol=1e-12)
for left, right in zip(first, second)
)
return coincident, antipodal
[docs]
def bearing_to(self, other: Coordinate) -> float:
"""Return the initial bearing to ``other`` in degrees from true north.
The bearing is in the half-open range ``[0, 360)`` and may change along
a great-circle path. Coincident and antipodal points have no unique
initial bearing and raise :class:`ValueError`.
"""
coincident, antipodal = self._relative_position(other)
if coincident:
raise ValueError("initial bearing is undefined for coincident coordinates")
if antipodal:
raise ValueError("initial bearing is undefined for antipodal coordinates")
lat1, lat2 = radians(self.latitude), radians(other.latitude)
delta_lon = radians(other.longitude - self.longitude)
y = sin(delta_lon) * cos(lat2)
x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(delta_lon)
return (degrees(atan2(y, x)) + 360.0) % 360.0
[docs]
def compass_direction_to(self, other: Coordinate, *, points: int = 16) -> str:
"""Return a compass direction such as ``"SE"`` for the initial bearing.
``points`` selects a 4-, 8-, or 16-point compass rose. The result
describes the initial great-circle direction, which can change along a
long route.
"""
labels = {
4: ("N", "E", "S", "W"),
8: ("N", "NE", "E", "SE", "S", "SW", "W", "NW"),
16: (
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW",
),
}
if points not in labels:
raise ValueError("points must be 4, 8, or 16")
step = 360 / points
index = int((self.bearing_to(other) + step / 2) // step) % points
return labels[points][index]
[docs]
def midpoint_to(self, other: Coordinate) -> Coordinate:
"""Return the spherical midpoint on the great-circle path to ``other``."""
_, antipodal = self._relative_position(other)
if antipodal:
raise ValueError("great-circle midpoint is undefined for antipodal coordinates")
lat1, lon1, lat2 = map(radians, (self.latitude, self.longitude, other.latitude))
delta_lon = radians(other.longitude - self.longitude)
bx, by = cos(lat2) * cos(delta_lon), cos(lat2) * sin(delta_lon)
latitude = atan2(sin(lat1) + sin(lat2), sqrt((cos(lat1) + bx) ** 2 + by**2))
longitude = lon1 + atan2(by, cos(lat1) + bx)
normalized_longitude = (degrees(longitude) + 540.0) % 360.0 - 180.0
return Coordinate(degrees(latitude), normalized_longitude)
[docs]
@dataclass(frozen=True, slots=True)
class Area:
"""Country area measurements in square kilometres.
``water_percent`` is derived from ``water_km2 / total_km2`` when both
source values are available. Missing components remain ``None``.
"""
total_km2: float | None = None
land_km2: float | None = None
water_km2: float | None = None
water_percent: float | None = None
disputed_km2: float | None = None
[docs]
@dataclass(frozen=True, slots=True)
class ElevationPoint:
"""A named highest or lowest point and its elevation above sea level.
A negative value is below sea level. ``is_approximate`` preserves an
explicit approximation in the source; the numeric value is not made more
precise by the package. ``source_label`` retains the exact compact label
from which the structured fields were parsed.
"""
name: str
elevation_m: float
is_approximate: bool = False
source_label: str | None = None
[docs]
@dataclass(frozen=True, slots=True)
class River:
"""A source-listed major river associated with a country profile.
``length_km`` is the full river length reported by the source, including
for rivers shared across countries. It is not the length inside this one
profile. ``source_label`` preserves shared/source/mouth context.
"""
name: str
length_km: float | None
source_label: str
[docs]
@dataclass(frozen=True, slots=True)
class Lake:
"""A source-listed major lake associated with a country profile.
``area_km2`` is the full lake area reported by the source, including for a
shared lake. ``water_type`` is ``"freshwater"`` or ``"saltwater"`` when
the source supplies that classification.
"""
name: str
area_km2: float | None
water_type: str | None
source_label: str
[docs]
@dataclass(frozen=True, slots=True)
class ClimateZone:
"""A Köppen-Geiger class detected within a country or area profile.
``share_percent`` is a latitude-area-weighted share derived from the
source's 0.1-degree 1991-2020 raster and pinned map-unit polygons. Classes
below the documented extraction threshold are omitted.
"""
code: str
name: str
group: str
share_percent: float
[docs]
@dataclass(frozen=True, slots=True)
class ClimateProfile:
"""A plain-language climate summary and reviewed Köppen-Geiger classes."""
summary: str | None = None
koppen_geiger_zones: tuple[ClimateZone, ...] = ()
reference_period: str | None = None
resolution_degrees: float | None = None
minimum_share_percent: float | None = None
summary_source: SourceReference | None = None
classification_source: SourceReference | None = None
@property
def dominant_zone(self) -> ClimateZone | None:
"""Return the largest represented Köppen-Geiger class, if available."""
return self.koppen_geiger_zones[0] if self.koppen_geiger_zones else None
@property
def zone_codes(self) -> tuple[str, ...]:
"""Return represented Köppen-Geiger codes in descending area share."""
return tuple(zone.code for zone in self.koppen_geiger_zones)
[docs]
@dataclass(frozen=True, slots=True)
class PhysicalGeography:
"""Structured physical facts extracted for a country or area profile.
Rivers and lakes are source-listed major features, not exhaustive
inventories. A missing tuple means that the source did not list a feature;
it does not assert that the feature does not exist.
"""
coastline_km: float | None = None
mean_elevation_m: float | None = None
highest_point: ElevationPoint | None = None
lowest_point: ElevationPoint | None = None
rivers: tuple[River, ...] = ()
lakes: tuple[Lake, ...] = ()
climate: ClimateProfile = ClimateProfile()
source: SourceReference | None = None
source_locator: str | None = None
@property
def is_coastal(self) -> bool | None:
"""Return whether the sourced coastline is positive, or ``None``."""
return self.coastline_km > 0 if self.coastline_km is not None else None
@property
def is_landlocked(self) -> bool | None:
"""Return whether the source reports zero coastline, or ``None``."""
coastal = self.is_coastal
return None if coastal is None else not coastal
[docs]
@dataclass(frozen=True, slots=True)
class Geography:
"""Core geographic classification and physical measurements."""
continent: str | None
region: str | None
subregion: str | None
area: Area = Area()
centroid: Coordinate | None = None
landlocked: bool | None = None
physical: PhysicalGeography = PhysicalGeography()
[docs]
@dataclass(frozen=True, slots=True)
class Capital:
"""A national capital sourced from GeoNames."""
name: str
country_code: str
coordinates: Coordinate
role: str = "official"
primary: bool = True
largest_city: bool | None = None
population: int | None = None
elevation_m: float | None = None
timezone_id: str | None = None
alternate_names: tuple[str, ...] = ()
geonames_id: int | None = None
def __repr__(self) -> str:
return f"Capital(name={self.name!r}, country_code={self.country_code!r})"
[docs]
@dataclass(frozen=True, slots=True)
class City:
"""An immutable bundled populated place.
Every city has a display name, country code, and WGS84 coordinates.
Population, elevation, timezone, capital roles, alternate names, and the
GeoNames identifier are optional snapshot fields.
"""
name: str
country_code: str
coordinates: Coordinate
population: int | None = None
elevation_m: float | None = None
timezone_id: str | None = None
capital_roles: tuple[str, ...] = ()
alternate_names: tuple[str, ...] = ()
geonames_id: int | None = None
@property
def label(self) -> str:
"""Return a compact place label such as ``"Tokyo (JP)"``."""
return f"{self.name} ({self.country_code})"
def __str__(self) -> str:
return self.label
[docs]
@dataclass(frozen=True, slots=True)
class SourceReference:
"""A source used for fields in a country profile."""
id: str
name: str
homepage: str
retrieved_at: str
version: str | None = None
license_name: str | None = None
license_url: str | None = None
notes: str | None = None
[docs]
@dataclass(frozen=True, slots=True)
class Currency:
"""A country's currency as identified by the captured source snapshot."""
code: str
name: str | None = None
symbol: str | None = None
minor_unit_digits: int | None = None
source: SourceReference | None = None
[docs]
@dataclass(frozen=True, slots=True)
class Language:
"""A language associated with a country in the captured sources."""
code: str
primary_code: str | None = None
name: str | None = None
script_code: str | None = None
source: SourceReference | None = None
[docs]
@dataclass(frozen=True, slots=True)
class Timezone:
"""A country timezone and its captured January, July, and raw offsets."""
id: str
january_utc_offset_hours: float
july_utc_offset_hours: float
raw_utc_offset_hours: float
source: SourceReference | None = None
[docs]
@dataclass(frozen=True, slots=True)
class PostalCodeFormat:
"""A source-provided postal-code display format and validation expression."""
format: str
regex: str | None = None
source: SourceReference | None = None
[docs]
@dataclass(frozen=True, slots=True)
class NationalAnthem:
"""A national-anthem title without lyrics, audio, or contributor data."""
title: str
english_title: str | None
source: SourceReference
source_locator: str
[docs]
@dataclass(frozen=True, slots=True)
class NationalMotto:
"""A reviewed source-listed national motto and selected language label.
The record does not infer a legal status. ``english_text`` is the captured
English label when the source supplies one; it may preserve the original
phrase instead of translating it.
"""
text: str
language_code: str
english_text: str | None
source: SourceReference
source_locator: str
[docs]
@dataclass(frozen=True, slots=True)
class Demonym:
"""Source-preserved noun and adjective forms for a country or area."""
noun: str | None
adjective: str | None
language_code: str
source: SourceReference
source_locator: str
def _jsonable(value: Any) -> Any:
if is_dataclass(value):
return {field.name: _jsonable(getattr(value, field.name)) for field in fields(value)}
if isinstance(value, tuple):
return [_jsonable(item) for item in value]
return value
[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
[docs]
@dataclass(frozen=True, slots=True)
class CountryRanking:
"""One deterministic position in a country ranking."""
position: int
country: CountryReference
metric: str
value: int | float
unit: str
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return JSON-compatible primitives for this ranking row."""
return _jsonable(self)
[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
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return JSON-compatible primitives for this distance result."""
return _jsonable(self)
[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.nearest_cities`.
"""
country: CountryReference
city: City
distance: float
unit: str
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return JSON-compatible primitives for this nearby-city result."""
return _jsonable(self)
[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 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)
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return JSON-compatible primitives for this path."""
return _jsonable(self)
[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 Flashcard:
"""A deterministic geography study prompt and answer.
Flashcards contain no scoring, session state, or hidden random state. The
``topic`` value identifies the documented generator used by
:meth:`pyworldatlas.Atlas.flashcards`.
"""
topic: str
prompt: str
answer: str
country: CountryReference
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return JSON-compatible primitives for this flashcard."""
return _jsonable(self)
[docs]
def to_json(self, indent: int | None = None) -> str:
"""Serialize this flashcard as JSON without escaping Unicode text."""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
[docs]
@dataclass(frozen=True, slots=True)
class QuizQuestion:
"""A deterministic multiple-choice geography question.
Choices are stored in display order and ``answer`` is always one of them.
Use :attr:`answer_number` for a one-based classroom answer key or
:meth:`is_correct` to check either a displayed answer or choice number.
"""
topic: str
prompt: str
choices: tuple[str, ...]
answer: str
country: CountryReference
def __post_init__(self) -> None:
if len(self.choices) < 2:
raise ValueError("a quiz question must contain at least two choices")
normalized = tuple(choice.casefold() for choice in self.choices)
if len(set(normalized)) != len(normalized):
raise ValueError("quiz choices must be unique")
if self.answer not in self.choices:
raise ValueError("answer must be present in choices")
@property
def answer_number(self) -> int:
"""Return the correct choice number using one-based classroom numbering."""
return self.choices.index(self.answer) + 1
[docs]
def is_correct(self, choice: str | int) -> bool:
"""Check an answer string or a one-based choice number.
Text matching ignores surrounding whitespace and letter case. Invalid
choice numbers return ``False``; unsupported input types raise
:class:`TypeError`.
"""
if isinstance(choice, bool):
raise TypeError("choice must be an answer string or one-based integer")
if isinstance(choice, int):
return 1 <= choice <= len(self.choices) and self.choices[choice - 1] == self.answer
if isinstance(choice, str):
return choice.strip().casefold() == self.answer.strip().casefold()
raise TypeError("choice must be an answer string or one-based integer")
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return JSON-compatible primitives for this question."""
return _jsonable(self)
[docs]
def to_json(self, indent: int | None = None) -> str:
"""Serialize this question without escaping Unicode text."""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
[docs]
@dataclass(frozen=True, slots=True)
class CountryDiscoveryCard:
"""A compact, serializable teaching view of one materialized country.
Every value is copied from, or calculated directly from, an existing
:class:`Country`. Creating a card never queries the database or network.
"""
country: CountryReference
flag_emoji: str | None
official_name: str | None
formal_name: str | None
capital: str | None
capital_coordinates: Coordinate | None
continent: str | None
region: str | None
subregion: str | None
population: int | None
area_km2: float | None
population_density: float | None
currency: Currency | None
language_codes: tuple[str, ...]
calling_codes: tuple[str, ...]
top_level_domain: str | None
observed_timezones: tuple[str, ...]
local_names: tuple[LocalizedName, ...]
major_city_count: int
source_ids: tuple[str, ...]
anthem_title: str | None = None
motto_text: str | None = None
demonym: str | None = None
timezone_ids: tuple[str, ...] = ()
coastline_km: float | None = None
highest_point: ElevationPoint | None = None
climate_zone_codes: tuple[str, ...] = ()
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return JSON-compatible primitives for this discovery card."""
return _jsonable(self)
[docs]
def to_json(self, indent: int | None = None) -> str:
"""Serialize this discovery card as JSON without escaping Unicode text."""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
[docs]
@dataclass(frozen=True, slots=True)
class Country:
"""A sourced, immutable country profile from the offline atlas.
``official_name`` is the canonical English identity from UN M49.
``formal_name`` is the sourced English long or formal form when the
country or area is covered by the formal-name source layer.
"""
name: str
official_name: str | None
names: tuple[LocalizedName, ...]
aliases: tuple[str, ...]
codes: CountryCodes
flag: str
geography: Geography
capitals: tuple[Capital, ...]
major_cities: tuple[City, ...]
sources: tuple[SourceReference, ...]
local_names: tuple[LocalizedName, ...] = ()
population: int | None = None
currency: Currency | None = None
languages: tuple[Language, ...] = ()
calling_codes: tuple[str, ...] = ()
top_level_domain: str | None = None
observed_timezones: tuple[str, ...] = ()
formal_name: str | None = None
anthems: tuple[NationalAnthem, ...] = ()
mottos: tuple[NationalMotto, ...] = ()
demonyms: tuple[Demonym, ...] = ()
timezones: tuple[Timezone, ...] = ()
postal_code: PostalCodeFormat | None = None
@property
def alpha2(self) -> str:
"""Return the ISO alpha-2 code."""
return self.codes.alpha2
@property
def alpha3(self) -> str | None:
"""Return the ISO alpha-3 code."""
return self.codes.alpha3
@property
def continent(self) -> str | None:
"""Return the broad continent classification."""
return self.geography.continent
@property
def region(self) -> str | None:
"""Return the UN region classification."""
return self.geography.region
@property
def subregion(self) -> str | None:
"""Return the UN subregion classification."""
return self.geography.subregion
@property
def area_km2(self) -> float | None:
"""Return sourced total area in square kilometres, when available."""
return self.geography.area.total_km2
@property
def land_area_km2(self) -> float | None:
"""Return sourced land area in square kilometres, when available."""
return self.geography.area.land_km2
@property
def water_area_km2(self) -> float | None:
"""Return sourced inland-water area in square kilometres, when available."""
return self.geography.area.water_km2
@property
def water_percent(self) -> float | None:
"""Return the derived percentage of total area recorded as water."""
return self.geography.area.water_percent
@property
def physical(self) -> PhysicalGeography:
"""Return structured physical-geography facts for this profile."""
return self.geography.physical
@property
def coastline_km(self) -> float | None:
"""Return sourced coastline length in kilometres, when available."""
return self.physical.coastline_km
@property
def mean_elevation_m(self) -> float | None:
"""Return sourced mean elevation above sea level, when available."""
return self.physical.mean_elevation_m
@property
def highest_point(self) -> ElevationPoint | None:
"""Return the sourced named highest point, when available."""
return self.physical.highest_point
@property
def lowest_point(self) -> ElevationPoint | None:
"""Return the sourced named lowest point, when available."""
return self.physical.lowest_point
@property
def rivers(self) -> tuple[River, ...]:
"""Return source-listed major rivers; this is not an exhaustive inventory."""
return self.physical.rivers
@property
def lakes(self) -> tuple[Lake, ...]:
"""Return source-listed major lakes; this is not an exhaustive inventory."""
return self.physical.lakes
@property
def climate(self) -> ClimateProfile:
"""Return the climate summary and represented Köppen-Geiger classes."""
return self.physical.climate
@property
def is_coastal(self) -> bool | None:
"""Return whether the source reports a positive coastline, or ``None``."""
return self.physical.is_coastal
@property
def is_landlocked(self) -> bool | None:
"""Return whether the source reports zero coastline, or ``None``."""
return self.physical.is_landlocked
@property
def flag_emoji(self) -> str | None:
"""Return the regional-indicator flag derived from the alpha-2 code.
Emoji appearance depends on the operating system, font, and application.
``None`` is returned if a profile ever lacks a valid two-letter code.
The existing ``flag`` attribute is the same value.
"""
return self.flag if len(self.alpha2) == 2 and self.alpha2.isalpha() else None
@property
def has_distinct_formal_name(self) -> bool:
"""Return whether the sourced English formal form differs from ``name``.
``False`` also covers records outside the current formal-name source
scope. Inspect ``formal_name`` directly when that distinction matters.
"""
return bool(self.formal_name and self.formal_name.casefold() != self.name.casefold())
@property
def population_density(self) -> float | None:
"""Return snapshot population per square kilometre when calculable.
This is a transparent ratio of ``population`` to ``area_km2``,
not a separately sourced official statistic. ``None`` represents a
missing population, missing area, or non-positive area.
"""
area = self.area_km2
if self.population is None or area is None or area <= 0:
return None
return self.population / area
@property
def language_codes(self) -> tuple[str, ...]:
"""Return the captured country language codes as an immutable tuple."""
return tuple(language.code for language in self.languages)
@property
def currency_code(self) -> str | None:
"""Return the captured currency code, or ``None`` when unavailable."""
return self.currency.code if self.currency else None
@property
def anthem(self) -> NationalAnthem | None:
"""Return the first sourced anthem-title record, when available."""
return self.anthems[0] if self.anthems else None
@property
def motto(self) -> NationalMotto | None:
"""Return the first reviewed source-listed motto, when available."""
return self.mottos[0] if self.mottos else None
@property
def demonym(self) -> Demonym | None:
"""Return the English demonym record, when available."""
return next(
(record for record in self.demonyms if record.language_code == "en"),
self.demonyms[0] if self.demonyms else None,
)
@property
def timezone_ids(self) -> tuple[str, ...]:
"""Return all captured country timezone identifiers."""
return tuple(timezone.id for timezone in self.timezones)
@property
def major_city_count(self) -> int:
"""Return the number of populated-place records bundled for this profile."""
return len(self.major_cities)
@property
def capital(self) -> Capital | None:
"""Return the primary capital, or ``None`` when unavailable."""
return next((capital for capital in self.capitals if capital.primary), None)
@property
def capital_coordinates(self) -> Coordinate | None:
"""Return the primary capital's coordinates, when a capital is available."""
return self.capital.coordinates if self.capital else None
@property
def local_name_languages(self) -> tuple[str, ...]:
"""Return language codes represented by sourced local identity records."""
return tuple(name.language_code for name in self.local_names if name.language_code)
[docs]
def local_name(self, language_code: str) -> LocalizedName | None:
"""Return the complete sourced local record for ``language_code``.
Matching is case-insensitive. ``None`` means that this dataset does not
contain a selected record for that language; it does not mean the
language or local name does not exist. No translation, English
fallback, or romanization is invented.
"""
normalized = language_code.casefold()
return next((name for name in self.local_names if name.language_code == normalized), None)
[docs]
def name_in(self, language_code: str) -> str | None:
"""Return the sourced short local name, without fallback."""
match = self.local_name(language_code)
return match.short_name if match else None
[docs]
def official_name_in(self, language_code: str) -> str | None:
"""Return the reviewed formal local name, without fallback.
This is populated only for ``national_official`` local records. It is
separate from the English ``formal_name`` profile field.
"""
match = self.local_name(language_code)
return match.formal_name if match else None
[docs]
def romanized_name_in(self, language_code: str) -> str | None:
"""Return a source-provided romanized short name, without generating one."""
match = self.local_name(language_code)
return match.romanized_short_name if match else None
[docs]
def romanized_official_name_in(self, language_code: str) -> str | None:
"""Return a source-provided romanized formal name, without generating one."""
match = self.local_name(language_code)
return match.romanized_official_name if match else None
[docs]
def reference(self) -> CountryReference:
"""Return a compact immutable reference suitable for results and prompts."""
return CountryReference(self.name, self.alpha2, self.alpha3, self.codes.numeric)
[docs]
def discovery_card(self) -> CountryDiscoveryCard:
"""Return a compact educational view built entirely from this profile.
The card is safe to retain after the originating :class:`Atlas` closes
and can be serialized with :meth:`CountryDiscoveryCard.to_dict` or
:meth:`CountryDiscoveryCard.to_json`.
"""
capital = self.capital
return CountryDiscoveryCard(
country=self.reference(),
flag_emoji=self.flag_emoji,
official_name=self.official_name,
formal_name=self.formal_name,
capital=capital.name if capital else None,
capital_coordinates=capital.coordinates if capital else None,
continent=self.continent,
region=self.region,
subregion=self.subregion,
population=self.population,
area_km2=self.area_km2,
population_density=self.population_density,
currency=self.currency,
language_codes=self.language_codes,
calling_codes=self.calling_codes,
top_level_domain=self.top_level_domain,
observed_timezones=self.observed_timezones,
local_names=self.local_names,
major_city_count=self.major_city_count,
source_ids=tuple(source.id for source in self.sources),
anthem_title=self.anthem.title if self.anthem else None,
motto_text=self.motto.text if self.motto else None,
demonym=self.demonym.noun if self.demonym else None,
timezone_ids=self.timezone_ids,
coastline_km=self.coastline_km,
highest_point=self.highest_point,
climate_zone_codes=self.climate.zone_codes,
)
[docs]
def summary(self, *, local_language: str | None = None) -> str:
"""Return a readable, multiline introduction to this country profile.
The summary favors useful classroom facts and omits unavailable values.
Pass a language code to request a particular bundled local name; with
no code, the selected non-English local identity is used when present.
This is a presentation helper rather than a serialization format—use
:meth:`to_dict` when field names and machine-readable values matter.
"""
local_name = (
self.local_name(local_language)
if local_language is not None
else next(
(
name
for name in self.local_names
if name.short_name.casefold() != self.name.casefold()
),
None,
)
)
heading = f"{self.flag_emoji or ''} {self.name}".strip()
if local_name is not None and local_name.short_name.casefold() != self.name.casefold():
heading += f" · {local_name.short_name}"
lines = [heading]
if self.has_distinct_formal_name:
lines.append(f"Formal name: {self.formal_name}")
if self.capital is not None:
lines.append(f"Capital: {self.capital.name}")
location = tuple(
value for value in (self.continent, self.subregion) if value
)
if location:
lines.append(f"Location: {' · '.join(dict.fromkeys(location))}")
if self.population is not None:
lines.append(f"Population snapshot: {self.population:,}")
if self.currency is not None:
currency = self.currency.name or self.currency.code
details = [self.currency.code]
if self.currency.symbol and self.currency.symbol != self.currency.code:
details.append(self.currency.symbol)
lines.append(f"Currency: {currency} ({', '.join(details)})")
if self.languages:
languages = ", ".join(
f"{language.name} ({language.code})"
if language.name and language.name != language.code
else language.code
for language in self.languages
)
lines.append(f"Languages: {languages}")
if self.anthem is not None:
anthem = self.anthem.title
if self.anthem.english_title and self.anthem.english_title != anthem:
anthem += f" · {self.anthem.english_title}"
lines.append(f"Anthem title: {anthem}")
if self.motto is not None:
motto = self.motto.text
if self.motto.english_text and self.motto.english_text != motto:
motto += f" · {self.motto.english_text}"
lines.append(f"Motto: {motto}")
if self.highest_point is not None:
lines.append(
f"Highest point: {self.highest_point.name} "
f"({self.highest_point.elevation_m:,.0f} m)"
)
if self.climate.dominant_zone is not None:
zone = self.climate.dominant_zone
lines.append(f"Dominant climate class: {zone.code} · {zone.name}")
if self.rivers:
lines.append(
"Source-listed rivers: "
+ ", ".join(river.name for river in self.rivers[:3])
)
if self.lakes:
lines.append(
"Source-listed lakes: "
+ ", ".join(lake.name for lake in self.lakes[:3])
)
return "\n".join(lines)
[docs]
def to_dict(self, include_history: bool = False) -> dict[str, Any]:
"""Serialize this profile to JSON-compatible primitives.
``include_history`` is reserved for compatibility and currently has no
effect because the bundled dataset has no historical series.
"""
del include_history
return _jsonable(self)
[docs]
def to_json(self, indent: int | None = None, include_history: bool = False) -> str:
"""Serialize this profile as JSON.
``include_history`` is reserved for compatibility and currently has no
effect because the bundled dataset has no historical series.
"""
return json.dumps(self.to_dict(include_history), ensure_ascii=False, indent=indent)
def __str__(self) -> str:
return self.name
def __repr__(self) -> str:
return f"Country(name={self.name!r}, alpha2={self.alpha2!r})"
[docs]
@dataclass(frozen=True, slots=True)
class CountryMatch:
"""A ranked country-search result."""
country: Country
matched_name: str
score: int