Source code for pyworldatlas.models

"""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 format(self, *, precision: int = 4) -> str: """Return signed coordinates as an easy-to-read hemisphere label. ``Coordinate(35.6895, 139.6917).format()`` returns ``"35.6895° N, 139.6917° E"``. ``precision`` controls decimal places and must be an integer from 0 through 8. """ if isinstance(precision, bool) or not isinstance(precision, int): raise TypeError("precision must be an integer") if not 0 <= precision <= 8: raise ValueError("precision must be between 0 and 8") latitude_hemisphere, longitude_hemisphere = self.hemispheres return ( f"{abs(self.latitude):.{precision}f}° {latitude_hemisphere}, " f"{abs(self.longitude):.{precision}f}° {longitude_hemisphere}" )
[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