From 08748f5ca80df267bcd5c486d62a96888280cf37 Mon Sep 17 00:00:00 2001 From: Daniel Huppmann Date: Tue, 3 Sep 2024 07:52:03 +0200 Subject: [PATCH] Add a `countries` attribute to the region codelist (#374) --- docs/api/countries.rst | 4 ++++ nomenclature/code.py | 21 ++++++++++++++++--- nomenclature/countries.py | 4 ++++ .../general-config-only/nomenclature.yaml | 1 - tests/data/general-config/nomenclature.yaml | 1 - .../region.yaml | 7 +++++++ .../norway_as_bool/region.yaml} | 0 .../region_codelist/{ => simple}/region.yaml | 0 tests/test_codelist.py | 18 ++++++++++++++-- tests/test_countries.py | 4 ++++ tests/test_definition.py | 6 ------ 11 files changed, 53 insertions(+), 13 deletions(-) create mode 100644 tests/data/region_codelist/countries_attribute_non-existing_name/region.yaml rename tests/data/{norway_as_bool/regions.yaml => region_codelist/norway_as_bool/region.yaml} (100%) rename tests/data/region_codelist/{ => simple}/region.yaml (100%) diff --git a/docs/api/countries.rst b/docs/api/countries.rst index d60359f1..1c2b6fc8 100644 --- a/docs/api/countries.rst +++ b/docs/api/countries.rst @@ -33,6 +33,10 @@ and alpha-3 or alpha-2 codes, as shown in this example. from nomenclature import countries + # list of country names + countries.names + + # mappings between ISO3 (alpha_3), alpha_2 and country names name = countries.get(alpha_3="...").name alpha_3 = countries.get(name="...").alpha_3 alpha_2 = countries.get(name="...").alpha_2 diff --git a/nomenclature/code.py b/nomenclature/code.py index 55ce3f00..097b6443 100644 --- a/nomenclature/code.py +++ b/nomenclature/code.py @@ -2,7 +2,7 @@ import re from keyword import iskeyword from pathlib import Path -from typing import Any, Dict, List, Set, Union +from typing import Any, Dict, List, Set, Union, Optional from pydantic import ( field_validator, field_serializer, @@ -233,13 +233,28 @@ class RegionCode(Code): Name of the RegionCode hierarchy : str Hierarchy of the RegionCode - iso3_codes : str or list of str + countries : list of str, optional + List of countries in that region + iso3_codes : str or list of str, optional ISO3 codes of countries in that region """ hierarchy: str = None - iso3_codes: Union[List[str], str] = None + countries: Optional[List[str]] = None + iso3_codes: Optional[Union[List[str], str]] = None + + @field_validator("countries") + def check_countries(cls, v: List[str], info: ValidationInfo) -> List[str]: + """Verifies that each country name is defined in `nomenclature.countries`.""" + if invalid_country_names := set(v) - set(countries.names): + raise ValueError( + f"Region '{info.data['name']}' uses non-standard country name(s): " + + ", ".join(invalid_country_names) + + "\nPlease use `nomenclature.countries` for consistency. (https://" + + "nomenclature-iamc.readthedocs.io/en/stable/api/countries.html)" + ) + return v @field_validator("iso3_codes") def check_iso3_codes(cls, v: List[str], info: ValidationInfo) -> List[str]: diff --git a/nomenclature/countries.py b/nomenclature/countries.py index d7bfba81..7a191c5d 100644 --- a/nomenclature/countries.py +++ b/nomenclature/countries.py @@ -108,6 +108,10 @@ def get(self, **kwargs): return country + @property + def names(self): + return [country.name for country in self.objects] + # Initialize `countries` for direct access via API and in codelist module countries = Countries() diff --git a/tests/data/general-config-only/nomenclature.yaml b/tests/data/general-config-only/nomenclature.yaml index 1c7e59e9..eb390c76 100644 --- a/tests/data/general-config-only/nomenclature.yaml +++ b/tests/data/general-config-only/nomenclature.yaml @@ -4,6 +4,5 @@ repositories: definitions: region: repository: common-definitions - country: true variable: repository: common-definitions diff --git a/tests/data/general-config/nomenclature.yaml b/tests/data/general-config/nomenclature.yaml index 644e304d..fe35a156 100644 --- a/tests/data/general-config/nomenclature.yaml +++ b/tests/data/general-config/nomenclature.yaml @@ -5,6 +5,5 @@ repositories: definitions: region: repository: common-definitions - country: true variable: repository: common-definitions diff --git a/tests/data/region_codelist/countries_attribute_non-existing_name/region.yaml b/tests/data/region_codelist/countries_attribute_non-existing_name/region.yaml new file mode 100644 index 00000000..15f969ed --- /dev/null +++ b/tests/data/region_codelist/countries_attribute_non-existing_name/region.yaml @@ -0,0 +1,7 @@ +- common: + - World +- countries: + - Some region: + # "Czech Republic" this was renamed to "Czechia" in ISO 3166-1 in 2023 + countries: [ Austria, Germany, Czech Republic ] + diff --git a/tests/data/norway_as_bool/regions.yaml b/tests/data/region_codelist/norway_as_bool/region.yaml similarity index 100% rename from tests/data/norway_as_bool/regions.yaml rename to tests/data/region_codelist/norway_as_bool/region.yaml diff --git a/tests/data/region_codelist/region.yaml b/tests/data/region_codelist/simple/region.yaml similarity index 100% rename from tests/data/region_codelist/region.yaml rename to tests/data/region_codelist/simple/region.yaml diff --git a/tests/test_codelist.py b/tests/test_codelist.py index 22b99dda..2d292fd7 100644 --- a/tests/test_codelist.py +++ b/tests/test_codelist.py @@ -147,7 +147,9 @@ def test_tags_in_list_attributes(): def test_region_codelist(): """Check replacing top-level hierarchy of yaml file as attribute for regions""" - code = RegionCodeList.from_directory("region", TEST_DATA_DIR / "region_codelist") + code = RegionCodeList.from_directory( + "region", TEST_DATA_DIR / "region_codelist" / "simple" + ) assert "World" in code assert code["World"].hierarchy == "common" @@ -157,9 +159,21 @@ def test_region_codelist(): assert code["Some Country"].iso2 == "XY" +def test_region_codelist_nonexisting_country_name(): + """Check that countries are validated against `nomenclature.countries`""" + with pytest.raises(ValueError, match="Region 'Some region' .*: Czech Republic"): + RegionCodeList.from_directory( + "region", + TEST_DATA_DIR / "region_codelist" / "countries_attribute_non-existing_name", + ) + + def test_norway_as_str(): """guard against casting of 'NO' to boolean `False` by PyYAML or pydantic""" - region = RegionCodeList.from_directory("region", TEST_DATA_DIR / "norway_as_bool") + region = RegionCodeList.from_directory( + "region", + TEST_DATA_DIR / "region_codelist" / "norway_as_bool", + ) assert region["Norway"].eu_member is False assert region["Norway"].iso2 == "NO" diff --git a/tests/test_countries.py b/tests/test_countries.py index eaff3e21..7d62e0b7 100644 --- a/tests/test_countries.py +++ b/tests/test_countries.py @@ -14,6 +14,8 @@ def test_countries_override(nc_name, iso_name, alpha_3): assert countries.get(name=iso_name).alpha_3 == "BOL" assert countries.get(alpha_3=alpha_3).name == nc_name + assert "Bolivia" in countries.names + def test_countries_add(): """Check that countries added to ISO 3166 can be found""" @@ -23,6 +25,8 @@ def test_countries_add(): with pytest.raises(AttributeError): countries.get(name="Kosovo").alpha_2 + assert "Kosovo" in countries.names + @pytest.mark.parametrize("alpha_2_eu, alpha_2", [("EL", "GR"), ("UK", "GB")]) def test_alternative_alpha2(alpha_2_eu, alpha_2): diff --git a/tests/test_definition.py b/tests/test_definition.py index 5db19860..0f1d8cc1 100644 --- a/tests/test_definition.py +++ b/tests/test_definition.py @@ -51,12 +51,6 @@ def test_definition_from_general_config(workflow_folder): assert "Region A" in obs.region # imported from https://github.com/IAMconsortium/common-definitions repo assert "World" in obs.region - # added via general-config definitions - assert "Austria" in obs.region - # added via general-config definitions renamed from pycountry name - assert "Bolivia" in obs.region - # added via general-config definitions in addition to pycountry.countries - assert "Kosovo" in obs.region # imported from https://github.com/IAMconsortium/common-definitions repo assert "Primary Energy" in obs.variable