"""The :class:`Atlas` class: every question you can ask, in one place.
Methods are grouped in the file by topic, in this order: country lookup and
filters, rankings, physical geography, borders, learning, cities and
distances, places, statistics, landmarks, the sun and antipodes, and the tour.
Older method names sit at the end and simply call the newer ones.
Why one big class: beginners type ``atlas.`` and find everything without
learning any imports. The work behind each method lives in small private
modules (``_profiles``, ``_rankings``, ``_learning``, ``_placefile``, and
others), so this file mostly checks arguments and puts results together.
The bundled SQLite database is opened read-only. Results are cached per
atlas, so repeated questions are fast even on a Raspberry Pi Zero 2 W.
"""
from __future__ import annotations
from collections import deque
from datetime import date, datetime
import heapq
from math import asin, cos, radians, sin, sqrt
from pathlib import Path
import sqlite3
from typing import TYPE_CHECKING, Any, Literal, overload
from collections.abc import Iterable, Iterator
from ._normalization import normalize_name
from ._version import SCHEMA_VERSION, __version__
from .database import Database
from ._learning import (FLASHCARD_TOPICS, build_flashcard, has_flashcard_answer,
stable_country_sample, stable_text_order)
from ._rankings import METRIC_SOURCES, METRIC_UNITS, metric_value, normalize_metric
from ._profiles import build_country, city_from_row, read_local_names
from ._placekinds import CATEGORIES, KINDS, category_of, kind_of
from ._sun import sun_times
from ._suggest import by_prefix, choice, closest, did_you_mean
from .exceptions import (AmbiguousCountryError, AmbiguousPlaceError,
AtlasClosedError, CapitalNotFoundError,
CountryNotFoundError, DatasetVersionError,
PackNotInstalledError, PlaceNotFoundError)
from .models import (Airport, Distance, Antipode, BorderPathResult, Capital, CapitalDistance, City,
CityDistance, Coordinate, Country, Indicator, StatisticSeries, StatisticValue,
CountryMatch, CountryRanking, DatasetInfo, Flashcard, LocalizedName, Place,
PlaceDistance, PlaceQuestion, QuizQuestion, SiteDistance, SunTimes, WorldHeritageSite)
if TYPE_CHECKING:
from . import _placefile, packs
from ._landmarks import Landmarks
from ._statistics import Statistics
from .maps import CountryMap, WorldMap
else:
from ._lazy import LazyModule
# Place files, packs, and landmarks need compression, hashing, and zip
# modules. They load on first use, so ``import pyworldatlas`` stays quick
# on small computers such as a Raspberry Pi.
_placefile = LazyModule("pyworldatlas._placefile")
packs = LazyModule("pyworldatlas.packs")
def _name_variants(key: str) -> tuple[str, ...]:
"""Return the exact lookup key, then common spelling variants of it.
People often add or drop a leading "the" ("The Gambia", "Netherlands") and
shorten "Saint" to "St" ("St Lucia"). Variants are tried only when the exact
key has no match, so they never change an existing result.
"""
words = key.split()
variants = [key]
if words and words[0] == "the":
variants.append(" ".join(words[1:]))
elif words:
variants.append(f"the {key}")
if "st" in words:
variants.append(" ".join("saint" if word == "st" else word for word in words))
return tuple(dict.fromkeys(variant for variant in variants if variant))
# GeoNames codes for whole countries and territories, shown as Country profiles.
_COUNTRY_CODES = frozenset({"A.PCL", "A.PCLD", "A.PCLF", "A.PCLI", "A.PCLS", "A.TERR"})
[docs]
class Atlas:
"""Open and explore the bundled, read-only world atlas.
With no arguments, :class:`Atlas` uses the SQLite dataset shipped inside
the package. Pass ``database_path`` only when working with a compatible
database built by the PyWorldAtlas pipeline. A context manager closes the
database promptly; already materialized immutable models remain usable.
Runtime lookups and calculations require no network access.
``data_dir`` selects the folder of optional data packs, such as 3D maps.
It defaults to ``PYWORLDATLAS_DATA`` or a per-user folder; see
:mod:`pyworldatlas.packs`.
"""
[docs]
def __init__(
self,
database_path: str | Path | None = None,
*,
data_dir: str | Path | None = None,
) -> None:
self._db = Database(database_path)
self._data_dir = data_dir
self._closed = False
self._country_cache: dict[int, Country] = {}
self._local_names_cache: dict[int, tuple[LocalizedName, ...]] | None = None
self._border_cache: dict[int, tuple[int, ...]] | None = None
self._bulk_rows: dict[str, dict[int, list[sqlite3.Row]]] | None = None
self._query_cache: dict[str, tuple[int, ...]] = {}
self._code_cache: dict[str, tuple[int, ...]] | None = None
self._city_cache: list[tuple[sqlite3.Row, float, float, float]] | None = None
self._place_indexes: list[sqlite3.Connection] | None = None
self._landmarks: Landmarks | None = None
self._statistics: Statistics | None = None
found = int(self._meta("schema_version"))
if found != SCHEMA_VERSION:
self.close()
raise DatasetVersionError(f"Dataset schema {found} is incompatible with runtime schema {SCHEMA_VERSION}")
def _ensure_open(self) -> None:
if self._closed:
raise AtlasClosedError("This Atlas has been closed")
def _meta(self, key: str) -> str:
row = self._db.connection.execute("SELECT value FROM schema_meta WHERE key = ?", (key,)).fetchone()
return str(row[0]) if row else ""
[docs]
def dataset_info(self) -> DatasetInfo:
"""Return the installed library, database schema, and dataset versions.
Record these values when a lesson or calculation must be reproducible.
Returns:
A :class:`DatasetInfo` with versions, build date, and source names.
Example:
>>> print(atlas.dataset_info()) # doctest: +ELLIPSIS
PyWorldAtlas ..., 248 countries
>>> atlas.dataset_info().country_count
248
"""
self._ensure_open()
return DatasetInfo(__version__, SCHEMA_VERSION, self._meta("dataset_version"), len(self), self._meta("built_at"))
def _country_ids(self, query: str) -> tuple[int, ...]:
self._ensure_open()
if not isinstance(query, str):
return self._read_country_ids(query)
ids = self._query_cache.get(query)
if ids is None:
if len(self._query_cache) >= 4096:
self._query_cache.clear()
ids = self._query_cache[query] = self._read_country_ids(query)
return ids
def _read_country_ids(self, query: str) -> tuple[int, ...]:
if isinstance(query, str) and query.isascii():
code_ids = self._code_ids().get(query.upper())
if code_ids:
return code_ids
elif not isinstance(query, str):
code_rows = self._db.connection.execute(
"""SELECT id FROM country
WHERE upper(alpha2)=upper(?) OR upper(alpha3)=upper(?)
OR numeric_code=?
ORDER BY id""",
(query, query, query),
).fetchall()
if code_rows:
return tuple(int(row[0]) for row in code_rows)
for key in _name_variants(normalize_name(query)):
name_rows = self._db.connection.execute(
"""SELECT DISTINCT country_id FROM country_name
WHERE normalized_name=? ORDER BY country_id""",
(key,),
).fetchall()
if name_rows:
return tuple(int(row[0]) for row in name_rows)
return ()
def _code_ids(self) -> dict[str, tuple[int, ...]]:
"""Map upper-case alpha-2, alpha-3, and numeric codes to country ids."""
if self._code_cache is None:
grouped: dict[str, set[int]] = {}
rows = self._db.connection.execute(
"SELECT id, alpha2, alpha3, numeric_code FROM country"
)
for row in rows:
for code in (row["alpha2"], row["alpha3"], row["numeric_code"]):
if code is not None:
grouped.setdefault(str(code).upper(), set()).add(int(row["id"]))
self._code_cache = {
code: tuple(sorted(ids)) for code, ids in grouped.items()
}
return self._code_cache
def _country_id(self, query: str) -> int | None:
ids = self._country_ids(query)
return ids[0] if len(ids) == 1 else None
def _country_suggestions(self, query: str) -> list[str]:
"""Return up to three country names that look like a mistyped ``query``."""
partial = [match.country.name for match in self.search_countries(query, limit=3)]
if partial:
return partial
# No name contains the text, so compare spellings against every known
# name, alias, and local name, then show each country's usual name.
rows = self._db.connection.execute("SELECT DISTINCT country_id, name FROM country_name").fetchall()
by_name: dict[str, int] = {}
for row in rows:
by_name.setdefault(str(row["name"]), int(row["country_id"]))
found: list[str] = []
for name in closest(query, by_name, limit=6):
display = self._load_country(by_name[name]).name
if display not in found:
found.append(display)
return found[:3]
[docs]
def country(self, query: str) -> Country:
"""Return one country profile resolved from a name or standard code.
``query`` may be a familiar English name, indexed alias, alpha-2 code,
alpha-3 code, or three-digit M49 code. Matching is case-insensitive and
accent-tolerant. Missing queries raise :class:`CountryNotFoundError`,
with suggestions when available. A name shared by multiple profiles
raises :class:`AmbiguousCountryError` and lists the matches.
Args:
query: A country name (``"Japan"``), alias, alpha-2 code (``"JP"``),
alpha-3 code (``"JPN"``), or M49 code (``"392"``).
Returns:
The full :class:`Country` profile.
Raises:
CountryNotFoundError: Nothing matches. The message suggests close
spellings, such as "Did you mean Japan?".
AmbiguousCountryError: The name fits more than one country.
Example:
>>> atlas.country("Japan")
Country(name='Japan', alpha2='JP')
>>> atlas.country("DE").name
'Germany'
>>> atlas.country("392").name
'Japan'
"""
self._ensure_open()
if not isinstance(query, str):
raise TypeError("query must be a string")
if not normalize_name(query):
raise ValueError("query must contain at least one letter or number")
country_ids = self._country_ids(query)
if not country_ids:
hint = did_you_mean(self._country_suggestions(query))
raise CountryNotFoundError(f"No country matches {query!r}.{hint}")
if len(country_ids) > 1:
matches = ", ".join(
f"{country.name} ({country.alpha2})"
for country in (self._load_country(item) for item in country_ids)
)
raise AmbiguousCountryError(
f"Country name {query!r} is ambiguous; matches include {matches}"
)
return self._load_country(country_ids[0])
[docs]
def get(self, query: str, default: Country | None = None) -> Country | None:
"""Return a matching country, or ``default`` instead of raising.
Use :meth:`country` when a missing profile should be treated as an
error. ``default`` is returned for both missing and non-unique queries.
Args:
query: A country name or code, as for :meth:`country`.
default: What to return when nothing matches.
Returns:
The :class:`Country`, or ``default``.
Example:
>>> atlas.get("FR")
Country(name='France', alpha2='FR')
>>> atlas.get("Atlantis") is None
True
"""
try:
return self.country(query)
except (CountryNotFoundError, AmbiguousCountryError):
return default
[docs]
def map(
self,
country: str | Country,
*,
quality: str = "auto",
places: int = 8,
) -> CountryMap:
"""Return an interactive offline 3D map for a country profile.
``country`` accepts the same names and codes as :meth:`country`, or an
already loaded :class:`Country`. ``quality="auto"`` prefers Standard
map data when installed and otherwise uses Overview data.
The map marks the ``places`` best-known towns, mountains and islands,
lakes and seas, and landmarks of the country, each kind as a layer
that can be shown or hidden. Pass ``places=0`` for terrain only.
The returned map opens in the default browser when its ``show()``
method is called. Maps need a map pack, installed once with
``pyworldatlas get maps`` (Standard) or ``pyworldatlas get maps-lite``
(Overview). Packs are read from this atlas's ``data_dir``.
Args:
country: A country name, code, or profile.
quality: ``"auto"``, ``"standard"``, or ``"overview"``.
places: How many well-known places to mark.
Returns:
A :class:`~pyworldatlas.maps.CountryMap`.
Raises:
MapSupportNotInstalledError: No map pack is installed.
Example:
>>> japan = atlas.map("Japan")
>>> print(japan)
3D map of Japan
"""
self._ensure_open()
if isinstance(country, str):
profile = self.country(country)
elif isinstance(country, Country):
profile = country
else:
raise TypeError("country must be a country name, code, or Country")
from .exceptions import MapSupportNotInstalledError
from .maps import CountryMap, MapDataError
if isinstance(places, bool) or not isinstance(places, int) or places < 0:
raise ValueError("places must be zero or a positive integer")
marked: list[Place] = []
if places:
for category in ("town", "landform", "water", "landmark"):
marked.extend(self.places_in(profile, category=category, limit=places))
result = CountryMap.from_country(
profile, quality=quality, data_dir=self._data_dir, places=tuple(marked)
)
try:
_ = result.quality # find the map pack now so a missing pack fails here
except MapDataError as error:
raise MapSupportNotInstalledError(str(error)) from error
return result
[docs]
def search_countries(self, query: str, *, limit: int = 20) -> tuple[CountryMatch, ...]:
"""Return ranked partial-name matches for human-entered text.
Matching is case-insensitive and accent-tolerant. Exact matches rank
before prefixes and substrings. The returned tuple is empty when no
indexed name matches; use :meth:`country` for exact resolution.
Args:
query: Part of a country name, such as ``"guin"``.
limit: The most matches to return.
Returns:
:class:`CountryMatch` rows, best match first.
Example:
>>> atlas.search_countries("guin", limit=3)
(CountryMatch('Guinea'), CountryMatch('Guinea-Bissau'), CountryMatch('Equatorial Guinea'))
"""
self._ensure_open()
if not isinstance(query, str):
raise TypeError("query must be a string")
normalized = normalize_name(query)
if not normalized:
raise ValueError("query must contain at least one letter or number")
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
raise ValueError("limit must be a positive integer")
rows = self._db.connection.execute(
"""SELECT country_id, name, normalized_name FROM country_name
WHERE normalized_name LIKE ? ORDER BY normalized_name, country_id""", (f"%{normalized}%",)).fetchall()
best: dict[int, tuple[int, str]] = {}
for row in rows:
score = 100 if row["normalized_name"] == normalized else 80 if row["normalized_name"].startswith(normalized) else 50
old = best.get(row["country_id"])
if old is None or score > old[0]:
best[row["country_id"]] = (score, row["name"])
ranked = sorted(best.items(), key=lambda item: (-item[1][0], self._load_country(item[0]).name))[:limit]
return tuple(CountryMatch(self._load_country(cid), matched, score) for cid, (score, matched) in ranked)
[docs]
def countries(
self,
*,
continent: str | None = None,
region: str | None = None,
currency_code: str | None = None,
language_code: str | None = None,
script_code: str | None = None,
timezone_id: str | None = None,
coastal: bool | None = None,
koppen_geiger_code: str | None = None,
has_rivers: bool | None = None,
has_lakes: bool | None = None,
river: str | None = None,
lake: str | None = None,
climate_zone: str | None = None,
land_borders: bool | None = None,
) -> tuple[Country, ...]:
"""Return countries alphabetically, keeping only those that match every filter.
This is the one place to filter countries. With no filters, every
bundled profile is returned in display-name order.
Physical-data filters only include profiles covered by the relevant
source layer. For example, ``coastal=False`` means a sourced coastline
of zero kilometres; it does not treat missing coastline data as zero.
Args:
continent: Keep countries on this continent, such as ``"Africa"``.
region: Keep countries in this UN region or subregion.
currency_code: Keep countries using this currency, such as ``"EUR"``.
language_code: Keep countries with this language, such as ``"es"``.
script_code: Keep countries writing in this script, such as ``"Cyrl"``.
timezone_id: Keep countries in this time zone, such as ``"Asia/Tokyo"``.
coastal: ``True`` for a coastline, ``False`` for landlocked.
koppen_geiger_code: Older name for ``climate_zone``.
has_rivers: Keep countries with (or without) listed major rivers.
has_lakes: Keep countries with (or without) listed major lakes.
river: Keep countries a river flows through; part of the name is
enough, in any case.
lake: Keep countries with this lake; part of the name is enough.
climate_zone: Keep countries with this Köppen-Geiger zone, a code
from :meth:`climate_zone_codes` such as ``"Cfb"``.
land_borders: ``False`` for islands and other countries with no
land border, ``True`` for the rest.
Returns:
Matching :class:`Country` profiles, alphabetical.
Raises:
ValueError: The climate zone is unknown.
Example:
>>> len(atlas.countries(continent="Africa", coastal=False)) # landlocked
16
>>> atlas.countries(river="Danube")[:3]
(Country(name='Austria', alpha2='AT'), Country(name='Bulgaria', alpha2='BG'), Country(name='Croatia', alpha2='HR'))
>>> len(atlas.countries(land_borders=False))
85
"""
self._ensure_open()
clauses: list[str] = []
params: list[Any] = []
if continent:
clauses.append("continent = ?")
params.append(continent)
if region:
clauses.append("(region = ? OR subregion = ?)")
params.extend((region, region))
if currency_code:
clauses.append("upper(currency_code) = upper(?)")
params.append(currency_code)
if language_code:
clauses.append(
"id IN (SELECT country_id FROM country_language "
"WHERE lower(code)=lower(?) OR lower(primary_code)=lower(?))"
)
params.extend((language_code, language_code))
if script_code:
clauses.append(
"id IN (SELECT country_id FROM country_language WHERE lower(script_code)=lower(?))"
)
params.append(script_code)
if timezone_id:
clauses.append(
"id IN (SELECT country_id FROM country_timezone WHERE lower(timezone_id)=lower(?))"
)
params.append(timezone_id)
if coastal is not None:
if not isinstance(coastal, bool):
raise TypeError("coastal must be bool or None")
comparison = "> 0" if coastal else "= 0"
clauses.append(
"id IN (SELECT country_id FROM country_physical "
f"WHERE coastline_km {comparison})"
)
if climate_zone is not None:
koppen_geiger_code = self._climate_zone(climate_zone)
if koppen_geiger_code:
clauses.append(
"id IN (SELECT country_id FROM country_climate_zone "
"WHERE upper(code)=upper(?))"
)
params.append(koppen_geiger_code)
for requested, table, label in (
(has_rivers, "country_river", "has_rivers"),
(has_lakes, "country_lake", "has_lakes"),
):
if requested is not None:
if not isinstance(requested, bool):
raise TypeError(f"{label} must be bool or None")
operator = "IN" if requested else "NOT IN"
clauses.append(f"id {operator} (SELECT country_id FROM {table})")
for name, table in ((river, "country_river"), (lake, "country_lake")):
if name is not None:
# The retained source label is searched too, so "Nile" also
# finds rows whose short name is the local "An Nil".
pattern = f"%{name.casefold()}%"
clauses.append(
f"id IN (SELECT country_id FROM {table} "
"WHERE lower(name) LIKE ? OR lower(source_label) LIKE ?)"
)
params.extend((pattern, pattern))
if land_borders is not None:
if not isinstance(land_borders, bool):
raise TypeError("land_borders must be bool or None")
alone = [cid for cid, neighbors in self._border_adjacency().items() if not neighbors]
if alone or not land_borders:
marks = ",".join("?" * len(alone)) or "NULL"
clauses.append(f"id {'NOT IN' if land_borders else 'IN'} ({marks})")
params.extend(alone)
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
rows = self._db.connection.execute(f"SELECT id FROM country{where} ORDER BY name", params).fetchall()
return self._load_countries(int(row[0]) for row in rows)
[docs]
def rank_countries(
self,
metric: str,
*,
limit: int | None = None,
descending: bool = True,
continent: str | None = None,
region: str | None = None,
) -> tuple[CountryRanking, ...]:
"""Rank countries by a documented sourced or directly derived metric.
Supported physical metrics include ``"land_area"``, ``"water_area"``,
``"water_percent"``, ``"coastline"``, ``"mean_elevation"``,
``"highest_elevation"``, ``"lowest_elevation"``, ``"river_count"``,
``"lake_count"``, and ``"climate_zone_count"``. Existing population,
total area, density, border, and city metrics remain available. Missing
values are excluded. Each row names its ``source``.
:meth:`rank` does the same and also ranks statistics; prefer it in new
code.
Args:
metric: One of the metrics above, or ``"population"``, ``"area"``,
``"population_density"``, ``"border_count"``, or
``"major_city_count"``.
limit: Keep only the first ``limit`` rows.
descending: ``False`` ranks smallest first.
continent: Rank only countries on this continent.
region: Rank only countries in this region or subregion.
Returns:
:class:`CountryRanking` rows, first place first.
Raises:
ValueError: The metric is unknown.
Example:
>>> atlas.rank_countries("coastline", limit=2)
(CountryRanking('#1 Canada: 202,080 km'), CountryRanking('#2 Indonesia: 54,716 km'))
"""
normalized_metric = normalize_metric(metric)
if limit is not None and (
isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0
):
raise ValueError("limit must be a positive integer or None")
ranked: list[tuple[Country, int | float]] = []
for country in self.countries(continent=continent, region=region):
value = metric_value(
country, normalized_metric, lambda item: len(self.neighbors(item.alpha2))
)
if value is not None:
ranked.append((country, value))
ranked.sort(
key=lambda item: (
-float(item[1]) if descending else float(item[1]),
item[0].name,
)
)
if limit is not None:
ranked = ranked[:limit]
return tuple(
CountryRanking(
position, country.reference(), normalized_metric, value,
METRIC_UNITS[normalized_metric], METRIC_SOURCES[normalized_metric],
)
for position, (country, value) in enumerate(ranked, 1)
)
[docs]
def rank(
self,
metric: str,
*,
year: int | None = None,
limit: int | None = None,
descending: bool = True,
continent: str | None = None,
region: str | None = None,
) -> tuple[CountryRanking, ...]:
"""Rank countries by anything the atlas can measure, largest first.
Args:
metric: A profile metric such as ``"area"``, ``"coastline"``, or
``"border_count"`` (all are listed in :meth:`rank_countries`),
or a statistic such as ``"life_expectancy"`` from
:meth:`indicators`.
year: Rank a statistic for one year. Without it, each country
uses its latest published value, and the row's ``year`` says
which year that is.
limit: Keep only the first ``limit`` rows.
descending: ``False`` ranks smallest first.
continent: Rank only countries on this continent.
region: Rank only countries in this region or subregion.
Returns:
:class:`CountryRanking` rows that print as ``#1 Canada: 202,080 km``.
Every row names its ``source``. Missing values are left out, never
guessed.
Raises:
ValueError: The metric is unknown. The message suggests a close
spelling and lists the choices.
``"population"`` ranks the bundled GeoNames snapshot; with a ``year``
it ranks the World Bank figures from the statistics pack. Ranking
orders numbers only; it says nothing about which is better.
Example:
>>> print(atlas.rank("coastline", limit=1)[0])
#1 Canada: 202,080 km
>>> atlas.rank("coastline", continent="Oceania", limit=2)
(CountryRanking('#1 Australia: 25,760 km'), CountryRanking('#2 New Zealand: 15,134 km'))
>>> atlas.rank("life_expectancy", year=2020, limit=1)
(CountryRanking('#1 Monaco: 86.1 years (2020)'),)
"""
if year is None:
try:
key = normalize_metric(metric)
except ValueError:
pass # not a profile metric, so try the statistics below
else:
return self.rank_countries(
key, limit=limit, descending=descending, continent=continent, region=region
)
if limit is not None and (isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0):
raise ValueError("limit must be a positive integer or None")
indicator = self._rank_indicator(metric, year)
keep = (
None if continent is None and region is None
else {country.alpha2 for country in self.countries(continent=continent, region=region)}
)
rows: list[CountryRanking] = []
for item in self.sort_by_statistic(indicator.key, year=year, descending=descending, limit=None):
if keep is not None and item.area not in keep:
continue
profile = self.get(item.area)
if profile is None:
continue
rows.append(CountryRanking(
len(rows) + 1, profile.reference(), indicator.key, item.value,
indicator.unit, indicator.source, item.year,
))
if limit is not None and len(rows) == limit:
break
return tuple(rows)
def _rank_indicator(self, metric: str, year: int | None) -> Indicator:
"""Return the statistic ``rank`` should use, or explain every choice."""
try:
indicators = self._statistics_pack().indicators
except PackNotInstalledError:
indicators = {}
name = choice(str(metric), indicators) if indicators else None
if name is not None:
return indicators[name]
options = tuple(indicators) if year is not None else (*METRIC_UNITS, *indicators)
hint = did_you_mean(closest(str(metric), options))
message = f"Unknown metric {metric!r}.{hint} Choose from {', '.join(options)}."
if not indicators:
message += " Statistics such as life_expectancy need the statistics pack: pyworldatlas get statistics"
raise ValueError(message)
[docs]
def climate_zone_codes(self) -> tuple[str, ...]:
"""Return every represented Köppen-Geiger code in source legend order.
Returns:
Codes such as ``"Af"`` (tropical rainforest) for use with
``countries(climate_zone=...)``.
Example:
>>> atlas.climate_zone_codes()[:5]
('Af', 'Am', 'Aw', 'BWh', 'BWk')
"""
self._ensure_open()
rows = self._db.connection.execute(
"SELECT DISTINCT code FROM country_climate_zone"
).fetchall()
# SQLite row order is not a scientific legend. The explicit class
# sequence makes the public result stable across database rebuilds.
order = (
"Af", "Am", "Aw", "BWh", "BWk", "BSh", "BSk", "Csa", "Csb",
"Csc", "Cwa", "Cwb", "Cwc", "Cfa", "Cfb", "Cfc", "Dsa", "Dsb",
"Dsc", "Dsd", "Dwa", "Dwb", "Dwc", "Dwd", "Dfa", "Dfb", "Dfc",
"Dfd", "ET", "EF",
)
present = {str(row[0]) for row in rows}
return tuple(code for code in order if code in present)
def _climate_zone(self, code: str) -> str:
"""Return the Köppen-Geiger code ``code`` means, or explain the choices."""
codes = self.climate_zone_codes()
found = choice(code, codes) if isinstance(code, str) else None
if found is None:
hint = did_you_mean(closest(str(code), codes))
raise ValueError(
f"Unknown or unrepresented Köppen-Geiger code {code!r}.{hint} Choose from {', '.join(codes)}."
)
return found
[docs]
def countries_in_climate_zone(self, code: str) -> tuple[Country, ...]:
"""Older name for ``countries(climate_zone=code)``, kept until 1.0."""
return self.countries(climate_zone=code)
[docs]
def countries_with_river(self, name: str | None = None) -> tuple[Country, ...]:
"""Older name for ``countries(river=name)``, kept until 1.0.
With no ``name`` it is ``countries(has_rivers=True)``.
"""
return self.countries(has_rivers=True) if name is None else self.countries(river=name)
[docs]
def countries_with_lake(self, name: str | None = None) -> tuple[Country, ...]:
"""Older name for ``countries(lake=name)``, kept until 1.0.
With no ``name`` it is ``countries(has_lakes=True)``.
"""
return self.countries(has_lakes=True) if name is None else self.countries(lake=name)
[docs]
def countries_with_local_names(
self,
*,
language_code: str | None = None,
script_code: str | None = None,
name_kind: str | None = None,
) -> tuple[Country, ...]:
"""Return countries with sourced local-language name records.
Results are alphabetical. ``language_code`` and ``script_code`` are
optional, case-insensitive exact filters such as ``"es"``, ``"hi"``,
``"Deva"``, or ``"Jpan"``. Every UN M49 record has one selected local
identity. Inspect ``LocalizedName.kind`` to distinguish reviewed
national official forms from CLDR locale display names, or pass
``name_kind="national_official"`` or ``"locale_display"`` directly.
Args:
language_code: Keep names in this language, such as ``"hi"``.
script_code: Keep names in this script, such as ``"Deva"``.
name_kind: ``"national_official"`` or ``"locale_display"``.
Returns:
Matching :class:`Country` profiles, alphabetical.
Example:
>>> atlas.countries_with_local_names(language_code="pt")[:2]
(Country(name='Angola', alpha2='AO'), Country(name='Brazil', alpha2='BR'))
"""
self._ensure_open()
clauses: list[str] = []
params: list[str] = []
if language_code is not None:
clauses.append("lower(n.language_code) = lower(?)")
params.append(language_code)
if script_code is not None:
clauses.append("lower(n.script_code) = lower(?)")
params.append(script_code)
if name_kind is not None:
normalized_kind = name_kind.casefold()
if normalized_kind not in {"national_official", "locale_display"}:
raise ValueError(
"name_kind must be 'national_official' or 'locale_display'"
)
clauses.append("n.name_kind = ?")
params.append(normalized_kind)
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
rows = self._db.connection.execute(
f"""SELECT DISTINCT c.id
FROM country c JOIN country_local_name n ON n.country_id = c.id
{where}
ORDER BY c.name""",
params,
).fetchall()
return self._load_countries(int(row[0]) for row in rows)
def _border_adjacency(self) -> dict[int, tuple[int, ...]]:
"""Load the undirected border graph once for graph operations."""
if self._border_cache is None:
self._border_cache = self._read_border_adjacency()
return self._border_cache
def _read_border_adjacency(self) -> dict[int, tuple[int, ...]]:
self._ensure_open()
country_rows = self._db.connection.execute(
"SELECT id, name FROM country ORDER BY name"
).fetchall()
names = {int(row["id"]): str(row["name"]) for row in country_rows}
graph: dict[int, list[int]] = {country_id: [] for country_id in names}
rows = self._db.connection.execute(
"SELECT country1_id, country2_id FROM country_border"
).fetchall()
for row in rows:
first, second = int(row["country1_id"]), int(row["country2_id"])
graph[first].append(second)
graph[second].append(first)
return {
country_id: tuple(sorted(neighbors, key=lambda item: names[item]))
for country_id, neighbors in graph.items()
}
def _border_country_id(self, query: str | Country) -> int:
"""Resolve a country name, code, or profile for an internal graph operation."""
if isinstance(query, Country):
query = query.alpha2
country_id = self._country_id(query)
if country_id is None:
self.country(query) # raises the public error with ranked suggestions
raise CountryNotFoundError(f"No unambiguous country matches {query!r}") # pragma: no cover
return country_id
[docs]
def neighbors(self, country: str | Country) -> tuple[Country, ...]:
"""Return countries sharing a reviewed land border with ``country``.
Results are alphabetized and immutable. Maritime neighbors, proximity,
and mere point contacts are excluded. Countries and areas without an
accepted land-border relationship return an empty tuple.
Args:
country: A country name, code, or profile.
Returns:
The neighboring :class:`Country` profiles, alphabetical.
Raises:
CountryNotFoundError: The country is unknown.
Example:
>>> atlas.neighbors("Portugal")
(Country(name='Spain', alpha2='ES'),)
>>> atlas.neighbors("Japan")
()
"""
country_id = self._border_country_id(country)
return tuple(self._load_country(item) for item in self._border_adjacency()[country_id])
[docs]
def shares_border(self, country1: str | Country, country2: str | Country) -> bool:
"""Return whether two countries share a reviewed land border.
Args:
country1: A country name, code, or profile.
country2: Another country.
Returns:
``True`` when the two touch by land.
Example:
>>> atlas.shares_border("France", "Spain")
True
>>> atlas.shares_border("France", "Portugal")
False
"""
first = self._border_country_id(country1)
second = self._border_country_id(country2)
return second in self._border_adjacency()[first]
[docs]
def shared_neighbors(self, country1: str | Country, country2: str | Country) -> tuple[Country, ...]:
"""Return alphabetized land neighbors shared by two countries.
Args:
country1: A country name, code, or profile.
country2: Another country.
Returns:
The :class:`Country` profiles that border both.
Example:
>>> atlas.shared_neighbors("France", "Germany")
(Country(name='Belgium', alpha2='BE'), Country(name='Luxembourg', alpha2='LU'), Country(name='Switzerland', alpha2='CH'))
"""
first = self._border_country_id(country1)
second = self._border_country_id(country2)
common = set(self._border_adjacency()[first]) & set(self._border_adjacency()[second])
return tuple(sorted((self._load_country(item) for item in common), key=lambda item: item.name))
[docs]
def border_path(self, origin: str | Country, destination: str | Country) -> BorderPathResult | None:
"""Return a deterministic shortest land-border path, or ``None``.
The path uses breadth-first search over the reviewed undirected graph.
Both endpoints are included. Equal-length alternatives are resolved by
alphabetic neighbor order. ``None`` means the two entities have no path
through accepted land-border relationships; it is not an error.
Args:
origin: The starting country.
destination: The country to reach.
Returns:
A :class:`BorderPathResult` that prints as the route, or ``None``.
Example:
>>> print(atlas.border_path("Portugal", "Poland"))
Portugal → Spain → France → Germany → Poland (4 border crossings)
>>> atlas.border_path("Japan", "France") is None
True
"""
start = self._border_country_id(origin)
finish = self._border_country_id(destination)
parents: dict[int, int | None] = {start: None}
queue = deque([start])
graph = self._border_adjacency()
while queue and finish not in parents:
current = queue.popleft()
for neighbor in graph[current]:
if neighbor not in parents:
parents[neighbor] = current
queue.append(neighbor)
if finish not in parents:
return None
path = [finish]
parent = parents[finish]
while parent is not None:
path.append(parent)
parent = parents[parent]
path.reverse()
references = tuple(self._load_country(item).reference() for item in path)
return BorderPathResult(references, len(references) - 1)
[docs]
def border_crossings(self, origin: str | Country, destination: str | Country) -> int | None:
"""Return the fewest land-border crossings, or ``None`` if unreachable.
Args:
origin: The starting country.
destination: The country to reach.
Returns:
The number of borders crossed on the shortest land route.
Example:
>>> atlas.border_crossings("Portugal", "Germany")
3
>>> atlas.border_crossings("Japan", "Iceland") is None
True
"""
path = self.border_path(origin, destination)
return path.crossings if path else None
[docs]
def has_land_route(self, origin: str | Country, destination: str | Country) -> bool:
"""Return whether ``origin`` and ``destination`` are land-connected.
This is derived at query time from the reviewed border graph; it does
not use road, rail, ferry, maritime, or travel-access data. Identical
endpoints return ``True`` because their shortest graph path has zero
border crossings. Unknown country queries raise
:class:`~pyworldatlas.CountryNotFoundError`.
Args:
origin: The starting country.
destination: The country to reach.
Returns:
``True`` when you could walk from one to the other across borders.
Example:
>>> atlas.has_land_route("Portugal", "Austria")
True
>>> atlas.has_land_route("Iceland", "Norway")
False
"""
return self.border_path(origin, destination) is not None
[docs]
def countries_reachable_by_land(self, country: str | Country) -> tuple[Country, ...]:
"""Return every other entity in ``country``'s land-connected component.
The starting country is excluded. Results are alphabetized; an island
or otherwise borderless entity returns an empty tuple.
Args:
country: The starting country.
Returns:
The other :class:`Country` profiles you could reach across borders.
Example:
>>> len(atlas.countries_reachable_by_land("Portugal")) > 50
True
>>> atlas.countries_reachable_by_land("Japan")
()
"""
start = self._border_country_id(country)
graph = self._border_adjacency()
reached = {start}
queue = deque([start])
while queue:
for neighbor in graph[queue.popleft()]:
if neighbor not in reached:
reached.add(neighbor)
queue.append(neighbor)
reached.remove(start)
return tuple(sorted((self._load_country(item) for item in reached), key=lambda item: item.name))
[docs]
def countries_with_no_land_borders(self) -> tuple[Country, ...]:
"""Older name for ``countries(land_borders=False)``, kept until 1.0."""
return self.countries(land_borders=False)
[docs]
def sample_countries(
self,
*,
count: int,
continent: str | None = None,
region: str | None = None,
seed: int | str = 0,
) -> tuple[Country, ...]:
"""Return a reproducible educational sample of country profiles.
Candidates are ranked with a versioned SHA-256 algorithm using their
stable M49 identifiers. Results therefore do not depend on SQLite row
order, global random state, or implementation details of
``random.sample()``. The same dataset, filters, count, and seed produce the
same ordered result across supported Python versions.
``count`` must be positive and cannot exceed the filtered population.
``continent`` and ``region`` follow :meth:`countries` semantics.
Args:
count: How many countries to pick.
continent: Pick only from this continent.
region: Pick only from this region or subregion.
seed: The same seed always picks the same countries.
Returns:
The picked :class:`Country` profiles.
Raises:
ValueError: ``count`` is not positive or is larger than the choices.
Example:
>>> atlas.sample_countries(count=3, continent="Europe", seed="class-1")
(Country(name='Andorra', alpha2='AD'), Country(name='Hungary', alpha2='HU'), Country(name='Monaco', alpha2='MC'))
"""
return stable_country_sample(
self.countries(continent=continent, region=region), count, seed
)
[docs]
def learning_topics(self) -> tuple[str, ...]:
"""Return topics supported by :meth:`flashcards` and :meth:`quiz`.
The tuple is stable, alphabetized, and safe to use for menus, lesson
builders, or playground controls.
Returns:
Topic names such as ``"capitals"`` and ``"flags"``.
Example:
>>> atlas.learning_topics()[:4]
('alpha_2_codes', 'alpha_3_codes', 'areas', 'border_counts')
"""
self._ensure_open()
return FLASHCARD_TOPICS
def _learning_topic(self, topic: str, also: tuple[str, ...] = ()) -> str:
"""Return the topic ``topic`` means ("Capital" means "capitals"), or list the choices.
``also`` names extra topics to suggest, such as the place topics that
only :meth:`quiz` accepts.
"""
if not isinstance(topic, str):
raise TypeError("topic must be a string")
found = choice(topic, FLASHCARD_TOPICS)
if found is None:
options = (*FLASHCARD_TOPICS, *also)
hint = did_you_mean(closest(topic, options))
raise ValueError(f"Unknown topic {topic!r}.{hint} Choose from {', '.join(options)}.")
return found
[docs]
def flashcards(
self,
topic: str,
*,
count: int = 10,
continent: str | None = None,
region: str | None = None,
seed: int | str = 0,
) -> tuple[Flashcard, ...]:
"""Return deterministic, immutable geography flashcards.
Supported topics are ``alpha_2_codes``, ``alpha_3_codes``, ``areas``,
``border_counts``, ``calling_codes``, ``capitals``, ``climate_zones``,
``coastlines``, ``continents``,
``countries_from_capitals``, ``currencies``, ``driving_sides``, ``flags``,
``highest_points``, ``language_codes``, ``lakes``, ``local_names``,
``m49_codes``, ``mains_electricity``, ``neighbors``, ``plug_types``, ``rivers``,
``population_density``, ``populations``, ``regions``, and
``top_level_domains``. Countries missing the answer required by a topic
are excluded before sampling. An impossible count raises
:class:`ValueError` rather than silently returning fewer cards.
Population, area, and density answers describe the captured source
snapshot. Neighbor and border-count answers are derived from the
reviewed land-border graph. Local-name cards use the selected CLDR or
UNGEGN identity record for every country and area.
Flashcards are structured values, not an interactive game.
Args:
topic: A topic from :meth:`learning_topics`, such as ``"capitals"``.
count: How many cards to make.
continent: Use only countries on this continent.
region: Use only countries in this region or subregion.
seed: The same seed always gives the same cards.
Returns:
:class:`Flashcard` rows that print as the prompt and its answer.
Raises:
ValueError: The topic is unknown or ``count`` is too large.
Example:
>>> card = atlas.flashcards("capitals", count=1, region="Western Europe")[0]
>>> print(card)
What is the capital of Switzerland? Bern
>>> card.answer
'Bern'
"""
topic = self._learning_topic(topic)
candidates = tuple(
country
for country in self.countries(continent=continent, region=region)
if self._has_flashcard_answer(country, topic)
)
selected = stable_country_sample(candidates, count, seed)
return tuple(self._flashcard(country, topic) for country in selected)
# A place topic gives PlaceQuestion rows; any other string gives
# QuizQuestion rows. The overlap is deliberate, as in the standard open().
@overload
def quiz( # type: ignore[overload-overlap]
self,
topic: Literal["closer_to_equator", "higher", "kinds"],
*,
count: int = 5,
choices: int = 4,
continent: str | None = None,
region: str | None = None,
seed: int | str = 0,
) -> tuple[PlaceQuestion, ...]: ...
@overload
def quiz(
self,
topic: str,
*,
count: int = 5,
choices: int = 4,
continent: str | None = None,
region: str | None = None,
seed: int | str = 0,
) -> tuple[QuizQuestion, ...]: ...
[docs]
def quiz(
self,
topic: str,
*,
count: int = 5,
choices: int = 4,
continent: str | None = None,
region: str | None = None,
seed: int | str = 0,
) -> tuple[QuizQuestion, ...] | tuple[PlaceQuestion, ...]:
"""Build multiple-choice questions from sourced facts.
Args:
topic: A country topic from :meth:`learning_topics`, such as
``"capitals"`` or ``"flags"``, or a place topic from
:attr:`PLACE_QUIZ_TOPICS`: ``"higher"`` (which mountain is
higher?), ``"kinds"`` (what kind of place is it?), or
``"closer_to_equator"``.
count: How many questions to make.
choices: Answers per country question, from 2 through 6. Place
questions come with their own choices.
continent: Ask only about countries on this continent.
region: Ask only about countries in this region or subregion.
seed: The same seed always gives the same questions, choices, and
answer positions, on every supported Python version.
Returns:
:class:`QuizQuestion` rows for country topics, or
:class:`PlaceQuestion` rows for place topics. Each prints as the
question with numbered choices.
Raises:
ValueError: The topic is unknown, or the filters leave too few
distinct answers for the requested number of choices.
No scoring, learner data, or session state is stored.
Example:
>>> question = atlas.quiz("capitals", count=1, region="Western Europe")[0]
>>> print(question)
What is the capital of Switzerland? 1) Monaco 2) Bern 3) Amsterdam 4) Berlin
>>> question.answer
'Bern'
>>> print(atlas.quiz("higher", count=1, seed=7)[0])
Which is higher: Mauna Loa or Weisshorn? 1) Mauna Loa 2) Weisshorn
"""
if isinstance(topic, str) and choice(topic, FLASHCARD_TOPICS) is None:
place_topic = choice(topic, self.PLACE_QUIZ_TOPICS)
if place_topic is not None:
if continent is not None or region is not None:
raise ValueError("Place questions cover the whole world, so continent and region do not apply.")
if choices != 4:
raise ValueError("Place questions come with their own choices, so choices does not apply.")
return self._place_quiz(place_topic, count=count, seed=seed)
topic = self._learning_topic(topic, self.PLACE_QUIZ_TOPICS)
if isinstance(choices, bool) or not isinstance(choices, int):
raise TypeError("choices must be an integer")
if not 2 <= choices <= 6:
raise ValueError("choices must be between 2 and 6")
candidates = tuple(
country
for country in self.countries(continent=continent, region=region)
if self._has_flashcard_answer(country, topic)
)
selected = stable_country_sample(candidates, count, seed)
cards = tuple(self._flashcard(country, topic) for country in candidates)
distinct_answers: dict[str, str] = {}
for card in cards:
distinct_answers.setdefault(card.answer.casefold(), card.answer)
if len(distinct_answers) < choices:
raise ValueError(
f"topic {topic!r} has only {len(distinct_answers)} distinct answers "
f"for the selected filters; {choices} choices were requested"
)
questions = []
for country in selected:
card = self._flashcard(country, topic)
distractors = tuple(
answer
for normalized, answer in distinct_answers.items()
if normalized != card.answer.casefold()
)
namespace = f"quiz-distractors:{topic}:{country.alpha2}"
picked = stable_text_order(
distractors, seed=seed, namespace=namespace
)[: choices - 1]
ordered_choices = stable_text_order(
(card.answer, *picked),
seed=seed,
namespace=f"quiz-choices:{topic}:{country.alpha2}",
)
questions.append(
QuizQuestion(
topic=topic,
prompt=card.prompt,
choices=ordered_choices,
answer=card.answer,
country=card.country,
)
)
return tuple(questions)
def _has_flashcard_answer(self, country: Country, topic: str) -> bool:
return has_flashcard_answer(country, topic, self._has_neighbors)
def _has_neighbors(self, country: Country) -> bool:
country_id = self._country_id(country.alpha2)
return country_id is not None and bool(self._border_adjacency()[country_id])
def _flashcard(self, country: Country, topic: str) -> Flashcard:
return build_flashcard(
country, topic, lambda item: self.neighbors(item.alpha2)
)
[docs]
def major_cities(self, country: str, *, limit: int | None = None) -> tuple[City, ...]:
"""Return populated places for a country, ordered by population and name.
``limit=None`` returns every bundled place for the country. The records
come from the package snapshot and are not live population estimates.
Args:
country: A country name or code.
limit: The most cities to return, or ``None`` for all.
Returns:
:class:`City` records, largest first.
Example:
>>> atlas.major_cities("Japan", limit=3)
(City(name='Tokyo', country_code='JP'), City(name='Yokohama', country_code='JP'), City(name='Osaka', country_code='JP'))
"""
result = self.country(country).major_cities
if limit is None:
return result
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
raise ValueError("limit must be a positive integer or None")
return result[:limit]
[docs]
def search_cities(
self,
query: str,
*,
country: str | None = None,
limit: int = 20,
) -> tuple[City, ...]:
"""Return ranked city-name matches from the bundled place table.
Matching is case-insensitive and accent-tolerant. Exact names rank
first, followed by prefix and substring matches; population breaks
ties. Pass ``country`` as a familiar country name or code to narrow the
search. An empty result is returned when nothing matches.
Args:
query: Part of a city name.
country: Search only in this country.
limit: The most cities to return.
Returns:
:class:`City` records, best match first.
Example:
>>> atlas.search_cities("sao paulo", limit=2)
(City(name='São Paulo', country_code='BR'),)
"""
self._ensure_open()
if not isinstance(query, str):
raise TypeError("query must be a string")
normalized = normalize_name(query)
if not normalized:
raise ValueError("query must contain at least one letter or number")
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
raise ValueError("limit must be a positive integer")
params: list[object] = [f"%{normalized}%"]
where = "c.normalized_name LIKE ?"
if country is not None:
country_id = self._country_id(self.country(country).alpha2)
where += " AND c.country_id = ?"
params.append(country_id)
rows = self._db.connection.execute(
f"""SELECT c.*, co.alpha2 FROM city c
JOIN country co ON co.id = c.country_id
WHERE {where}""",
params,
).fetchall()
def rank(row: sqlite3.Row) -> tuple[object, ...]:
candidate = row["normalized_name"]
score = 100 if candidate == normalized else 80 if candidate.startswith(normalized) else 50
population = row["population"] if row["population"] is not None else -1
return (-score, -population, row["name"].casefold(), row["alpha2"], row["geonames_id"])
return tuple(self._city_from_row(row) for row in sorted(rows, key=rank)[:limit])
[docs]
def city(self, query: str, *, country: str | None = None) -> City:
"""Return one exact bundled city, optionally limited to a country.
Matching is case-insensitive and accent-tolerant. Pass a familiar
country name or code when the city name is shared. Missing names raise
:class:`PlaceNotFoundError`; ambiguous names raise
:class:`AmbiguousPlaceError` with example matches.
Args:
query: The city's name, in any case and with or without accents.
country: The country, when several cities share the name.
Returns:
The :class:`City`.
Raises:
PlaceNotFoundError: No city has this name. The message suggests
close spellings.
AmbiguousPlaceError: Several cities share the name; pass ``country``.
Example:
>>> atlas.city("Lima")
City(name='Lima', country_code='PE')
>>> atlas.city("bogota").name
'Bogotá'
"""
self._ensure_open()
normalized = normalize_name(query)
params: list[object] = [normalized]
where = "c.normalized_name=?"
if country is not None:
country_id = self._country_id(country)
if country_id is None:
raise CountryNotFoundError(f"No unambiguous country matches {country!r}")
where += " AND c.country_id=?"
params.append(country_id)
rows = self._db.connection.execute(
f"""SELECT c.*, co.alpha2 FROM city c JOIN country co ON co.id=c.country_id
WHERE {where} ORDER BY c.population DESC, c.geonames_id""", params,
).fetchall()
if not rows:
suffix = f" in {country!r}" if country else ""
hint = did_you_mean(by_prefix(
query, lambda text: [found.name for found in self.search_cities(text, country=country, limit=40)]
))
raise PlaceNotFoundError(f"No city matches {query!r}{suffix}.{hint}")
if len(rows) > 1:
options = ", ".join(f"{row['name']} ({row['alpha2']})" for row in rows[:5])
raise AmbiguousPlaceError(f"City {query!r} is ambiguous. Specify country; matches include {options}")
return self._city_from_row(rows[0])
[docs]
def coordinates(self, query: str, *, country: str | None = None) -> Coordinate:
"""Return WGS84 coordinates for an exact bundled city lookup.
This is shorthand for ``atlas.city(query, country=country).coordinates``.
Args:
query: The city's name.
country: The country, when several cities share the name.
Returns:
The city's :class:`Coordinate`.
Example:
>>> print(atlas.coordinates("Nairobi"))
Coordinate(latitude=-1.28333, longitude=36.81667)
"""
return self.city(query, country=country).coordinates
[docs]
def distance_between(
self,
first: str | Country | Capital | City | Coordinate | tuple[float, float],
second: str | Country | Capital | City | Coordinate | tuple[float, float],
*,
unit: str = "km",
first_country: str | None = None,
second_country: str | None = None,
) -> float:
"""Return great-circle distance between city, model, or coordinate inputs.
Accepted inputs are exact bundled-city names, :class:`City`,
:class:`Capital`, :class:`Country`, :class:`Coordinate`, or
``(latitude, longitude)`` tuples. Country objects use their primary
capitals. ``unit`` accepts ``"km"``, ``"mi"``, or ``"nmi"``.
The result is a surface measurement, not a road or flight route.
Args:
first: Where to measure from.
second: Where to measure to.
unit: ``"km"``, ``"mi"``, or ``"nmi"``.
first_country: The country of ``first``, when its name is shared.
second_country: The country of ``second``, when its name is shared.
Returns:
A :class:`Distance`: a number that prints with its unit.
Example:
>>> atlas.distance_between("Paris", "Tokyo")
Distance(9712.5, 'km')
>>> print(atlas.distance_between("Paris", "Tokyo", unit="mi"))
6,035 mi
"""
start = self._coordinates_of(first, country=first_country)
finish = self._coordinates_of(second, country=second_country)
return Distance(start.distance_to(finish, unit=unit), unit)
[docs]
def capitals_near(
self,
origin: str | Country | Capital | City | Coordinate | tuple[float, float],
*,
limit: int = 5,
unit: str = "km",
country: str | None = None,
include_origin: bool = False,
) -> tuple[CapitalDistance, ...]:
"""Return primary capitals nearest to a city, country, or coordinate.
String origins use the exact city lookup and may be constrained with
``country``. Pass a :class:`Country` to measure from its primary
capital or a :class:`Coordinate` for an arbitrary starting point.
``unit`` accepts ``"km"``, ``"mi"``, or ``"nmi"``. Results are ordered
nearest first with deterministic name tie-breaking.
Args:
origin: A city or place name, a country (its capital), any model
with coordinates, or a ``(latitude, longitude)`` tuple.
limit: The most capitals to return.
unit: ``"km"``, ``"mi"``, or ``"nmi"``.
country: The country of ``origin``, when its name is shared.
include_origin: Also list a capital at the origin itself.
Returns:
:class:`CapitalDistance` rows, nearest first.
Example:
>>> atlas.capitals_near("Paris", limit=2)
(CapitalDistance('Brussels, Belgium · 264 km'), CapitalDistance('Luxembourg, Luxembourg · 287 km'))
"""
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
raise ValueError("limit must be a positive integer")
start = self._coordinates_of(origin, country=country)
results: list[CapitalDistance] = []
for candidate in self.countries():
capital = candidate.capital
if capital is None:
continue
distance = start.distance_to(capital.coordinates, unit=unit)
if not include_origin and distance <= 1e-9:
continue
results.append(
CapitalDistance(candidate.reference(), capital, distance, unit)
)
results.sort(key=lambda result: (result.distance, result.country.name))
return tuple(results[:limit])
[docs]
def cities_near(
self,
origin: str | Country | Capital | City | Coordinate | tuple[float, float],
*,
limit: int = 5,
unit: str = "km",
origin_country: str | None = None,
within_country: str | None = None,
include_origin: bool = False,
) -> tuple[CityDistance, ...]:
"""Return bundled populated places nearest to an origin.
String origins are exact city names and can be disambiguated with
``origin_country``. ``within_country`` limits results to one country or
area. By default, records at the origin's exact coordinates are omitted.
Distances are great-circle surface measurements, not road distances.
Args:
origin: A city or place name, a country (its capital), any model
with coordinates, or a ``(latitude, longitude)`` tuple.
limit: The most cities to return.
unit: ``"km"``, ``"mi"``, or ``"nmi"``.
origin_country: The country of ``origin``, when its name is shared.
within_country: Return only cities in this country.
include_origin: Also list a city at the origin itself.
Returns:
:class:`CityDistance` rows, nearest first.
Example:
>>> atlas.cities_near("Reykjavik", limit=2)
(CityDistance('Tórshavn (FO) · 795 km'), CityDistance('Aberdeen (GB) · 1,321 km'))
"""
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
raise ValueError("limit must be a positive integer")
if not isinstance(include_origin, bool):
raise TypeError("include_origin must be a bool")
start = self._coordinates_of(origin, country=origin_country)
start.distance_to(start, unit=unit)
country_filter = None
if within_country is not None:
country_filter = self._country_id(self.country(within_country).alpha2)
# Rank cached raw rows first and build models only for the returned places.
# The arithmetic mirrors Coordinate.distance_to step for step, so the
# distances are identical to the public method's results.
factor = {"km": 1.0, "mi": 0.621371192237334, "nmi": 0.539956803455724}[unit]
lat1, lon1 = radians(start.latitude), radians(start.longitude)
cos_lat1 = cos(lat1)
ranked = []
for row, lat2, lon2, cos_lat2 in self._city_positions():
if country_filter is not None and row["country_id"] != country_filter:
continue
delta_lat, delta_lon = lat2 - lat1, lon2 - lon1
haversine = sin(delta_lat / 2) ** 2 + cos_lat1 * cos_lat2 * sin(delta_lon / 2) ** 2
distance = 2 * 6371.0088 * asin(min(1.0, sqrt(haversine))) * factor
if not include_origin and distance <= 1e-9:
continue
key = (distance, row["country_name"], row["name"], row["geonames_id"] or 0)
ranked.append((key, row))
return tuple(
CityDistance(
self._load_country(int(row["country_id"])).reference(),
self._city_from_row(row),
key[0],
unit,
)
for key, row in heapq.nsmallest(limit, ranked, key=lambda item: item[0])
)
# --- every place on Earth -----------------------------------------------------
PLACE_PACKS = ("places-explorer", "places-world")
"""Data packs that add places to the bundled school atlas, smallest first."""
def _places(self) -> list[sqlite3.Connection]:
"""Open the place indexes once: the school atlas plus any installed place packs."""
self._ensure_open()
if self._place_indexes is None:
sources = [Path(__file__).resolve().parent / "data" / "places.pwpl"]
for name in self.PLACE_PACKS:
folder = packs.pack_path(name, self._data_dir)
if folder is not None and (folder / "places.pwpl").is_file():
sources.append(folder / "places.pwpl")
cache = packs.data_dir(self._data_dir) / "index"
self._place_indexes = [_placefile.open_index(source, cache) for source in sources if source.is_file()]
return self._place_indexes
@staticmethod
def _place_from_row(row: sqlite3.Row) -> Place:
code = row["code"]
return Place(
row["name"], row["english"] or row["name"], kind_of(code), category_of(code), row["country"],
Coordinate(row["latitude"], row["longitude"]), row["population"], row["elevation_m"],
code, row["fame"],
)
def _place_filter(
self, kind: str | None, category: str | None, country: str | None
) -> tuple[str, list[object]]:
"""Return an SQL condition (starting with AND) and its parameters."""
where = ""
params: list[object] = []
if kind is not None:
label = " ".join(str(kind).casefold().split())
codes = sorted(code for code, name in KINDS.items() if name == label)
if not codes:
raise ValueError(f"Unknown place kind {kind!r}; see Atlas.place_kinds()")
where += f" AND code IN ({','.join('?' * len(codes))})"
params += codes
if category is not None:
letters = [letter for letter, name in CATEGORIES.items() if name == str(category).casefold()]
if not letters:
choices = ", ".join(sorted(CATEGORIES.values()))
raise ValueError(f"Unknown place category {category!r}; choose from {choices}")
where += " AND code LIKE ?"
params.append(f"{letters[0]}.%")
if country is not None:
where += " AND country = ?"
params.append(self.country(country).alpha2)
return where, params
[docs]
@staticmethod
def place_kinds() -> tuple[str, ...]:
"""Return every place kind accepted by place searches, such as ``"volcano"``.
Returns:
Kind names for the ``kind`` argument of place searches.
Example:
>>> "volcano" in atlas.place_kinds()
True
"""
return tuple(sorted(set(KINDS.values())))
[docs]
@staticmethod
def place_categories() -> tuple[str, ...]:
"""Return the general place categories, such as ``"water"`` and ``"landform"``.
Returns:
Category names for the ``category`` argument of place searches.
Example:
>>> atlas.place_categories()
('area', 'forest', 'landform', 'landmark', 'region', 'route', 'town', 'undersea', 'water')
"""
return tuple(sorted(CATEGORIES.values()))
[docs]
def search_places(
self,
query: str,
*,
kind: str | None = None,
category: str | None = None,
country: str | None = None,
limit: int = 10,
) -> tuple[Place, ...]:
"""Find places on Earth by name, best known first.
Searches the bundled school atlas (about 200,000 places: every country,
region, town of 5,000 people or more, and well-known mountain, lake, river,
island, and landmark) plus any installed place packs. Local and English
names both match, without regard to case or accents: ``"Lake Titicaca"``
finds ``"Lago Titicaca"``. Exact names come first, then names that start
with the query; better-known places break ties.
Narrow results with ``kind`` (see :meth:`place_kinds`), ``category``
(see :meth:`place_categories`), or ``country``.
Args:
query: A name or the start of one.
kind: Keep only this kind, such as ``"lake"``.
category: Keep only this category, such as ``"water"``.
country: Keep only places in this country.
limit: The most places to return.
Returns:
:class:`Place` records, best match first.
Example:
>>> atlas.search_places("Titicaca", limit=1)
(Place(english_name='Lake Titicaca', kind='lake', country_code='BO'),)
"""
if not isinstance(query, str):
raise TypeError("query must be a string")
if not normalize_name(query):
raise ValueError("query must contain at least one letter or number")
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
raise ValueError("limit must be a positive integer")
where, params = self._place_filter(kind, category, country)
ranked = []
for index in self._places():
for score, row in _placefile.find(index, query, limit, where, params):
name = row["english"] or row["name"]
order = (-score, -row["fame"], -(row["population"] or 0), name, row["country"])
ranked.append((order, row))
ranked.sort(key=lambda item: item[0])
seen: set[tuple[object, ...]] = set()
result: list[Place] = []
for _, row in ranked:
key = (row["name"], row["code"], round(row["latitude"], 2), round(row["longitude"], 2))
if key not in seen:
seen.add(key)
result.append(self._place_from_row(row))
if len(result) == limit:
break
return tuple(result)
[docs]
def place(self, query: str, *, kind: str | None = None, country: str | None = None) -> Place:
"""Return the best-known place with exactly this name.
``atlas.place("Kilimanjaro")`` is the mountain and ``atlas.place("Lake
Victoria")`` the African lake. Narrow with ``kind`` or ``country`` to pick
a less famous namesake. Raises :class:`PlaceNotFoundError` when no place
has this exact name.
Args:
query: The place's exact name.
kind: Pick a place of this kind, such as ``"mountain"``.
country: Pick a place in this country.
Returns:
The :class:`Place`.
Raises:
PlaceNotFoundError: No place has this name. The message suggests
close spellings.
Example:
>>> atlas.place("Kilimanjaro")
Place(english_name='Mount Kilimanjaro', kind='mountain', country_code='TZ')
>>> atlas.place("Kilimanjaro").elevation_m
5895
"""
key = normalize_name(query) if isinstance(query, str) else ""
for match in self.search_places(query, kind=kind, country=country, limit=10):
if key in {normalize_name(match.name), normalize_name(match.english_name)}:
return match
suffix = f" in {country!r}" if country else ""
hint = did_you_mean(by_prefix(
query,
lambda text: [
found.english_name
for found in self.search_places(text, kind=kind, country=country, limit=40)
],
)) if isinstance(query, str) else ""
raise PlaceNotFoundError(f"No place is named {query!r}{suffix}.{hint}")
[docs]
def places_in(
self,
country: str | Country,
*,
kind: str | None = None,
category: str | None = None,
limit: int = 20,
) -> tuple[Place, ...]:
"""Return the best-known places in a country from the school atlas.
Towns, mountains, lakes, landmarks, and more, best known first. Narrow
with ``kind`` (see :meth:`place_kinds`) or ``category`` (see
:meth:`place_categories`). The capital, whole regions, schools,
airports, and river points are left out unless asked for by ``kind``.
Args:
country: A country name, code, or profile.
kind: Keep only this kind, such as ``"lake"``.
category: Keep only this category, such as ``"water"``.
limit: The most places to return.
Returns:
:class:`Place` records, best known first.
Example:
>>> atlas.places_in("Peru", kind="lake", limit=2)
(Place(english_name='Lake Junín', kind='lake', country_code='PE'),)
"""
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
raise ValueError("limit must be a positive integer")
profile = country if isinstance(country, Country) else self.country(country)
where, params = self._place_filter(kind, category, None)
if kind is None:
where += " AND code NOT IN ('P.PPLC', 'S.SCH', 'S.SCHC', 'S.AIRP', 'H.STM')"
if category is None:
where += " AND substr(code, 1, 1) NOT IN ('A', 'L', 'R', 'U')"
rows = self._places()[0].execute(
f"""SELECT * FROM place WHERE country = ?{where}
ORDER BY fame DESC, population DESC, id LIMIT ?""",
(profile.alpha2, *params, limit),
).fetchall()
return tuple(self._place_from_row(row) for row in rows)
[docs]
def find(self, query: str, *, limit: int = 10) -> tuple[Country | Place, ...]:
"""Find anything by name: countries, cities, regions, and landmarks.
One search for human-entered text. A country that the query names
exactly (by name, alias, or code) comes first as a full
:class:`Country` profile; then come the best-known places from
:meth:`search_places`, where a country-level place is replaced by its
:class:`Country` profile. ``atlas.find("Luxembourg")`` gives the country
and then its capital city.
Args:
query: Any name, such as ``"Luxembourg"`` or ``"Kilimanjaro"``.
limit: The most results to return.
Returns:
:class:`Country` profiles and :class:`Place` records, best first.
Example:
>>> atlas.find("Luxembourg", limit=2)
(Country(name='Luxembourg', alpha2='LU'), Place(english_name='Luxembourg', kind='capital city', country_code='LU'))
"""
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
raise ValueError("limit must be a positive integer")
found: list[Country | Place] = []
countries: set[str] = set()
exact = self.get(query)
if exact is not None:
found.append(exact)
countries.add(exact.alpha2)
for place in self.search_places(query, limit=limit + 2):
if place.feature_code in _COUNTRY_CODES:
country = self.get(place.country_code) if place.country_code else None
if country is not None:
if country.alpha2 not in countries:
countries.add(country.alpha2)
found.append(country)
continue
found.append(place)
return tuple(found[:limit])
[docs]
def places_near(
self,
origin: str | Country | Capital | City | Place | Coordinate | tuple[float, float],
*,
limit: int = 10,
kind: str | None = None,
category: str | None = None,
unit: str = "km",
include_origin: bool = False,
origin_country: str | None = None,
) -> tuple[PlaceDistance, ...]:
"""Return the places nearest to an origin, nearest first.
The origin can be a city name, any place or country model, a
:class:`Coordinate`, or a ``(latitude, longitude)`` tuple. Filter with
``kind`` or ``category`` (``category="water"`` finds the nearest lakes
and rivers). Distances are great-circle surface measurements in
``"km"``, ``"mi"``, or ``"nmi"``.
Args:
origin: A city or place name, a country (its capital), any model
with coordinates, or a ``(latitude, longitude)`` tuple.
limit: The most places to return.
kind: Keep only this kind, such as ``"volcano"``.
category: Keep only this category, such as ``"water"``.
unit: ``"km"``, ``"mi"``, or ``"nmi"``.
include_origin: Also list places named like the origin, at the origin.
origin_country: The country of ``origin``, when its name is shared.
Returns:
:class:`PlaceDistance` rows, nearest first.
Example:
>>> atlas.places_near("Reykjavik", kind="volcano", limit=2)
(PlaceDistance('Sundhnúkur, volcano (IS) · 38 km'), PlaceDistance('Lágafell, volcano (IS) · 42 km'))
"""
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
raise ValueError("limit must be a positive integer")
if unit not in {"km", "mi", "nmi"}:
raise ValueError('unit must be "km", "mi", or "nmi"')
start = self._coordinates_of(origin, country=origin_country)
where, params = self._place_filter(kind, category, None)
factor = {"km": 1.0, "mi": 0.621371192237334, "nmi": 0.539956803455724}[unit]
# A named origin such as "Tokyo" sits on top of places with the same
# name (Tokyo the city and Tokyo the prefecture), so ask for a few
# extra and leave out every same-named place within 10 km.
origin_name = normalize_name(origin if isinstance(origin, str) else getattr(origin, "name", "") or "")
found = _placefile.near(self._places(), start.latitude, start.longitude, limit + 6, where, params)
result: list[PlaceDistance] = []
for distance, row in found:
if not include_origin and (
distance <= 1e-6
or (origin_name and distance <= 10 and origin_name in {
normalize_name(row["name"]), normalize_name(row["english"] or "")
})
):
continue
result.append(PlaceDistance(self._place_from_row(row), distance * factor, unit))
if len(result) == limit:
break
return tuple(result)
def _city_positions(self) -> list[tuple[sqlite3.Row, float, float, float]]:
"""Load every populated place once, with radians precomputed for distances."""
if self._city_cache is None:
self._ensure_open()
rows = self._db.connection.execute(
"""SELECT c.*, co.alpha2, co.name AS country_name FROM city c
JOIN country co ON co.id = c.country_id"""
).fetchall()
self._city_cache = []
for row in rows:
latitude = radians(row["latitude"])
self._city_cache.append(
(row, latitude, radians(row["longitude"]), cos(latitude))
)
return self._city_cache
def _statistics_pack(self) -> Statistics:
"""Load the statistics pack once, or explain how to install it."""
self._ensure_open()
if self._statistics is None:
from ._statistics import FILE, Statistics
folder = packs.pack_path("statistics", self._data_dir)
if folder is None or not (folder / FILE).is_file():
raise PackNotInstalledError(
"Statistics need the statistics pack. Run: pyworldatlas get statistics"
)
self._statistics = Statistics(folder / FILE)
return self._statistics
[docs]
def indicators(self) -> tuple[Indicator, ...]:
"""Return every statistic in the statistics pack, such as ``life_expectancy``.
Needs the statistics pack (``pyworldatlas get statistics``).
Returns:
Every :class:`Indicator`, with its unit and source.
Raises:
PackNotInstalledError: The statistics pack is not installed.
Example:
>>> len(atlas.indicators())
11
>>> atlas.indicators()[0]
Indicator(key='birth_rate', unit='per 1,000 people a year')
"""
return tuple(self._statistics_pack().indicators.values())
def _indicator(self, key: str) -> Indicator:
indicators = self._statistics_pack().indicators
name = choice(str(key), indicators)
if name is None:
hint = did_you_mean(closest(str(key), indicators))
raise ValueError(f"Unknown indicator {key!r}.{hint} Choose from {', '.join(indicators)}.")
found = indicators[name]
return found
[docs]
def time_series(self, country: str | Country, indicator: str) -> StatisticSeries:
"""Return the yearly values of an indicator for a country, from 1960.
``country`` is a country name, code, or profile, or ``"world"`` for the
World Bank's world total. Years without a published value are ``None``.
Needs the statistics pack (``pyworldatlas get statistics``).
Args:
country: A country name, code, or profile, or ``"world"``.
indicator: A key from :meth:`indicators`, such as ``"life_expectancy"``.
Returns:
A :class:`StatisticSeries` with a ``summary`` sentence.
Raises:
ValueError: The indicator is unknown.
PackNotInstalledError: The statistics pack is not installed.
Example:
>>> print(atlas.time_series("Kenya", "electricity_access").summary)
Access to electricity in Kenya rose from 10.9% of people in 1993 to 77.0% of people in 2024.
"""
found = self._indicator(indicator)
if isinstance(country, str) and country.strip().casefold() == "world":
area, area_name = "world", "the world"
else:
profile = country if isinstance(country, Country) else self.country(country)
area, area_name = profile.alpha2, profile.name
first, values = self._statistics_pack().series[found.key].get(area, (0, ()))
return StatisticSeries(found, area, area_name, first, values)
[docs]
def statistic(self, country: str | Country, indicator: str, year: int | None = None) -> StatisticValue | None:
"""Return one published value, the latest unless ``year`` is given.
Returns ``None`` when the publisher has no value; missing values are
never estimated. Needs the statistics pack.
Args:
country: A country name, code, or profile, or ``"world"``.
indicator: A key from :meth:`indicators`.
year: The year to read, or ``None`` for the latest.
Returns:
A :class:`StatisticValue`, or ``None``.
Example:
>>> print(atlas.statistic("Brazil", "urban_population", 1960))
44.7% of people (1960)
"""
series = self.time_series(country, indicator)
if year is None:
return series.latest
value = series.value(year)
return None if value is None else StatisticValue(series.indicator, series.area, year, value)
[docs]
def sort_by_statistic(
self, indicator: str, *, year: int | None = None, descending: bool = True, limit: int | None = 10
) -> tuple[StatisticValue, ...]:
"""List countries sorted by one indicator, for a year or each one's latest.
Sorting only orders the published numbers; it says nothing about which
is better. Countries without a value are left out. Needs the
statistics pack. :meth:`rank` gives the same order as numbered rows
with country names; prefer it in new code.
Args:
indicator: A key from :meth:`indicators`.
year: The year to compare, or ``None`` for each country's latest.
descending: ``False`` sorts smallest first.
limit: The most rows to return, or ``None`` for all.
Returns:
:class:`StatisticValue` rows, sorted.
Example:
>>> atlas.sort_by_statistic("forest_area", limit=2)
(StatisticValue(indicator='forest_area', area='FM', year=2024, value=92.1), StatisticValue(indicator='forest_area', area='SR', year=2024, value=91.5))
"""
found = self._indicator(indicator)
if limit is not None and (isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0):
raise ValueError("limit must be a positive integer or None")
rows: list[StatisticValue] = []
for area, (first, values) in self._statistics_pack().series[found.key].items():
if area == "world":
continue
series = StatisticSeries(found, area, area, first, values)
item = series.latest if year is None else (
None if series.value(year) is None else StatisticValue(found, area, year, series.value(year) or 0.0)
)
if item is not None:
rows.append(item)
rows.sort(key=lambda item: (-item.value if descending else item.value, item.area))
return tuple(rows if limit is None else rows[:limit])
[docs]
def world_map(self, indicator: str, *, year: int | None = None) -> WorldMap:
"""Return a world map with every country colored by one indicator.
Each country shows its value for ``year``, or its latest value when
``year`` is ``None``. Countries fall into five classes with about the
same number of countries each; the colors order the numbers and say
nothing about which is better. Call ``show()`` to open the map,
``write_html()`` or ``write_svg()`` to save it, or display it in a
notebook. Needs the statistics pack and a map pack
(``pyworldatlas get statistics`` and ``pyworldatlas get maps-lite``).
Args:
indicator: A key from :meth:`indicators`.
year: The year to show, or ``None`` for each country's latest.
Returns:
A :class:`~pyworldatlas.maps.WorldMap`.
Example:
>>> print(atlas.world_map("life_expectancy", year=2020))
Life expectancy at birth (years), in 2020
"""
if year is not None and (isinstance(year, bool) or not isinstance(year, int)):
raise TypeError("year must be an integer or None")
from .exceptions import MapSupportNotInstalledError
from .maps import MapDataError, WorldMap
from .maps._world import outline_database
found = self._indicator(indicator)
try:
database = outline_database(self._data_dir)
except MapDataError as error:
raise MapSupportNotInstalledError(str(error)) from error
names = sorted(((country.alpha2, country.name) for country in self), key=lambda pair: pair[1].casefold())
order = {alpha2: index for index, (alpha2, _) in enumerate(names)}
values = sorted(
(item for item in self.sort_by_statistic(found.key, year=year, limit=None) if item.area in order),
key=lambda item: order[item.area],
)
return WorldMap(found, tuple(values), tuple(names), year, str(database))
def _landmark_pack(self) -> Landmarks:
"""Load the landmarks pack once, or explain how to install it."""
self._ensure_open()
if self._landmarks is None:
from ._landmarks import FILE, Landmarks
folder = packs.pack_path("landmarks", self._data_dir)
if folder is None or not (folder / FILE).is_file():
raise PackNotInstalledError(
"Airports and World Heritage sites need the landmarks pack. "
"Run: pyworldatlas get landmarks"
)
self._landmarks = Landmarks(folder / FILE)
return self._landmarks
[docs]
def airport(self, code: str) -> Airport:
"""Return the airport with this IATA (``"LHR"``) or ICAO (``"EGLL"``) code.
Needs the landmarks pack (``pyworldatlas get landmarks``). Raises
:class:`PlaceNotFoundError` for an unknown code.
Args:
code: A three-letter IATA or four-letter ICAO code.
Returns:
The :class:`Airport`.
Example:
>>> atlas.airport("LHR")
Airport(iata_code='LHR', name='London Heathrow Airport')
"""
if not isinstance(code, str):
raise TypeError("code must be a string")
found = self._landmark_pack().by_code.get(code.strip().upper())
if found is None:
raise PlaceNotFoundError(f"No airport with scheduled flights has the code {code!r}")
return found
[docs]
def airports(
self, country: str | Country | None = None, *, size: str | None = None
) -> tuple[Airport, ...]:
"""Return airports with scheduled flights, largest first, then by name.
Narrow with ``country`` and ``size`` (``"large"``, ``"medium"``, or
``"small"``). Needs the landmarks pack (``pyworldatlas get landmarks``).
Args:
country: Keep only airports in this country.
size: Keep only ``"large"``, ``"medium"``, or ``"small"`` airports.
Returns:
:class:`Airport` records.
Example:
>>> atlas.airports("Iceland", size="medium")[:2]
(Airport(iata_code='EGS', name='Egilsstaðir Airport'), Airport(iata_code='GRY', name='Grímsey Airport'))
"""
if size is not None and size not in {"large", "medium", "small"}:
raise ValueError("size must be 'large', 'medium', or 'small'")
found = self._landmark_pack().airports
if country is not None:
code = (country if isinstance(country, Country) else self.country(country)).alpha2
found = tuple(airport for airport in found if airport.country_code == code)
if size is not None:
found = tuple(airport for airport in found if airport.size == size)
order = {"large": 0, "medium": 1, "small": 2}
return tuple(sorted(found, key=lambda airport: (order[airport.size], airport.name.casefold())))
[docs]
def world_heritage_sites(self, *, category: str | None = None) -> tuple[WorldHeritageSite, ...]:
"""Return UNESCO World Heritage sites in list order.
Narrow with ``category`` (``"cultural"``, ``"natural"``, or ``"mixed"``).
Use :meth:`heritage_sites_near` to find the sites around a place.
Needs the landmarks pack (``pyworldatlas get landmarks``).
Args:
category: Keep only ``"cultural"``, ``"natural"``, or ``"mixed"`` sites.
Returns:
:class:`WorldHeritageSite` records.
Example:
>>> atlas.world_heritage_sites(category="mixed")[0]
WorldHeritageSite(site_id=39, name='Ngorongoro Conservation Area')
"""
if category is not None and category not in {"cultural", "natural", "mixed"}:
raise ValueError("category must be 'cultural', 'natural', or 'mixed'")
found = self._landmark_pack().world_heritage
if category is not None:
found = tuple(site for site in found if site.category == category)
return found
[docs]
def heritage_sites_near(
self,
origin: str | Country | Capital | City | Place | Coordinate | tuple[float, float],
*,
limit: int = 10,
category: str | None = None,
unit: str = "km",
) -> tuple[SiteDistance, ...]:
"""Return the World Heritage sites nearest to a place, nearest first.
``origin`` is a city or place name, a country (its capital), a model
with coordinates, or a ``(latitude, longitude)`` tuple. ``unit`` is
``"km"`` or ``"mi"``. Needs the landmarks pack.
Args:
origin: A city or place name, a country (its capital), any model
with coordinates, or a ``(latitude, longitude)`` tuple.
limit: The most sites to return.
category: Keep only ``"cultural"``, ``"natural"``, or ``"mixed"`` sites.
unit: ``"km"`` or ``"mi"``.
Returns:
:class:`SiteDistance` rows, nearest first.
Example:
>>> atlas.heritage_sites_near("Cusco", limit=1)
(SiteDistance('Historic centre of Cusco, cultural · 1.9 km'),)
"""
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
raise ValueError("limit must be a positive integer")
if unit not in {"km", "mi"}:
raise ValueError("unit must be 'km' or 'mi'")
point = self._point_of(origin)
scale = 1.0 if unit == "km" else 1 / 1.609344
ranked = sorted(
(
(_placefile.distance_km(point.latitude, point.longitude,
site.coordinates.latitude, site.coordinates.longitude), site.site_id, site)
for site in self.world_heritage_sites(category=category)
if site.coordinates is not None
),
key=lambda item: (item[0], item[1]),
)
return tuple(SiteDistance(site, round(km * scale, 3), unit) for km, _, site in ranked[:limit])
[docs]
def world_heritage_site(self, query: str | int) -> WorldHeritageSite:
"""Return one World Heritage site by list number or name.
Names match without regard to case or accents: an exact name first,
then the one site whose name contains the query. Raises
:class:`PlaceNotFoundError` or :class:`AmbiguousPlaceError`.
Args:
query: The site's list number, or its name or part of it.
Returns:
The :class:`WorldHeritageSite`.
Example:
>>> atlas.world_heritage_site("Machu Picchu")
WorldHeritageSite(site_id=274, name='Historic Sanctuary of Machu Picchu')
"""
sites = self._landmark_pack().world_heritage
if isinstance(query, int) and not isinstance(query, bool):
for site in sites:
if site.site_id == query:
return site
raise PlaceNotFoundError(f"No World Heritage site has the list number {query}")
if not isinstance(query, str):
raise TypeError("query must be a name or a list number")
key = normalize_name(query)
if not key:
raise ValueError("query must contain at least one letter or number")
exact = [site for site in sites if normalize_name(site.name) == key]
matches = exact or [site for site in sites if key in normalize_name(site.name)]
if not matches:
raise PlaceNotFoundError(f"No World Heritage site is named {query!r}")
if len(matches) > 1:
names = "; ".join(site.name for site in matches[:5])
raise AmbiguousPlaceError(f"{query!r} matches several World Heritage sites: {names}")
return matches[0]
[docs]
def sun(
self,
where: str | Country | Capital | City | Place | Coordinate | tuple[float, float],
on: date | None = None,
) -> SunTimes:
"""Return sunrise, sunset, day length, and the noon sun height for a place.
``where`` is a city or place name, a country (its capital), a model with
coordinates, or a ``(latitude, longitude)`` tuple. ``on`` defaults to
today. Times are in UTC, and also on the local clock when the place is a
city, capital, or country and the computer has time zone data (see
:class:`SunTimes`). They agree with almanac times to about a minute
between latitudes 72° north and south. Compare ``atlas.sun("Oslo",
date(2026, 6, 21))`` with December to see why seasons happen.
Args:
where: A city or place name, a country (its capital), any model
with coordinates, or a ``(latitude, longitude)`` tuple.
on: The day, or ``None`` for today.
Returns:
:class:`SunTimes` that print as one sentence.
Example:
>>> from datetime import date
>>> print(atlas.sun((59.91, 10.75), date(2026, 6, 21))) # Oslo
On 21 June 2026 the sun rises at 01:53 UTC and sets at 20:43 UTC, giving 18 h 50 min of daylight. At noon it is 54° above the horizon.
"""
point = self._point_of(where)
zone = self._timezone_of(where)
day = on if on is not None else date.today()
if not isinstance(day, date):
raise TypeError("on must be a datetime.date")
if isinstance(day, datetime):
day = day.date()
noon, sunrise, sunset, hours, height = sun_times(point.latitude, point.longitude, day)
return SunTimes(point, day, noon, sunrise, sunset, round(hours, 4), round(height, 2), zone)
def _timezone_of(self, where: object) -> str | None:
"""Return the time zone of a country's capital, a capital, or a city, when known."""
if isinstance(where, Country):
return where.capital.timezone_id if where.capital else None
if isinstance(where, (Capital, City)):
return where.timezone_id
if isinstance(where, str):
try:
return self.city(where).timezone_id
except (PlaceNotFoundError, AmbiguousPlaceError):
pass
try:
return self._timezone_of(self.country(where))
except (CountryNotFoundError, AmbiguousCountryError, ValueError):
return None # a place, not a city or country: places carry no time zone
return None
PLACE_QUIZ_TOPICS = ("closer_to_equator", "higher", "kinds")
"""Topics about famous places that :meth:`quiz` accepts besides :meth:`learning_topics`."""
_QUIZ_KINDS = (
"atoll", "bay", "cape", "desert", "glacier", "gulf", "island", "lake", "mountain",
"mountain range", "ocean", "peninsula", "plateau", "river", "sea", "strait", "valley",
"volcano", "waterfall",
)
def _place_quiz(self, topic: str, *, count: int = 5, seed: int | str = 0) -> tuple[PlaceQuestion, ...]:
"""Return multiple-choice questions about famous natural places.
Topics are ``"kinds"`` (what kind of place is it?), ``"higher"`` (which
of two mountains is higher?), and ``"closer_to_equator"`` (which of two
places is closer to the equator?). Questions come from the best-known
mountains (3,000 m and higher), islands, lakes, rivers, and seas in the
school atlas, and the same ``seed`` always gives the same questions.
"""
found_topic = choice(topic, self.PLACE_QUIZ_TOPICS) if isinstance(topic, str) else None
if found_topic is None:
hint = did_you_mean(closest(str(topic), self.PLACE_QUIZ_TOPICS))
raise ValueError(f"Unknown topic {topic!r}.{hint} Choose from {', '.join(self.PLACE_QUIZ_TOPICS)}.")
topic = found_topic
if isinstance(count, bool) or not isinstance(count, int) or not 1 <= count <= 50:
raise ValueError("count must be an integer from 1 through 50")
from hashlib import sha256
# The best-known natural places, with a plain English name.
rows = self._places()[0].execute(
"SELECT * FROM place WHERE substr(code, 1, 1) IN ('T', 'H', 'V') ORDER BY fame DESC, id LIMIT 300"
).fetchall()
pool = [
place for place in map(self._place_from_row, rows)
if place.english_name.isascii() and "," not in place.english_name
# Many small hills carry machine-copied names in dozens of
# languages, which inflates their fame; keep only real mountains.
and not (place.kind in {"mountain", "volcano", "peak", "hill", "hills"} and (place.elevation_m or 0) < 3000)
]
def shuffled(items: list[Place], salt: str) -> list[Place]:
def rank(place: Place) -> bytes:
key = f"pyworldatlas:place-quiz:{topic}:{seed}:{salt}:{place.english_name}:{place.coordinates}"
return sha256(key.encode()).digest()
return sorted(items, key=rank)
questions: list[PlaceQuestion] = []
if topic == "kinds":
kinds = [place for place in pool if place.kind in self._QUIZ_KINDS]
for place in shuffled(kinds, "pick")[:count]:
others = [kind for kind in self._QUIZ_KINDS if kind != place.kind]
wrong = stable_text_order(tuple(others), seed=seed, namespace=f"place-kinds:{place.english_name}")[:3]
choices = stable_text_order((place.kind, *wrong), seed=seed, namespace=f"place-order:{place.english_name}")
questions.append(PlaceQuestion(topic, f"What kind of place is {place.english_name}?", choices, place.kind, (place,)))
else:
if topic == "higher":
candidates = [p for p in pool if p.kind in {"mountain", "volcano", "peak"} and p.elevation_m]
else: # compact places only: an ocean or a mountain range has no single latitude
compact = {"mountain", "volcano", "peak", "island", "atoll", "lake", "waterfall"}
candidates = [p for p in pool if p.kind in compact]
order = shuffled(candidates, "pick")
for first, second in zip(order[0::2], order[1::2], strict=False):
if len(questions) == count:
break
if first.english_name == second.english_name:
continue
if topic == "higher":
assert first.elevation_m is not None and second.elevation_m is not None
if first.elevation_m == second.elevation_m:
continue
winner = first if first.elevation_m > second.elevation_m else second
prompt = f"Which is higher: {first.english_name} or {second.english_name}?"
else:
a, b = abs(first.coordinates.latitude), abs(second.coordinates.latitude)
if abs(a - b) < 1:
continue
winner = first if a < b else second
prompt = f"Which is closer to the equator: {first.english_name} or {second.english_name}?"
questions.append(PlaceQuestion(
topic, prompt, (first.english_name, second.english_name), winner.english_name, (first, second)
))
if len(questions) < count:
raise ValueError(f"only {len(questions)} {topic!r} questions are available")
return tuple(questions)
[docs]
def antipode(
self,
where: str | Country | Capital | City | Place | Coordinate | tuple[float, float],
*,
limit: int = 3,
) -> Antipode:
"""Return the point on the opposite side of the Earth, and the places nearest it.
Dig straight down through the centre of the Earth and you come out at
the antipode: the latitude flips north and south, and the longitude
moves halfway around the world. ``where`` is a city or place name, a
country (its capital), a model with coordinates, or a ``(latitude,
longitude)`` tuple. Most antipodes are at sea. ``nearest`` lists
well-known places within 500 km of the antipode first, nearest first.
Args:
where: A city or place name, a country (its capital), any model
with coordinates, or a ``(latitude, longitude)`` tuple.
limit: How many nearby places to list.
Returns:
An :class:`Antipode` with the opposite point and the places nearest it.
Example:
>>> print(atlas.antipode("Madrid"))
Straight through the centre of the Earth from 40.42° N, 3.70° W you would come out at 40.42° S, 176.30° E. The nearest well-known place is Waipatiki, town (NZ), 4.9 km away.
"""
point = self._point_of(where)
longitude = point.longitude + 180 if point.longitude <= 0 else point.longitude - 180
opposite = Coordinate(round(-point.latitude, 6), round(longitude, 6))
# The very nearest named point is often an unknown hill or stream, so
# well-known places within 500 km come first, then the rest by distance.
nearby = self.places_near(opposite, limit=max(limit, 40))
known = [item for item in nearby if item.place.fame > 0 and item.distance <= 500]
unknown = [item for item in nearby if item not in known]
return Antipode(point, opposite, tuple((known + unknown)[:limit]))
def _point_of(
self, where: str | Country | Capital | City | Place | Coordinate | tuple[float, float]
) -> Coordinate:
"""Resolve a place-like value; a name that is not one city is looked up as a place."""
if isinstance(where, str):
try:
return self._coordinates_of(where, country=None)
except (PlaceNotFoundError, AmbiguousPlaceError):
return self.place(where).coordinates
return self._coordinates_of(where, country=None)
def _coordinates_of(
self,
value: str | Country | Capital | City | Place | Coordinate | tuple[float, float],
*,
country: str | None,
) -> Coordinate:
if isinstance(value, Coordinate):
return value
if isinstance(value, (Capital, City, Place)):
return value.coordinates
if isinstance(value, Country):
if value.capital_coordinates is None:
raise CapitalNotFoundError(f"{value.name} has no primary-capital coordinates")
return value.capital_coordinates
if isinstance(value, str):
return self.coordinates(value, country=country)
if isinstance(value, tuple) and len(value) == 2:
return Coordinate(float(value[0]), float(value[1]))
raise TypeError("place must be a city name, country or place model, Coordinate, or (latitude, longitude) tuple")
@staticmethod
def _city_from_row(row: sqlite3.Row) -> City:
return city_from_row(row)
def _load_countries(self, country_ids: Iterable[int]) -> tuple[Country, ...]:
"""Return several country models, reading missing ones in bulk."""
ids = tuple(country_ids)
missing = [item for item in ids if item not in self._country_cache]
if len(missing) > 1:
self._bulk_rows = {}
try:
for item in missing:
self._load_country(item)
finally:
self._bulk_rows = None
return tuple(self._load_country(item) for item in ids)
def _child_rows(self, sql: str, country_id: int) -> list[sqlite3.Row]:
"""Run one per-country query, or serve it from a bulk read of all countries."""
if self._bulk_rows is None:
return self._db.connection.execute(sql, (country_id,)).fetchall()
grouped = self._bulk_rows.get(sql)
if grouped is None:
grouped = self._bulk_rows[sql] = {}
bulk_sql = sql.replace("country_id=?", "country_id IS NOT NULL")
for row in self._db.connection.execute(bulk_sql):
grouped.setdefault(int(row["country_id"]), []).append(row)
return grouped.get(country_id, [])
def _load_country(self, country_id: int) -> Country:
"""Return one immutable country model, reading it at most once per atlas."""
country = self._country_cache.get(country_id)
if country is None:
country = self._country_cache[country_id] = self._read_country(country_id)
return country
def _read_country(self, country_id: int) -> Country:
row = self._db.connection.execute("SELECT * FROM country WHERE id=?", (country_id,)).fetchone()
return build_country(
row,
lambda sql: self._child_rows(sql, country_id),
self._load_local_names().get(country_id, ()),
)
def _load_local_names(self) -> dict[int, tuple[LocalizedName, ...]]:
"""Load local names once so collection iteration never creates an N+1 query."""
if self._local_names_cache is None:
self._local_names_cache = self._read_local_names()
return self._local_names_cache
def _read_local_names(self) -> dict[int, tuple[LocalizedName, ...]]:
return read_local_names(self._db.connection)
[docs]
def __getitem__(self, query: str) -> Country:
return self.country(query)
[docs]
def __contains__(self, query: object) -> bool:
return isinstance(query, str) and self._country_id(query) is not None
[docs]
def __len__(self) -> int:
self._ensure_open()
return int(self._db.connection.execute("SELECT count(*) FROM country").fetchone()[0])
[docs]
def __iter__(self) -> Iterator[Country]:
return iter(self.countries())
[docs]
def help(self) -> None:
"""Print a short tour: about a dozen things to try, grouped by topic.
Every line is a real call you can copy. ``pyworldatlas tour`` prints
the same tour on the command line.
Example:
>>> atlas.help() # doctest: +ELLIPSIS
PyWorldAtlas: things to try
...
"""
from ._tour import TOUR
print(TOUR)
# Older names. They keep working until 1.0 so nobody's code breaks, but
# the docs teach only the new names above.
[docs]
def nearest_capitals(self, *args: Any, **kwargs: Any) -> tuple[CapitalDistance, ...]:
"""Older name for :meth:`capitals_near`, kept until 1.0."""
return self.capitals_near(*args, **kwargs)
[docs]
def nearest_cities(self, *args: Any, **kwargs: Any) -> tuple[CityDistance, ...]:
"""Older name for :meth:`cities_near`, kept until 1.0."""
return self.cities_near(*args, **kwargs)
[docs]
def world_heritage_sites_near(self, *args: Any, **kwargs: Any) -> tuple[SiteDistance, ...]:
"""Older name for :meth:`heritage_sites_near`, kept until 1.0."""
return self.heritage_sites_near(*args, **kwargs)
[docs]
def lookup(self, query: str, *, limit: int = 10) -> tuple[Country | Place, ...]:
"""Older name for :meth:`find`, kept until 1.0."""
return self.find(query, limit=limit)
[docs]
def place_quiz(self, topic: str, *, count: int = 5, seed: int | str = 0) -> tuple[PlaceQuestion, ...]:
"""Older name for ``quiz(topic)`` with a place topic, kept until 1.0."""
found = choice(topic, self.PLACE_QUIZ_TOPICS) if isinstance(topic, str) else None
if found is None:
hint = did_you_mean(closest(str(topic), self.PLACE_QUIZ_TOPICS))
raise ValueError(f"Unknown topic {topic!r}.{hint} Choose from {', '.join(self.PLACE_QUIZ_TOPICS)}.")
return self._place_quiz(found, count=count, seed=seed)
[docs]
def close(self) -> None:
"""Close the read-only database connection.
Calling ``close()`` more than once is safe. Queries on a closed atlas
raise :class:`AtlasClosedError`; models returned earlier remain usable.
Example:
>>> other = Atlas()
>>> other.close()
>>> other.close() # closing twice is safe
"""
if not self._closed:
self._db.close()
self._closed = True
self._country_cache.clear()
self._local_names_cache = None
self._border_cache = None
self._query_cache.clear()
self._code_cache = None
self._city_cache = None
for index in self._place_indexes or ():
index.close()
self._place_indexes = None
[docs]
def __enter__(self) -> Atlas:
return self
[docs]
def __exit__(self, *args: object) -> None:
self.close()