Repository: Diaoul/babelfish

Issue:
Immutability issues
Currently babelfish implements the hash function to override default behavior and make it possible for objects to be keys in dictionaries and other useful features.
While this is a good thing, the way it is implemented makes it prone to some weird errors as explained in [this lyft  blog post](https://eng.lyft.com/hashing-and-equality-in-python-2ea8c738fb9d?gi=198a2c2464b):
```python
>>> import babelfish
>>> fr = babelfish.Language("fra")
>>> fr_fr = babelfish.Language("fra", "FR")
>>> s = set([fr])
>>> fr in s
True
>>> fr_fr in s
False
```

All that is great, but if we modify the objects, things get weird because python expect the result of hash **not** to change:
```python
>>> fr.country = babelfish.Country("FR")
>>> fr
<Language [fr-FR]>
>>> fr in s
False
>>> list(s)[0]
<Language [fr-FR]>
>>> fr_fr in s
False
```

I want to have true immutability of babelfish objects by making use of tuples (and derivatives) or at least faking it maybe with dataclasses frozen options.
This will surely be a breaking change.

Discussion:
Diaoul: Can you rebase on main?
getzze: I rebased to main.
I removed the `cattrs` test dependency and regenerated the `poetry.lock` file, otherwise the tests were failing.

Patch:
diff --git a/babelfish/country.py b/babelfish/country.py
index 4c3893d..0ffb33f 100644
--- a/babelfish/country.py
+++ b/babelfish/country.py
@@ -5,6 +5,7 @@
 from __future__ import annotations
 
 from collections import namedtuple
+from dataclasses import dataclass
 from functools import partial
 from typing import Any, ClassVar
 
@@ -52,6 +53,7 @@ class CountryMeta(type):
         return type.__getattribute__(cls, name)
 
 
+@dataclass(frozen=True)
 class Country(metaclass=CountryMeta):
     """A country on Earth.
 
@@ -61,13 +63,13 @@ class Country(metaclass=CountryMeta):
 
     """
 
-    def __init__(self, country: str) -> None:
-        if country not in COUNTRIES:
-            msg = f'{country!r} is not a valid country'
-            raise ValueError(msg)
+    #: ISO-3166 2-letter country code
+    country: str
 
-        #: ISO-3166 2-letter country code
-        self.alpha2 = country
+    def __post_init__(self) -> None:
+        if self.country not in COUNTRIES:
+            msg = f'{self.country!r} is not a valid country'
+            raise ValueError(msg)
 
     @classmethod
     def fromcode(cls, code: str, converter: str) -> Country:
@@ -82,31 +84,21 @@ class Country(metaclass=CountryMeta):
         """
         return cls(country_converters[converter].reverse(code))
 
-    def __getstate__(self) -> str:
-        return self.alpha2
-
-    def __setstate__(self, state: str) -> None:
-        self.alpha2 = state
+    @property
+    def alpha2(self) -> str:
+        return self.country
 
     def __getattr__(self, name: str) -> str:
+        # Handle private attributes by raising AttributeError
+        # so the class is pickable, see: https://stackoverflow.com/a/50888571
+        if name.startswith('_'):
+            raise AttributeError
+
         try:
             return country_converters[name].convert(self.alpha2)
         except KeyError as err:
             raise AttributeError(name) from err
 
-    def __hash__(self) -> int:
-        return hash(self.alpha2)
-
-    def __eq__(self, other: Any) -> bool:
-        if isinstance(other, str):
-            return str(self) == other
-        if not isinstance(other, Country):
-            return False
-        return self.alpha2 == other.alpha2
-
-    def __ne__(self, other: Any) -> bool:
-        return not self == other
-
     def __repr__(self) -> str:
         return f'<Country [{self}]>'
 
diff --git a/babelfish/language.py b/babelfish/language.py
index f1b004e..5a19260 100644
--- a/babelfish/language.py
+++ b/babelfish/language.py
@@ -5,6 +5,7 @@
 from __future__ import annotations
 
 from collections import namedtuple
+from dataclasses import InitVar, dataclass
 from functools import partial
 from typing import Any, ClassVar
 
@@ -50,6 +51,24 @@ class LanguageConverterManager(ConverterManager[LanguageReverseConverter]):
 language_converters = LanguageConverterManager()
 
 
+def to_country(country: str | Country | None) -> Country | None:
+    """Convert to Country or None."""
+    if isinstance(country, Country):
+        return country
+    if country is None:
+        return None
+    return Country(country)
+
+
+def to_script(script: str | Script | None) -> Script | None:
+    """Convert to Script or None."""
+    if isinstance(script, Script):
+        return script
+    if script is None:
+        return None
+    return Script(script)
+
+
 class LanguageMeta(type):
     """The :class:`Language` metaclass.
 
@@ -63,6 +82,7 @@ class LanguageMeta(type):
         return type.__getattribute__(cls, name)
 
 
+@dataclass(frozen=True)
 class Language(metaclass=LanguageMeta):
     """A human language.
 
@@ -83,9 +103,10 @@ class Language(metaclass=LanguageMeta):
 
     """
 
-    alpha3: str
+    language: str
     country: Country | None
     script: Script | None
+    unknown: InitVar[str | None] = None
 
     def __init__(
         self,
@@ -96,24 +117,16 @@ class Language(metaclass=LanguageMeta):
     ) -> None:
         if unknown is not None and language not in LANGUAGES:
             language = unknown
+
         if language not in LANGUAGES:
             msg = f'{language!r} is not a valid language'
             raise ValueError(msg)
-        self.alpha3 = language
-        self.country = None
-        if isinstance(country, Country):
-            self.country = country
-        elif country is None:
-            self.country = None
-        else:
-            self.country = Country(country)
-        self.script = None
-        if isinstance(script, Script):
-            self.script = script
-        elif script is None:
-            self.script = None
-        else:
-            self.script = Script(script)
+        country = to_country(country)
+        script = to_script(script)
+
+        object.__setattr__(self, 'language', language)
+        object.__setattr__(self, 'country', country)
+        object.__setattr__(self, 'script', script)
 
     @classmethod
     def fromcode(cls, code: str, converter: str) -> Language:
@@ -138,51 +151,49 @@ class Language(metaclass=LanguageMeta):
 
         """
         subtags = ietf.split('-')
+        # Parse language from the first subtags
         language_subtag = subtags.pop(0).lower()
-        language = cls.fromalpha2(language_subtag) if len(language_subtag) == 2 else cls(language_subtag)
+        if len(language_subtag) == 2:
+            simple_language = cls.fromalpha2(language_subtag)
+            language_subtag = simple_language.alpha3
+
+        # Parse country and script from the rest of subtags
+        country_subtag: Country | None = None
+        script_subtag: Script | None = None
         while subtags:
             subtag = subtags.pop(0)
             if len(subtag) == 2:
-                language.country = Country(subtag.upper())
+                country_subtag = Country(subtag.upper())
             else:
-                language.script = Script(subtag.capitalize())
-            if language.script is not None:
+                script_subtag = Script(subtag.capitalize())
+            if script_subtag is not None:
                 if subtags:
                     msg = f'Wrong IETF format. Unmatched subtags: {subtags!r}'
                     raise ValueError(msg)
                 break
-        return language
 
-    def __getstate__(self) -> tuple[str, Country | None, Script | None]:
-        return self.alpha3, self.country, self.script
+        return cls(language_subtag, country_subtag, script_subtag)
 
-    def __setstate__(self, state: tuple[str, Country | None, Script | None]) -> None:
-        self.alpha3, self.country, self.script = state
+    @property
+    def alpha3(self) -> str:
+        return self.language
 
     def __getattr__(self, name: str) -> str:
-        alpha3 = self.alpha3
-        country = self.country.alpha2 if self.country is not None else None
-        script = self.script.code if self.script is not None else None
+        # Handle private attributes by raising AttributeError
+        # so the class is pickable, see: https://stackoverflow.com/a/50888571
+        if name.startswith('_'):
+            raise AttributeError
+
         try:
+            alpha3 = self.alpha3
+            country = self.country.alpha2 if self.country is not None else None
+            script = self.script.code if self.script is not None else None
             return language_converters[name].convert(alpha3, country, script)
         except KeyError as err:
             raise AttributeError(name) from err
 
-    def __hash__(self) -> int:
-        return hash(str(self))
-
-    def __eq__(self, other: Any) -> bool:
-        if isinstance(other, str):
-            return str(self) == other
-        if not isinstance(other, Language):
-            return False
-        return self.alpha3 == other.alpha3 and self.country == other.country and self.script == other.script
-
-    def __ne__(self, other: Any) -> bool:
-        return not self == other
-
     def __bool__(self) -> bool:
-        return self.alpha3 != 'und'
+        return self.language != 'und'
 
     __nonzero__ = __bool__
 
diff --git a/babelfish/script.py b/babelfish/script.py
index 4a36b71..2a85fe2 100644
--- a/babelfish/script.py
+++ b/babelfish/script.py
@@ -5,7 +5,7 @@
 from __future__ import annotations
 
 from collections import namedtuple
-from typing import Any
+from dataclasses import dataclass
 
 from .compat import resource_stream
 
@@ -30,6 +30,7 @@ with resource_stream('babelfish', 'data/iso15924-utf8-20131012.txt') as f:
         SCRIPTS[script.code] = script.name
 
 
+@dataclass(frozen=True)
 class Script:
     """A human writing system.
 
@@ -39,38 +40,23 @@ class Script:
 
     """
 
-    def __init__(self, script: str) -> None:
-        if script not in SCRIPTS:
-            msg = f'{script!r} is not a valid script'
+    #: ISO-15924 4-letter script code
+    script: str
+
+    def __post_init__(self) -> None:
+        if self.script not in SCRIPTS:
+            msg = f'{self.script!r} is not a valid script'
             raise ValueError(msg)
 
-        #: ISO-15924 4-letter script code
-        self.code = script
+    @property
+    def code(self) -> str:
+        return self.script
 
     @property
     def name(self) -> str:
         """English name of the script."""
         return SCRIPTS[self.code]
 
-    def __getstate__(self) -> str:
-        return self.code
-
-    def __setstate__(self, state: str) -> None:
-        self.code = state
-
-    def __hash__(self) -> int:
-        return hash(self.code)
-
-    def __eq__(self, other: Any) -> bool:
-        if isinstance(other, str):
-            return self.code == other
-        if not isinstance(other, Script):
-            return False
-        return self.code == other.code
-
-    def __ne__(self, other: Any) -> bool:
-        return not self == other
-
     def __repr__(self) -> str:
         return f'<Script [{self}]>'
 
diff --git a/poetry.lock b/poetry.lock
index 7b49026..6fd11c5 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand.
 
 [[package]]
 name = "colorama"
@@ -13,13 +13,13 @@ files = [
 
 [[package]]
 name = "exceptiongroup"
-version = "1.2.1"
+version = "1.2.2"
 description = "Backport of PEP 654 (exception groups)"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"},
-    {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"},
+    {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
+    {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
 ]
 
 [package.extras]
@@ -75,13 +75,13 @@ files = [
 
 [[package]]
 name = "packaging"
-version = "24.0"
+version = "24.2"
 description = "Core utilities for Python packages"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"},
-    {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"},
+    {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
+    {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
 ]
 
 [[package]]
@@ -101,13 +101,13 @@ testing = ["pytest", "pytest-benchmark"]
 
 [[package]]
 name = "pytest"
-version = "8.2.0"
+version = "8.3.4"
 description = "pytest: simple powerful testing with Python"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "pytest-8.2.0-py3-none-any.whl", hash = "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233"},
-    {file = "pytest-8.2.0.tar.gz", hash = "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f"},
+    {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"},
+    {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"},
 ]
 
 [package.dependencies]
@@ -115,7 +115,7 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""}
 exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
 iniconfig = "*"
 packaging = "*"
-pluggy = ">=1.5,<2.0"
+pluggy = ">=1.5,<2"
 tomli = {version = ">=1", markers = "python_version < \"3.11\""}
 
 [package.extras]
@@ -123,31 +123,65 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments
 
 [[package]]
 name = "tomli"
-version = "2.0.1"
+version = "2.2.1"
 description = "A lil' TOML parser"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
-    {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+    {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
+    {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"},
+    {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"},
+    {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"},
+    {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"},
+    {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"},
+    {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"},
+    {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"},
+    {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"},
+    {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"},
+    {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"},
+    {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"},
+    {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"},
+    {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"},
+    {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"},
+    {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"},
+    {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"},
+    {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"},
+    {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"},
+    {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"},
+    {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"},
+    {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"},
+    {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"},
+    {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"},
+    {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"},
+    {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"},
+    {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"},
+    {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"},
+    {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"},
+    {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"},
+    {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"},
+    {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"},
 ]
 
 [[package]]
 name = "zipp"
-version = "3.18.1"
+version = "3.20.2"
 description = "Backport of pathlib-compatible object wrapper for zip files"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "zipp-3.18.1-py3-none-any.whl", hash = "sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b"},
-    {file = "zipp-3.18.1.tar.gz", hash = "sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715"},
+    {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"},
+    {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"},
 ]
 
 [package.extras]
-docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"]
+cover = ["pytest-cov"]
+doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+enabler = ["pytest-enabler (>=2.2)"]
+test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"]
+type = ["pytest-mypy"]
 
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.8"
-content-hash = "86412ca5e363c440f0e9deec92866354cf91e45f5618f8350db140a8d2440c13"
+content-hash = "4ef2b4f9f24dad75fb130c30b1157be04f23540893d4d9396e83cc5e0539dc83"
diff --git a/pyproject.toml b/pyproject.toml
index 2a28987..5c987db 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,7 +41,7 @@ python = "^3.8"
 importlib-resources = {version = "^5.0", python = "<3.9"}
 importlib-metadata = {version = "^4.6", python = "<3.10"}
 
-[tool.poetry.dev-dependencies]
+[tool.poetry.group.dev.dependencies]
 pytest = "^8.2.0"
 
 


Test patch:
diff --git a/tests/test_country.py b/tests/test_country.py
index 8a039ef..2c8b095 100644
--- a/tests/test_country.py
+++ b/tests/test_country.py
@@ -12,8 +12,9 @@ def test_ne():
     assert Country('GB') != Country('US')
 
 
-def test_hash_eq():
-    assert hash(Country('US')) == hash('US')
+def test_hash_eq_incorrect():
+    # Not true anymore
+    assert hash(Country('US')) != hash('US')
 
 
 def test_hasattr_name():
diff --git a/tests/test_language.py b/tests/test_language.py
index a9a66a1..8cbaf21 100644
--- a/tests/test_language.py
+++ b/tests/test_language.py
@@ -71,12 +71,13 @@ def test_ne_with_script_and_none():
     assert Language('srp', script='Latn') != Language('srp')
 
 
-def test_hash():
-    assert hash(Language('fra')) == hash('fr')
-    assert hash(Language('ace')) == hash('ace')
-    assert hash(Language('por', 'BR')) == hash('pt-BR')
-    assert hash(Language('srp', script='Cyrl')) == hash('sr-Cyrl')
-    assert hash(Language('eng', country='US', script='Latn')) == hash('en-US-Latn')
+def test_hash_incorrect():
+    # Not true anymore
+    assert hash(Language('fra')) != hash('fr')
+    assert hash(Language('ace')) != hash('ace')
+    assert hash(Language('por', 'BR')) != hash('pt-BR')
+    assert hash(Language('srp', script='Cyrl')) != hash('sr-Cyrl')
+    assert hash(Language('eng', country='US', script='Latn')) != hash('en-US-Latn')
 
 
 @pytest.mark.parametrize(