#!/usr/bin/env python3 """Build registry/countries.yaml from the World Bank country list + mledoze/countries. World Bank : region, income group, capital, lat/long, WB aggregates (excluded here, listed in groups.yaml). mledoze : ISO numeric, official name, subregion, currency, area, UN membership, flag, borders, languages, demonym. Hand overrides survive regeneration: edit registry/countries.overrides.yaml (keyed by iso3). Usage: python3 scripts/build_country_registry.py [--offline] (offline = reuse cached /tmp files) """ from __future__ import annotations import json import re import sys import unicodedata import urllib.request from pathlib import Path import yaml ROOT = Path(__file__).resolve().parents[1] OUT = ROOT / "registry" / "countries.yaml" OVERRIDES = ROOT / "registry" / "countries.overrides.yaml" CACHE = Path("/tmp/countryatlas-registry") CACHE.mkdir(parents=True, exist_ok=True) WB_URL = "https://api.worldbank.org/v2/country?format=json&per_page=400" MLEDOZE_URL = "https://raw.githubusercontent.com/mledoze/countries/master/countries.json" CONTINENT_BY_REGION = { "Africa": "Africa", "Americas": "Americas", "Asia": "Asia", "Europe": "Europe", "Oceania": "Oceania", "Antarctic": "Antarctica", } # Short display names preferred over the WB/mledoze defaults SHORT_NAME_OVERRIDES = { "USA": "United States", "GBR": "United Kingdom", "RUS": "Russia", "KOR": "South Korea", "PRK": "North Korea", "IRN": "Iran", "SYR": "Syria", "VEN": "Venezuela", "BOL": "Bolivia", "TZA": "Tanzania", "LAO": "Laos", "VNM": "Vietnam", "EGY": "Egypt", "YEM": "Yemen", "GMB": "Gambia", "BHS": "Bahamas", "COD": "DR Congo", "COG": "Republic of the Congo", "CIV": "Côte d'Ivoire", "CZE": "Czechia", "SVK": "Slovakia", "MKD": "North Macedonia", "MDA": "Moldova", "KGZ": "Kyrgyzstan", "BRN": "Brunei", "FSM": "Micronesia", "STP": "São Tomé and Príncipe", "TUR": "Türkiye", "HKG": "Hong Kong", "MAC": "Macao", "PSE": "Palestine", "TWN": "Taiwan", "CPV": "Cabo Verde", "SWZ": "Eswatini", "TLS": "Timor-Leste", "VIR": "U.S. Virgin Islands", "VGB": "British Virgin Islands", "SXM": "Sint Maarten", "MAF": "Saint Martin", "CUW": "Curaçao", "XKX": "Kosovo", } def fetch(url: str, name: str, offline: bool) -> bytes: p = CACHE / name if offline and p.exists(): return p.read_bytes() req = urllib.request.Request(url, headers={"User-Agent": "CountryAtlas registry builder (contact@countryatlas.co)"}) with urllib.request.urlopen(req, timeout=60) as r: data = r.read() p.write_bytes(data) return data def slugify(name: str) -> str: s = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode() s = s.lower().replace("&", "and").replace("'", "") s = re.sub(r"[^a-z0-9]+", "-", s).strip("-") return s def main() -> None: offline = "--offline" in sys.argv wb = json.loads(fetch(WB_URL, "wb-countries.json", offline))[1] ml = json.loads(fetch(MLEDOZE_URL, "mledoze.json", offline)) ml_by3 = {c["cca3"]: c for c in ml} overrides = yaml.safe_load(OVERRIDES.read_text()) if OVERRIDES.exists() else {} overrides = overrides or {} countries: list[dict] = [] seen = set() for c in wb: if c["region"]["id"] == "NA" or not c["region"]["id"]: continue # aggregate iso3 = c["id"] m = ml_by3.get(iso3) if iso3 == "XKX": # Kosovo: mledoze uses UNK m = ml_by3.get("UNK") if iso3 == "CHI": # Channel Islands (WB pseudo-country) — keep as territory using Jersey/Guernsey info m = None name = SHORT_NAME_OVERRIDES.get(iso3) or (m["name"]["common"] if m else c["name"]) cur_code, cur_name = None, None if m and m.get("currencies"): cur_code = sorted(m["currencies"].keys())[0] cur_name = m["currencies"][cur_code].get("name") region_ml = m["region"] if m else None entry = { "id": iso3, "iso2": c["iso2Code"], "iso3": iso3, "iso_numeric": (m or {}).get("ccn3") or None, "slug": slugify(name), "short_name": name, "official_name": (m["name"]["official"] if m else c["name"]), "capital": (m["capital"][0] if m and m.get("capital") else c.get("capitalCity") or None), "continent": CONTINENT_BY_REGION.get(region_ml or "", None), "region_wb": c["region"]["id"], "region_wb_name": c["region"]["value"].strip(), "subregion": (m or {}).get("subregion") or None, "income_group": c["incomeLevel"]["id"] if c["incomeLevel"]["id"] not in ("", "INX") else None, "income_group_name": c["incomeLevel"]["value"] if c["incomeLevel"]["id"] not in ("", "INX") else None, "currency_code": cur_code, "currency_name": cur_name, "area_km2": (m or {}).get("area"), "latitude": float(c["latitude"]) if c.get("latitude") else ((m or {}).get("latlng") or [None, None])[0], "longitude": float(c["longitude"]) if c.get("longitude") else ((m or {}).get("latlng") or [None, None])[1], "flag_emoji": (m or {}).get("flag") or "".join(chr(0x1F1E6 + ord(ch) - 65) for ch in c["iso2Code"] if ch.isalpha()), "un_member": bool((m or {}).get("unMember", False)), "independent": bool((m or {}).get("independent", False)), "landlocked": bool((m or {}).get("landlocked", False)), "borders": (m or {}).get("borders") or [], "languages": sorted(((m or {}).get("languages") or {}).values()), "demonym": (((m or {}).get("demonyms") or {}).get("eng") or {}).get("m") or None, "status": "country" if (m and m.get("independent")) else "territory", "kind": "country", } # WB lists some territories with income levels; status from independence entry.update(overrides.get(iso3, {})) if entry["slug"] in seen: raise SystemExit(f"duplicate slug {entry['slug']} ({iso3})") seen.add(entry["slug"]) countries.append(entry) # Taiwan is not in the WB list — add from mledoze so the registry is complete (data will be sparse). if "TWN" not in {c["id"] for c in countries} and "TWN" in ml_by3: m = ml_by3["TWN"] countries.append({ "id": "TWN", "iso2": "TW", "iso3": "TWN", "iso_numeric": m.get("ccn3"), "slug": "taiwan", "short_name": "Taiwan", "official_name": m["name"]["official"], "capital": m["capital"][0], "continent": "Asia", "region_wb": "EAS", "region_wb_name": "East Asia & Pacific", "subregion": m.get("subregion"), "income_group": "HIC", "income_group_name": "High income", "currency_code": "TWD", "currency_name": "New Taiwan dollar", "area_km2": m.get("area"), "latitude": m["latlng"][0], "longitude": m["latlng"][1], "flag_emoji": m.get("flag"), "un_member": False, "independent": False, "landlocked": False, "borders": [], "languages": sorted(m["languages"].values()), "demonym": "Taiwanese", "status": "territory", "kind": "country", **overrides.get("TWN", {}), }) countries.sort(key=lambda x: x["short_name"]) header = ( "# Canonical CountryAtlas country registry — GENERATED by scripts/build_country_registry.py\n" "# Sources: World Bank country API (region, income, capital, coordinates) + mledoze/countries (ISO numeric, official name,\n" "# subregion, currency, area, UN membership, flag, borders, languages). Edit registry/countries.overrides.yaml, not this file.\n" ) OUT.write_text(header + yaml.safe_dump({"countries": countries}, allow_unicode=True, sort_keys=False, width=120)) n_country = sum(1 for c in countries if c["status"] == "country") print(f"wrote {OUT} — {len(countries)} entries ({n_country} independent countries, {len(countries)-n_country} territories)") if __name__ == "__main__": main()