diff --git a/README.md b/README.md index fdbe59f..9b833b0 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,23 @@ 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`. + +### 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 +``` 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 793fa56..7d57cfd 100644 --- a/pycountry_wrapper/__init__.py +++ b/pycountry_wrapper/__init__.py @@ -1,118 +1,10 @@ -from __future__ import annotations -from typing import Optional -from functools import wraps -import pycountry +from .country import Country, EmptyCountry, EmptyCountryException +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 +__name__ = "pycountry_wrapper" +__all__ = [ + "Country", + "EmptyCountry", + "config", + "EmptyCountryException", +] diff --git a/pycountry_wrapper/__main__.py b/pycountry_wrapper/__main__.py index 6ed4f72..054f9ee 100644 --- a/pycountry_wrapper/__main__.py +++ b/pycountry_wrapper/__main__.py @@ -1,8 +1,28 @@ -from .__about__ import __name__, __version__ -from . import Country +from . import __name__ +from . import Country, EmptyCountry, config 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") - print(Country(country="DE").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) + + 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/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..f19a532 --- /dev/null +++ b/pycountry_wrapper/country.py @@ -0,0 +1,178 @@ +from __future__ import annotations +from typing import Optional, Union +from functools import wraps +import pycountry +import pycountry.db + +from . import config + + +class EmptyCountryException(ValueError): + pass + + +class Country: + """ + A class representing a country based on the ISO 3166-1 standard, wrapping the pycountry library. + + 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 + + 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, + pycountry_object: Optional[pycountry.db.Country] = None, + 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, 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") + + self.pycountry_object: pycountry.db.Country = pycountry_object + + + @classmethod + def _search_pycountry_object(cls, country: Optional[str], disable_fallback: bool = False) -> Optional[pycountry.db.Country]: + pycountry_object = None + + 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 + + if config.fallback_country is not None and not disable_fallback: + return cls._search_pycountry_object(country=config.fallback_country, disable_fallback=True) + + + @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 + 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)) # type: ignore + + @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 + + def __str__(self) -> str: + return self.pycountry_object.__str__() + + def __repr__(self) -> str: + return self.pycountry_object.__repr__() + + +class EmptyCountry(Country): + """ + 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: + return Country(country=country, pycountry_object=pycountry_object, disable_fallback=True) + 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 + + def __str__(self) -> str: + return "EmptyCountry()" + + def __repr__(self) -> str: + return "EmptyCountry()" + 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" 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/* 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()