Merge pull request 'config_default_behaviour' (#1) from config_default_behaviour into main

Reviewed-on: #1
This commit is contained in:
Hazel 2025-06-12 14:28:03 +00:00
commit a109f7f1f9
10 changed files with 337 additions and 143 deletions

View File

@ -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)) # <class 'pycountry_wrapper.country.EmptyCountry'>
Country.from_fuzzy("Deutschland")
found = EmptyCountry("US")
print(type(found)) # <class 'pycountry_wrapper.country.Country'>
```
### 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
```

View File

@ -1,2 +0,0 @@
__version__ = "0.0.4"
__name__ = "pycountry_wrapper"

View File

@ -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",
]

View File

@ -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)
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)

View File

@ -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

View File

@ -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)) # <class 'pycountry_wrapper.country.EmptyCountry'>
>>>
>>> found = EmptyCountry("US")
>>> print(type(found)) # <class 'pycountry_wrapper.country.Country'>
"""
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()"

View File

@ -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"

11
release
View File

@ -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/*

43
tests/test_fallback.py Normal file
View File

@ -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()

48
tests/test_no_fallback.py Normal file
View File

@ -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()