From f6d3271187eb0a55ea08db6cfd8298286f0805f2 Mon Sep 17 00:00:00 2001 From: Hazel Noack Date: Thu, 12 Jun 2025 12:57:10 +0200 Subject: [PATCH 01/16] feat: added proper way to create country with fallback --- pycountry_wrapper/__init__.py | 124 ++-------------------------- pycountry_wrapper/config.py | 8 ++ pycountry_wrapper/country.py | 151 ++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 117 deletions(-) create mode 100644 pycountry_wrapper/config.py create mode 100644 pycountry_wrapper/country.py diff --git a/pycountry_wrapper/__init__.py b/pycountry_wrapper/__init__.py index 793fa56..257425a 100644 --- a/pycountry_wrapper/__init__.py +++ b/pycountry_wrapper/__init__.py @@ -1,118 +1,8 @@ -from __future__ import annotations -from typing import Optional -from functools import wraps -import pycountry +from .country import Country, StrictCountry +from . import config - -def none_if_empty(func): - @wraps(func) - def wrapper(self, *args, **kwargs): - if self.is_empty: - return None - - return func(self, *args, **kwargs) - - return wrapper - - -class Country: - """ - This gets countries based on the ISO 3166-1 standart. - - Two examples are: - - Country.from_alpha_2("DE") - - Country.from_alpha_3("DEU") - - If the country couldn't be found, it raises a ValueError, or creates an empty object. - Empty objects return for every attribute None - """ - - def __init__(self, country: Optional[str] = None, pycountry_object = None, allow_empty: bool = True) -> None: - if country is not None: - # auto detect if alpha_2 or alpha_3 - if len(country) == 2: - pycountry_object = pycountry.countries.get(alpha_2=country.upper()) - elif len(country) == 3: - pycountry_object = pycountry.countries.get(alpha_3=country.upper()) - - if pycountry_object is None and not allow_empty: - raise ValueError(f"Country {country} couldn't be found") - - self.pycountry_object = pycountry_object - - @classmethod - def from_alpha_2(cls, alpha_2: str) -> Country: - return cls(pycountry_object=pycountry.countries.get(alpha_2=alpha_2.upper())) - - @classmethod - def from_alpha_3(cls, alpha_3: str) -> Country: - return cls(pycountry_object=pycountry.countries.get(alpha_3=alpha_3.upper())) - - @classmethod - def from_fuzzy(cls, fuzzy: str) -> Country: - return cls(pycountry_object=pycountry.countries.search_fuzzy(fuzzy)) - - @property - def is_empty(self) -> bool: - return self.pycountry_object is None - - @property - @none_if_empty - def name(self) -> Optional[str]: - return self.pycountry_object.name - - @property - @none_if_empty - def alpha_2(self) -> Optional[str]: - return self.pycountry_object.alpha_2 - - @property - @none_if_empty - def alpha_3(self) -> Optional[str]: - return self.pycountry_object.alpha_3 - - @property - @none_if_empty - def numeric(self) -> Optional[str]: - return self.pycountry_object.numeric - - @property - @none_if_empty - def official_name(self) -> Optional[str]: - return self.pycountry_object.official_name - - def __str__(self) -> str: - return self.pycountry_object.__str__() - - def __repr__(self) -> str: - return self.pycountry_object.__repr__() - - -class StrictCountry(Country): - """ - This works just like Country, - but the object cant be empty - """ - - def __init__(self, country: Optional[str] = None, pycountry_object = None) -> None: - super().__init__(country=country, pycountry_object=pycountry_object, allow_empty=False) - - @property - def name(self) -> str: - return self.pycountry_object.name - - @property - def alpha_2(self) -> str: - return self.pycountry_object.alpha_2 - - @property - def alpha_3(self) -> str: - return self.pycountry_object.alpha_3 - - @property - def numeric(self) -> str: - return self.pycountry_object.numeric - - @property - def official_name(self) -> str: - return self.pycountry_object.official_name +__all__ = [ + "Country", + "StrictCountry", + "config", +] diff --git a/pycountry_wrapper/config.py b/pycountry_wrapper/config.py new file mode 100644 index 0000000..91ced91 --- /dev/null +++ b/pycountry_wrapper/config.py @@ -0,0 +1,8 @@ +import typing as _t + +# defines the fallback country if a country can't be found +# alpha_2 or alpha_3 of ISO 3166-1 +fallback_country: _t.Optional[str] = None + +# should use fuzzy search if it cant find the country with alpha_2 or alpha_3 +allow_fuzzy_search: bool = True diff --git a/pycountry_wrapper/country.py b/pycountry_wrapper/country.py new file mode 100644 index 0000000..845ddd7 --- /dev/null +++ b/pycountry_wrapper/country.py @@ -0,0 +1,151 @@ +from __future__ import annotations +from typing import Optional +from functools import wraps +import pycountry + +from . import config + + +def none_if_empty(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + if self.is_empty: + return None + + return func(self, *args, **kwargs) + + return wrapper + + +class Country: + """ + This gets countries based on the ISO 3166-1 standart. + + Two examples are: + - Country.from_alpha_2("DE") + - Country.from_alpha_3("DEU") + + If the country couldn't be found, it raises a ValueError, or creates an empty object. + Empty objects return for every attribute None + """ + + def __init__(self, country: Optional[str] = None, pycountry_object = None) -> None: + self.pycountry_object = pycountry_object + + if self.pycountry_object is None: + # search for the country string instead if the pycountry_object isn't given + # this also implements the optional fallback + self.pycountry_object = self._search_pycountry_object(country=country) + + + if pycountry_object is None: + raise ValueError(f"Country {country} couldn't be found") + + + @classmethod + def _search_pycountry_object(cls, country: Optional[str], is_fallback: bool = False): + # fallback to configured country if necessary + if country is None: + if is_fallback: + return None + + return cls._search_pycountry_object(country=config.fallback_country, is_fallback=True) + + pycountry_object = None + + # the reason I don't immediately return the result is because then there would be a chance + # I would return None even though a country could be found through fuzzy search + country = country.strip() + if len(country) == 2: + pycountry_object = pycountry.countries.get(alpha_2=country.upper()) + elif len(country) == 3: + pycountry_object = pycountry.countries.get(alpha_3=country.upper()) + if pycountry_object is not None: + return cls(pycountry_object=pycountry_object) + + # fuzzy search if enabled + if config.allow_fuzzy_search: + found_countries = pycountry.countries.search_fuzzy(country) + if len(found_countries): + return cls(pycountry_object=found_countries[0]) + + @classmethod + def search(cls, country: Optional[str]) -> Optional[Country]: + return cls(pycountry_object=cls._search_pycountry_object(country=country)) + + @classmethod + def from_alpha_2(cls, alpha_2: str) -> Country: + return cls(pycountry_object=pycountry.countries.get(alpha_2=alpha_2.upper())) + + @classmethod + def from_alpha_3(cls, alpha_3: str) -> Country: + return cls(pycountry_object=pycountry.countries.get(alpha_3=alpha_3.upper())) + + @classmethod + def from_fuzzy(cls, fuzzy: str) -> Country: + return cls(pycountry_object=pycountry.countries.search_fuzzy(fuzzy)) + + @property + def is_empty(self) -> bool: + return self.pycountry_object is None + + @property + @none_if_empty + def name(self) -> Optional[str]: + return self.pycountry_object.name + + @property + @none_if_empty + def alpha_2(self) -> Optional[str]: + return self.pycountry_object.alpha_2 + + @property + @none_if_empty + def alpha_3(self) -> Optional[str]: + return self.pycountry_object.alpha_3 + + @property + @none_if_empty + def numeric(self) -> Optional[str]: + return self.pycountry_object.numeric + + @property + @none_if_empty + def official_name(self) -> Optional[str]: + return self.pycountry_object.official_name + + def __str__(self) -> str: + return self.pycountry_object.__str__() + + def __repr__(self) -> str: + return self.pycountry_object.__repr__() + + +class StrictCountry(Country): + """ + This works just like Country, + but the object cant be empty + """ + + def __init__(self, country: Optional[str] = None, pycountry_object = None) -> None: + super().__init__(country=country, pycountry_object=pycountry_object, allow_empty=False) + + @property + def name(self) -> str: + return self.pycountry_object.name + + @property + def alpha_2(self) -> str: + return self.pycountry_object.alpha_2 + + @property + def alpha_3(self) -> str: + return self.pycountry_object.alpha_3 + + @property + def numeric(self) -> str: + return self.pycountry_object.numeric + + @property + def official_name(self) -> str: + return self.pycountry_object.official_name -- 2.47.2 From b10cdd1201d40e0042f0251cdb4b82a8ff55fa80 Mon Sep 17 00:00:00 2001 From: Hazel Noack Date: Thu, 12 Jun 2025 13:01:22 +0200 Subject: [PATCH 02/16] feat: implemented empty country exception --- pycountry_wrapper/__init__.py | 3 ++- pycountry_wrapper/country.py | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/pycountry_wrapper/__init__.py b/pycountry_wrapper/__init__.py index 257425a..b11f108 100644 --- a/pycountry_wrapper/__init__.py +++ b/pycountry_wrapper/__init__.py @@ -1,8 +1,9 @@ -from .country import Country, StrictCountry +from .country import Country, StrictCountry, EmptyCountryException from . import config __all__ = [ "Country", "StrictCountry", "config", + "EmptyCountryException", ] diff --git a/pycountry_wrapper/country.py b/pycountry_wrapper/country.py index 845ddd7..e0ced19 100644 --- a/pycountry_wrapper/country.py +++ b/pycountry_wrapper/country.py @@ -17,6 +17,10 @@ def none_if_empty(func): return wrapper +class EmptyCountryException(ValueError): + pass + + class Country: """ This gets countries based on the ISO 3166-1 standart. @@ -39,7 +43,7 @@ class Country: if pycountry_object is None: - raise ValueError(f"Country {country} couldn't be found") + raise EmptyCountryException(f"the country {country} was not found and config.fallback_country isn't set") @classmethod @@ -85,13 +89,9 @@ class Country: def from_fuzzy(cls, fuzzy: str) -> Country: return cls(pycountry_object=pycountry.countries.search_fuzzy(fuzzy)) - @property - def is_empty(self) -> bool: - return self.pycountry_object is None - @property @none_if_empty - def name(self) -> Optional[str]: + def name(self) -> str: return self.pycountry_object.name @property -- 2.47.2 From 27a04b0e2e6b7e49279cfd1af207e781ac865060 Mon Sep 17 00:00:00 2001 From: Hazel Noack Date: Thu, 12 Jun 2025 13:15:56 +0200 Subject: [PATCH 03/16] feat: removed none type hints --- pycountry_wrapper/country.py | 35 +++++++++-------------------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/pycountry_wrapper/country.py b/pycountry_wrapper/country.py index e0ced19..a337fd0 100644 --- a/pycountry_wrapper/country.py +++ b/pycountry_wrapper/country.py @@ -6,17 +6,6 @@ import pycountry from . import config -def none_if_empty(func): - @wraps(func) - def wrapper(self, *args, **kwargs): - if self.is_empty: - return None - - return func(self, *args, **kwargs) - - return wrapper - - class EmptyCountryException(ValueError): pass @@ -34,17 +23,16 @@ class Country: """ def __init__(self, country: Optional[str] = None, pycountry_object = None) -> None: - self.pycountry_object = pycountry_object - - if self.pycountry_object is None: + if pycountry_object is None: # search for the country string instead if the pycountry_object isn't given # this also implements the optional fallback - self.pycountry_object = self._search_pycountry_object(country=country) + pycountry_object = self._search_pycountry_object(country=country) - if pycountry_object is None: raise EmptyCountryException(f"the country {country} was not found and config.fallback_country isn't set") - + + self.pycountry_object = pycountry_object + @classmethod def _search_pycountry_object(cls, country: Optional[str], is_fallback: bool = False): @@ -90,28 +78,23 @@ class Country: return cls(pycountry_object=pycountry.countries.search_fuzzy(fuzzy)) @property - @none_if_empty def name(self) -> str: return self.pycountry_object.name @property - @none_if_empty - def alpha_2(self) -> Optional[str]: + def alpha_2(self) -> str: return self.pycountry_object.alpha_2 @property - @none_if_empty - def alpha_3(self) -> Optional[str]: + def alpha_3(self) -> str: return self.pycountry_object.alpha_3 @property - @none_if_empty - def numeric(self) -> Optional[str]: + def numeric(self) -> str: return self.pycountry_object.numeric @property - @none_if_empty - def official_name(self) -> Optional[str]: + def official_name(self) -> str: return self.pycountry_object.official_name def __str__(self) -> str: -- 2.47.2 From de2ab0b8c057c3ea7586f2906d31947ddb7aaea6 Mon Sep 17 00:00:00 2001 From: Hazel Noack Date: Thu, 12 Jun 2025 13:57:08 +0200 Subject: [PATCH 04/16] feat: fixed type hints --- pycountry_wrapper/country.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pycountry_wrapper/country.py b/pycountry_wrapper/country.py index a337fd0..65ca73b 100644 --- a/pycountry_wrapper/country.py +++ b/pycountry_wrapper/country.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import Optional from functools import wraps import pycountry +import pycountry.db from . import config @@ -22,7 +23,7 @@ class Country: Empty objects return for every attribute None """ - def __init__(self, country: Optional[str] = None, pycountry_object = None) -> None: + def __init__(self, country: Optional[str] = None, pycountry_object: Optional[pycountry.db.Country] = None) -> None: if pycountry_object is None: # search for the country string instead if the pycountry_object isn't given # this also implements the optional fallback @@ -31,11 +32,11 @@ class Country: if pycountry_object is None: raise EmptyCountryException(f"the country {country} was not found and config.fallback_country isn't set") - self.pycountry_object = pycountry_object + self.pycountry_object: pycountry.db.Country = pycountry_object @classmethod - def _search_pycountry_object(cls, country: Optional[str], is_fallback: bool = False): + def _search_pycountry_object(cls, country: Optional[str], is_fallback: bool = False) -> Optional[pycountry.db.Country]: # fallback to configured country if necessary if country is None: if is_fallback: -- 2.47.2 From a9a6e78b892224ae1c6ac2dcd3e1bef4b92e5694 Mon Sep 17 00:00:00 2001 From: Hazel Noack Date: Thu, 12 Jun 2025 14:10:49 +0200 Subject: [PATCH 05/16] feat: layed out empty country class --- pycountry_wrapper/__init__.py | 4 ++-- pycountry_wrapper/country.py | 43 ++++++++++++----------------------- 2 files changed, 17 insertions(+), 30 deletions(-) diff --git a/pycountry_wrapper/__init__.py b/pycountry_wrapper/__init__.py index b11f108..fbd10bf 100644 --- a/pycountry_wrapper/__init__.py +++ b/pycountry_wrapper/__init__.py @@ -1,9 +1,9 @@ -from .country import Country, StrictCountry, EmptyCountryException +from .country import Country, EmptyCountry, EmptyCountryException from . import config __all__ = [ "Country", - "StrictCountry", + "EmptyCountry", "config", "EmptyCountryException", ] diff --git a/pycountry_wrapper/country.py b/pycountry_wrapper/country.py index 65ca73b..b4474c6 100644 --- a/pycountry_wrapper/country.py +++ b/pycountry_wrapper/country.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Optional +from typing import Optional, Union from functools import wraps import pycountry import pycountry.db @@ -54,13 +54,13 @@ class Country: elif len(country) == 3: pycountry_object = pycountry.countries.get(alpha_3=country.upper()) if pycountry_object is not None: - return cls(pycountry_object=pycountry_object) + return cls(pycountry_object=pycountry_object) # type: ignore # fuzzy search if enabled if config.allow_fuzzy_search: found_countries = pycountry.countries.search_fuzzy(country) if len(found_countries): - return cls(pycountry_object=found_countries[0]) + return cls(pycountry_object=found_countries[0]) # type: ignore @classmethod def search(cls, country: Optional[str]) -> Optional[Country]: @@ -76,7 +76,7 @@ class Country: @classmethod def from_fuzzy(cls, fuzzy: str) -> Country: - return cls(pycountry_object=pycountry.countries.search_fuzzy(fuzzy)) + return cls(pycountry_object=pycountry.countries.search_fuzzy(fuzzy)) # type: ignore @property def name(self) -> str: @@ -105,31 +105,18 @@ class Country: return self.pycountry_object.__repr__() -class StrictCountry(Country): +class EmptyCountry(Country): """ - This works just like Country, - but the object cant be empty + This will be used if you don't want to use a fallback country but you still want to be able to not have None + You can access the same attributes but they will just return None """ + def __init__(self) -> None: + pass - def __init__(self, country: Optional[str] = None, pycountry_object = None) -> None: - super().__init__(country=country, pycountry_object=pycountry_object, allow_empty=False) + @classmethod + def search(cls, country: Optional[str]) -> Union[Country, EmptyCountry]: + result = super().search(country) - @property - def name(self) -> str: - return self.pycountry_object.name - - @property - def alpha_2(self) -> str: - return self.pycountry_object.alpha_2 - - @property - def alpha_3(self) -> str: - return self.pycountry_object.alpha_3 - - @property - def numeric(self) -> str: - return self.pycountry_object.numeric - - @property - def official_name(self) -> str: - return self.pycountry_object.official_name + if result is None: + return EmptyCountry() + return result -- 2.47.2 From 65b0c2b8e682f568b976362f1e11ab7ac9b5a507 Mon Sep 17 00:00:00 2001 From: Hazel Noack Date: Thu, 12 Jun 2025 14:37:38 +0200 Subject: [PATCH 06/16] feat: playing with new --- pycountry_wrapper/__main__.py | 4 ++-- pycountry_wrapper/country.py | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/pycountry_wrapper/__main__.py b/pycountry_wrapper/__main__.py index 6ed4f72..4b537e4 100644 --- a/pycountry_wrapper/__main__.py +++ b/pycountry_wrapper/__main__.py @@ -1,8 +1,8 @@ from .__about__ import __name__, __version__ -from . import Country +from . import Country, EmptyCountry def cli(): print(f"Running {__name__} version {__version__} from __main__.py") - print(Country(country="DE").name) \ No newline at end of file + print(EmptyCountry(country="doesn't exist").name) \ No newline at end of file diff --git a/pycountry_wrapper/country.py b/pycountry_wrapper/country.py index b4474c6..6762500 100644 --- a/pycountry_wrapper/country.py +++ b/pycountry_wrapper/country.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Optional, Union from functools import wraps +from typing_extensions import Self import pycountry import pycountry.db @@ -22,7 +23,6 @@ class Country: If the country couldn't be found, it raises a ValueError, or creates an empty object. Empty objects return for every attribute None """ - def __init__(self, country: Optional[str] = None, pycountry_object: Optional[pycountry.db.Country] = None) -> None: if pycountry_object is None: # search for the country string instead if the pycountry_object isn't given @@ -54,13 +54,13 @@ class Country: elif len(country) == 3: pycountry_object = pycountry.countries.get(alpha_3=country.upper()) if pycountry_object is not None: - return cls(pycountry_object=pycountry_object) # type: ignore + return pycountry_object # fuzzy search if enabled if config.allow_fuzzy_search: found_countries = pycountry.countries.search_fuzzy(country) if len(found_countries): - return cls(pycountry_object=found_countries[0]) # type: ignore + return found_countries[0] @classmethod def search(cls, country: Optional[str]) -> Optional[Country]: @@ -110,8 +110,10 @@ class EmptyCountry(Country): This will be used if you don't want to use a fallback country but you still want to be able to not have None You can access the same attributes but they will just return None """ - def __init__(self) -> None: - pass + def __new__(cls, country: Optional[str] = None, pycountry_object: Optional[pycountry.db.Country] = None): + print("new", country, pycountry_object) + return super().__new__(cls) + @classmethod def search(cls, country: Optional[str]) -> Union[Country, EmptyCountry]: -- 2.47.2 From af3fff85593c48b2992380dc121c124e14983aa1 Mon Sep 17 00:00:00 2001 From: Hazel Noack Date: Thu, 12 Jun 2025 14:40:23 +0200 Subject: [PATCH 07/16] fix: lookup error for fuzzy search --- pycountry_wrapper/__main__.py | 2 +- pycountry_wrapper/country.py | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/pycountry_wrapper/__main__.py b/pycountry_wrapper/__main__.py index 4b537e4..de19c1a 100644 --- a/pycountry_wrapper/__main__.py +++ b/pycountry_wrapper/__main__.py @@ -5,4 +5,4 @@ from . import Country, EmptyCountry def cli(): print(f"Running {__name__} version {__version__} from __main__.py") - print(EmptyCountry(country="doesn't exist").name) \ No newline at end of file + print(EmptyCountry(country="zwx").name) \ No newline at end of file diff --git a/pycountry_wrapper/country.py b/pycountry_wrapper/country.py index 6762500..ec40e09 100644 --- a/pycountry_wrapper/country.py +++ b/pycountry_wrapper/country.py @@ -58,9 +58,14 @@ class Country: # fuzzy search if enabled if config.allow_fuzzy_search: - found_countries = pycountry.countries.search_fuzzy(country) - if len(found_countries): - return found_countries[0] + # fuzzy search raises lookup error if nothing was found + try: + found_countries = pycountry.countries.search_fuzzy(country) + if len(found_countries): + return found_countries[0] + except LookupError: + pass + @classmethod def search(cls, country: Optional[str]) -> Optional[Country]: -- 2.47.2 From 5b724077e67ac2e97d7adcdef037873a2dabb17d Mon Sep 17 00:00:00 2001 From: Hazel Noack Date: Thu, 12 Jun 2025 14:53:57 +0200 Subject: [PATCH 08/16] feat: implemented empty country --- pycountry_wrapper/__main__.py | 12 +++++++++++- pycountry_wrapper/country.py | 24 ++++++++++++++++-------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/pycountry_wrapper/__main__.py b/pycountry_wrapper/__main__.py index de19c1a..a723c72 100644 --- a/pycountry_wrapper/__main__.py +++ b/pycountry_wrapper/__main__.py @@ -1,8 +1,18 @@ from .__about__ import __name__, __version__ from . import Country, EmptyCountry +import pycountry + def cli(): print(f"Running {__name__} version {__version__} from __main__.py") + t = pycountry.countries.get(alpha_2="DE") - print(EmptyCountry(country="zwx").name) \ No newline at end of file + country = EmptyCountry(pycountry_object=t) + print(type(country)) + print(country) + + print() + empty_country = EmptyCountry(country="zwx") + print(type(empty_country)) + print(empty_country) \ No newline at end of file diff --git a/pycountry_wrapper/country.py b/pycountry_wrapper/country.py index ec40e09..0588e5a 100644 --- a/pycountry_wrapper/country.py +++ b/pycountry_wrapper/country.py @@ -116,14 +116,22 @@ class EmptyCountry(Country): You can access the same attributes but they will just return None """ def __new__(cls, country: Optional[str] = None, pycountry_object: Optional[pycountry.db.Country] = None): - print("new", country, pycountry_object) - return super().__new__(cls) + try: + return Country(country=country, pycountry_object=pycountry_object) + except EmptyCountryException: + return super().__new__(cls) + + def __init__(self, *args, **kwargs) -> None: + pass + name = None # type: ignore + alpha_2 = None # type: ignore + alpha_3 = None # type: ignore + numeric = None # type: ignore - @classmethod - def search(cls, country: Optional[str]) -> Union[Country, EmptyCountry]: - result = super().search(country) + def __str__(self) -> str: + return "EmptyCountry()" + + def __repr__(self) -> str: + return "EmptyCountry()" - if result is None: - return EmptyCountry() - return result -- 2.47.2 From 78d60c990825fd3809a999019da3d98118fe61b1 Mon Sep 17 00:00:00 2001 From: Hazel Noack Date: Thu, 12 Jun 2025 15:09:06 +0200 Subject: [PATCH 09/16] fix: fallback only worked if not searching --- pycountry_wrapper/__main__.py | 5 +++-- pycountry_wrapper/country.py | 21 +++++++++++++-------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/pycountry_wrapper/__main__.py b/pycountry_wrapper/__main__.py index a723c72..b01091c 100644 --- a/pycountry_wrapper/__main__.py +++ b/pycountry_wrapper/__main__.py @@ -1,11 +1,12 @@ from .__about__ import __name__, __version__ -from . import Country, EmptyCountry +from . import Country, EmptyCountry, config import pycountry def cli(): print(f"Running {__name__} version {__version__} from __main__.py") + config.fallback_country = "US" t = pycountry.countries.get(alpha_2="DE") country = EmptyCountry(pycountry_object=t) @@ -15,4 +16,4 @@ def cli(): print() empty_country = EmptyCountry(country="zwx") print(type(empty_country)) - print(empty_country) \ No newline at end of file + print(empty_country) diff --git a/pycountry_wrapper/country.py b/pycountry_wrapper/country.py index 0588e5a..7cd3939 100644 --- a/pycountry_wrapper/country.py +++ b/pycountry_wrapper/country.py @@ -23,11 +23,16 @@ class Country: If the country couldn't be found, it raises a ValueError, or creates an empty object. Empty objects return for every attribute None """ - def __init__(self, country: Optional[str] = None, pycountry_object: Optional[pycountry.db.Country] = None) -> None: + def __init__( + self, + country: Optional[str] = None, + pycountry_object: Optional[pycountry.db.Country] = None, + force_disable_fallback: bool = False + ) -> None: if pycountry_object is None: # search for the country string instead if the pycountry_object isn't given # this also implements the optional fallback - pycountry_object = self._search_pycountry_object(country=country) + pycountry_object = self._search_pycountry_object(country=country, force_disable_fallback=force_disable_fallback) if pycountry_object is None: raise EmptyCountryException(f"the country {country} was not found and config.fallback_country isn't set") @@ -36,13 +41,13 @@ class Country: @classmethod - def _search_pycountry_object(cls, country: Optional[str], is_fallback: bool = False) -> Optional[pycountry.db.Country]: + def _search_pycountry_object(cls, country: Optional[str], is_fallback: bool = False, force_disable_fallback: bool = False) -> Optional[pycountry.db.Country]: # fallback to configured country if necessary - if country is None: - if is_fallback: + if country is None and not force_disable_fallback: + if force_disable_fallback or config.fallback_country is None: return None - return cls._search_pycountry_object(country=config.fallback_country, is_fallback=True) + country = config.fallback_country pycountry_object = None @@ -115,9 +120,9 @@ class EmptyCountry(Country): This will be used if you don't want to use a fallback country but you still want to be able to not have None You can access the same attributes but they will just return None """ - def __new__(cls, country: Optional[str] = None, pycountry_object: Optional[pycountry.db.Country] = None): + def __new__(cls, country: Optional[str] = None, pycountry_object: Optional[pycountry.db.Country] = None, **kwargs): try: - return Country(country=country, pycountry_object=pycountry_object) + return Country(country=country, pycountry_object=pycountry_object, force_disable_fallback=False) except EmptyCountryException: return super().__new__(cls) -- 2.47.2 From 62fd09b8defb992fc9fc96d4b530daa0f560405a Mon Sep 17 00:00:00 2001 From: Hazel Noack Date: Thu, 12 Jun 2025 15:17:15 +0200 Subject: [PATCH 10/16] fix: fallback country --- pycountry_wrapper/__main__.py | 10 +++++++ pycountry_wrapper/country.py | 56 +++++++++++++++++------------------ 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/pycountry_wrapper/__main__.py b/pycountry_wrapper/__main__.py index b01091c..16afc37 100644 --- a/pycountry_wrapper/__main__.py +++ b/pycountry_wrapper/__main__.py @@ -17,3 +17,13 @@ def cli(): empty_country = EmptyCountry(country="zwx") print(type(empty_country)) print(empty_country) + + print() + normal_country = Country("UK") + print(type(normal_country)) + print(normal_country) + + print() + fallback_country = Country("zwx") + print(type(fallback_country)) + print(fallback_country) \ No newline at end of file diff --git a/pycountry_wrapper/country.py b/pycountry_wrapper/country.py index 7cd3939..aeb0072 100644 --- a/pycountry_wrapper/country.py +++ b/pycountry_wrapper/country.py @@ -27,12 +27,12 @@ class Country: self, country: Optional[str] = None, pycountry_object: Optional[pycountry.db.Country] = None, - force_disable_fallback: bool = False + disable_fallback: bool = False ) -> None: if pycountry_object is None: # search for the country string instead if the pycountry_object isn't given # this also implements the optional fallback - pycountry_object = self._search_pycountry_object(country=country, force_disable_fallback=force_disable_fallback) + pycountry_object = self._search_pycountry_object(country=country, disable_fallback=disable_fallback) if pycountry_object is None: raise EmptyCountryException(f"the country {country} was not found and config.fallback_country isn't set") @@ -41,35 +41,35 @@ class Country: @classmethod - def _search_pycountry_object(cls, country: Optional[str], is_fallback: bool = False, force_disable_fallback: bool = False) -> Optional[pycountry.db.Country]: - # fallback to configured country if necessary - if country is None and not force_disable_fallback: - if force_disable_fallback or config.fallback_country is None: - return None - - country = config.fallback_country - + def _search_pycountry_object(cls, country: Optional[str], disable_fallback: bool = False) -> Optional[pycountry.db.Country]: pycountry_object = None - # the reason I don't immediately return the result is because then there would be a chance - # I would return None even though a country could be found through fuzzy search - country = country.strip() - if len(country) == 2: - pycountry_object = pycountry.countries.get(alpha_2=country.upper()) - elif len(country) == 3: - pycountry_object = pycountry.countries.get(alpha_3=country.upper()) + if country is not None: + # the reason I don't immediately return the result is because then there would be a chance + # I would return None even though a country could be found through fuzzy search + country = country.strip() + if len(country) == 2: + pycountry_object = pycountry.countries.get(alpha_2=country.upper()) + elif len(country) == 3: + pycountry_object = pycountry.countries.get(alpha_3=country.upper()) + if pycountry_object is not None: + return pycountry_object + + # fuzzy search if enabled + if config.allow_fuzzy_search: + # fuzzy search raises lookup error if nothing was found + try: + found_countries = pycountry.countries.search_fuzzy(country) + if len(found_countries): + return found_countries[0] + except LookupError: + pass + if pycountry_object is not None: return pycountry_object - - # fuzzy search if enabled - if config.allow_fuzzy_search: - # fuzzy search raises lookup error if nothing was found - try: - found_countries = pycountry.countries.search_fuzzy(country) - if len(found_countries): - return found_countries[0] - except LookupError: - pass + + if config.fallback_country is not None and not disable_fallback: + return cls._search_pycountry_object(country=config.fallback_country, disable_fallback=True) @classmethod @@ -122,7 +122,7 @@ class EmptyCountry(Country): """ def __new__(cls, country: Optional[str] = None, pycountry_object: Optional[pycountry.db.Country] = None, **kwargs): try: - return Country(country=country, pycountry_object=pycountry_object, force_disable_fallback=False) + return Country(country=country, pycountry_object=pycountry_object, disable_fallback=True) except EmptyCountryException: return super().__new__(cls) -- 2.47.2 From 683895b66db8c6c6b380c1d4a097e7c923650337 Mon Sep 17 00:00:00 2001 From: Hazel Noack Date: Thu, 12 Jun 2025 15:20:26 +0200 Subject: [PATCH 11/16] feat: refactored the versioning --- pycountry_wrapper/__about__.py | 2 -- pycountry_wrapper/__init__.py | 1 + pycountry_wrapper/__main__.py | 7 +++---- pycountry_wrapper/country.py | 3 +-- pyproject.toml | 6 ++---- 5 files changed, 7 insertions(+), 12 deletions(-) delete mode 100644 pycountry_wrapper/__about__.py diff --git a/pycountry_wrapper/__about__.py b/pycountry_wrapper/__about__.py deleted file mode 100644 index 88e9b11..0000000 --- a/pycountry_wrapper/__about__.py +++ /dev/null @@ -1,2 +0,0 @@ -__version__ = "0.0.4" -__name__ = "pycountry_wrapper" \ No newline at end of file diff --git a/pycountry_wrapper/__init__.py b/pycountry_wrapper/__init__.py index fbd10bf..7d57cfd 100644 --- a/pycountry_wrapper/__init__.py +++ b/pycountry_wrapper/__init__.py @@ -1,6 +1,7 @@ from .country import Country, EmptyCountry, EmptyCountryException from . import config +__name__ = "pycountry_wrapper" __all__ = [ "Country", "EmptyCountry", diff --git a/pycountry_wrapper/__main__.py b/pycountry_wrapper/__main__.py index 16afc37..054f9ee 100644 --- a/pycountry_wrapper/__main__.py +++ b/pycountry_wrapper/__main__.py @@ -1,11 +1,10 @@ -from .__about__ import __name__, __version__ +from . import __name__ from . import Country, EmptyCountry, config -import pycountry - def cli(): - print(f"Running {__name__} version {__version__} from __main__.py") + print(f"Running {__name__} from __main__.py") + import pycountry config.fallback_country = "US" t = pycountry.countries.get(alpha_2="DE") diff --git a/pycountry_wrapper/country.py b/pycountry_wrapper/country.py index aeb0072..e33bfec 100644 --- a/pycountry_wrapper/country.py +++ b/pycountry_wrapper/country.py @@ -1,7 +1,6 @@ from __future__ import annotations from typing import Optional, Union from functools import wraps -from typing_extensions import Self import pycountry import pycountry.db @@ -120,7 +119,7 @@ class EmptyCountry(Country): This will be used if you don't want to use a fallback country but you still want to be able to not have None You can access the same attributes but they will just return None """ - def __new__(cls, country: Optional[str] = None, pycountry_object: Optional[pycountry.db.Country] = None, **kwargs): + def __new__(cls, country: Optional[str] = None, pycountry_object: Optional[pycountry.db.Country] = None, **kwargs) -> Union[Country, EmptyCountry]: try: return Country(country=country, pycountry_object=pycountry_object, disable_fallback=True) except EmptyCountryException: diff --git a/pyproject.toml b/pyproject.toml index 3255e58..8412c0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,8 @@ name = "pycountry-wrapper" dependencies = [ "pycountry~=24.6.1", ] -dynamic = ["version"] +version = "1.0.0" +dynamic = [] authors = [] description = "This is a wrapper for pycountry, to make said library more usable." readme = "README.md" @@ -32,6 +33,3 @@ packages = ["pycountry_wrapper"] [tool.hatch.metadata] allow-direct-references = true - -[tool.hatch.version] -path = "pycountry_wrapper/__about__.py" -- 2.47.2 From 08294e53dc1e0f7793b42ab7ca9bbaf42588495a Mon Sep 17 00:00:00 2001 From: Hazel Noack Date: Thu, 12 Jun 2025 15:21:34 +0200 Subject: [PATCH 12/16] feat: change release script --- release | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/release b/release index df7a542..1bc9967 100755 --- a/release +++ b/release @@ -2,18 +2,9 @@ # install build tools pip install build -pip install twine -pip install hatch - - -# increment version in pyproject.toml -hatch version micro -git add pycountry_wrapper/__about__.py -git commit -m "bump version" -git push # build package -python3 -m build --wheel +python3 -m build # upload to pypi python3 -m twine upload dist/* -- 2.47.2 From 070982ee03a1be31c797e523cd63d5f42ab02725 Mon Sep 17 00:00:00 2001 From: Hazel Noack Date: Thu, 12 Jun 2025 15:50:32 +0200 Subject: [PATCH 13/16] feat: added docstring --- pycountry_wrapper/country.py | 55 ++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/pycountry_wrapper/country.py b/pycountry_wrapper/country.py index e33bfec..f19a532 100644 --- a/pycountry_wrapper/country.py +++ b/pycountry_wrapper/country.py @@ -13,15 +13,32 @@ class EmptyCountryException(ValueError): class Country: """ - This gets countries based on the ISO 3166-1 standart. + A class representing a country based on the ISO 3166-1 standard, wrapping the pycountry library. - Two examples are: - - Country.from_alpha_2("DE") - - Country.from_alpha_3("DEU") + This class provides multiple ways to look up countries: + - By 2-letter alpha-2 code (e.g., "DE" for Germany) + - By 3-letter alpha-3 code (e.g., "DEU" for Germany) + - By fuzzy search of country names + - Directly from pycountry Country objects - If the country couldn't be found, it raises a ValueError, or creates an empty object. - Empty objects return for every attribute None - """ + The class supports optional fallback behavior when a country isn't found, + configurable through the module's config settings. + + Examples: + >>> Country("Germany") # Automatic detection + >>> Country.search("Germany") + >>> + >>> Country.from_alpha_2("DE") # Germany by alpha-2 code + >>> Country.from_alpha_3("DEU") # Germany by alpha-3 code + >>> Country.from_fuzzy("Germany") # Germany by name search + + Raises: + EmptyCountryException: If the country cannot be found and no fallback is configured. + + If you don't want to raise an Exception if no country you can create a Country instance by the following methods: + - Country.search: returns None if nothing is found + - initialize EmptyCountry instead: gives you either a Country instance or an EmptyCountry instance + """ def __init__( self, country: Optional[str] = None, @@ -73,6 +90,15 @@ class Country: @classmethod def search(cls, country: Optional[str]) -> Optional[Country]: + """ + Search for a country and return None instead of raising if not found. + + Args: + country: String to search for (name, alpha-2, or alpha-3 code) + + Returns: + Country object if found, None otherwise + """ return cls(pycountry_object=cls._search_pycountry_object(country=country)) @classmethod @@ -116,8 +142,19 @@ class Country: class EmptyCountry(Country): """ - This will be used if you don't want to use a fallback country but you still want to be able to not have None - You can access the same attributes but they will just return None + A null-object pattern implementation of Country that returns None for all attributes. + + >>> empty = EmptyCountry("InvalidCountry") + >>> print(empty.name) # None + >>> print(empty) # EmptyCountry() + + It doubles as a factory, so if you instantiate the class you'll either get a Country object or EmptyCountry depending if it found a country. + + >>> empty = EmptyCountry("InvalidCountry") + >>> print(type(empty)) # + >>> + >>> found = EmptyCountry("US") + >>> print(type(found)) # """ def __new__(cls, country: Optional[str] = None, pycountry_object: Optional[pycountry.db.Country] = None, **kwargs) -> Union[Country, EmptyCountry]: try: -- 2.47.2 From 32597c1c9c095895e82f62b03f04f8796990b22a Mon Sep 17 00:00:00 2001 From: Hazel Noack Date: Thu, 12 Jun 2025 16:06:35 +0200 Subject: [PATCH 14/16] feat: updated readme --- README.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fdbe59f..38b16fc 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ except ValueError: ### Creating country class -You can call create an instance of `pycountry_wrapper.Country` in multiple slightly different ways. +You can call create an instance of `Country` in multiple slightly different ways. The [**ISO 3166-1**](https://en.wikipedia.org/wiki/ISO_3166-1) standart can either use 2 or 3 letters (alpha_2 or alpha_3). @@ -44,15 +44,20 @@ Country.from_alpha_2("DE") Country.from_alpha_3("DEU") ``` -If you want to use fuzzy search, you will have to use `Country.from_fuzzy()`. +If the country can't be found it will raise a `EmptyCountryException` or use the fallback defined in `config.fallback_country`. + +Alternatively you can get an instance of `Country` by using `Country.search`. This will return `None` if no country was found. + +I also implemented a null-object pattern of `Country`, meaning you can get an `EmptyCountry` object. If you create a country from this object you'll get an instance of `Country` if it was found, and an instance of `EmptyCountry` if it wasn't. ```python -from pycountry_wrapper import Country +empty = EmptyCountry("InvalidCountry") +print(type(empty)) # -Country.from_fuzzy("Deutschland") +found = EmptyCountry("US") +print(type(found)) # ``` - ### Accessing information There are only a handful (readonly) attributes. @@ -60,10 +65,12 @@ There are only a handful (readonly) attributes. ```python from pycountry_wrapper import Country -country = Country.from_alpha_2("DE") +country = Country("DE") country.name country.alpha_2 country.alpha_3 country.official_name ``` + +If you have an `EmptyCountry` these attributes will all be `None`. -- 2.47.2 From 4abc43ac1c5eff399aad8366ef95785703d340f4 Mon Sep 17 00:00:00 2001 From: Hazel Noack Date: Thu, 12 Jun 2025 16:09:19 +0200 Subject: [PATCH 15/16] feat: added config to readme --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 38b16fc..9b833b0 100644 --- a/README.md +++ b/README.md @@ -74,3 +74,14 @@ country.official_name ``` If you have an `EmptyCountry` these attributes will all be `None`. + +### Configuring behavior + +If you want to set a fallback country or disable fuzzy search you can do that with the config module. + +```python +from pycountry_wrapper import config + +config.fallback_country = "US" +config.allow_fuzzy_search = False +``` -- 2.47.2 From 6b79537ae5190dc3433db285c99243f532be66eb Mon Sep 17 00:00:00 2001 From: Hazel Noack Date: Thu, 12 Jun 2025 16:27:05 +0200 Subject: [PATCH 16/16] feat: implemented tests --- tests/test_fallback.py | 43 +++++++++++++++++++++++++++++++++++ tests/test_no_fallback.py | 48 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 tests/test_fallback.py create mode 100644 tests/test_no_fallback.py diff --git a/tests/test_fallback.py b/tests/test_fallback.py new file mode 100644 index 0000000..44e640b --- /dev/null +++ b/tests/test_fallback.py @@ -0,0 +1,43 @@ +import unittest + +from pycountry_wrapper import Country, EmptyCountry, config, EmptyCountryException + + +class TestCountry(unittest.TestCase): + def test_alpha_2(self): + config.fallback_country = "US" + c = Country("DE") + self.assertEqual(c.alpha_2, "DE") + + def test_alpha_3(self): + config.fallback_country = "US" + c = Country("DEU") + self.assertEqual(c.alpha_2, "DE") + + def test_fuzzy(self): + config.fallback_country = "US" + c = Country("Germany") + self.assertEqual(c.alpha_2, "DE") + + def test_not_found(self): + config.fallback_country = "US" + c = Country("does not exist") + self.assertEqual(c.alpha_2, "US") + + +class TestEmptyCountry(unittest.TestCase): + def test_found(self): + config.fallback_country = "US" + c = EmptyCountry("DE") + self.assertEqual(type(c), Country) + self.assertEqual(c.alpha_2, "DE") + + def test_not_found(self): + config.fallback_country = "US" + c = EmptyCountry("does not exist") + self.assertEqual(type(c), EmptyCountry) + self.assertEqual(c.alpha_2, None) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_no_fallback.py b/tests/test_no_fallback.py new file mode 100644 index 0000000..c35ab1c --- /dev/null +++ b/tests/test_no_fallback.py @@ -0,0 +1,48 @@ +import unittest + +from pycountry_wrapper import Country, EmptyCountry, config, EmptyCountryException + + + + +class TestCountry(unittest.TestCase): + def test_alpha_2(self): + config.fallback_country = None + c = Country("DE") + self.assertEqual(c.alpha_2, "DE") + + def test_alpha_3(self): + config.fallback_country = None + c = Country("DEU") + self.assertEqual(c.alpha_2, "DE") + + def test_fuzzy(self): + config.fallback_country = None + c = Country("Germany") + self.assertEqual(c.alpha_2, "DE") + + def test_not_found(self): + config.fallback_country = None + with self.assertRaises(EmptyCountryException): + Country("does not exist") + + +class TestEmptyCountry(unittest.TestCase): + def test_found(self): + config.fallback_country = None + c = EmptyCountry("DE") + self.assertEqual(type(c), Country) + + def test_data(self): + config.fallback_country = None + c = EmptyCountry("DE") + self.assertEqual(c.alpha_2, "DE") + + def test_not_found(self): + config.fallback_country = None + c = EmptyCountry("does not exist") + self.assertEqual(type(c), EmptyCountry) + + +if __name__ == '__main__': + unittest.main() -- 2.47.2