diff --git a/docs/conf.py b/docs/conf.py index e9ffff96..cc6c3271 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,7 @@ from _extend_docstrings import extend_docstrings # noqa: PLC2701 -def pick_newtype_attrs(some_type: type) -> list: +def __get_newtypes(some_type: type) -> list: return [ attr for attr in dir(some_type) @@ -278,25 +278,20 @@ def pick_newtype_attrs(some_type: type) -> list: nb_execution_show_tb = True nb_execution_timeout = -1 nb_output_stderr = "remove" - - -nitpick_temp_names = [ - *pick_newtype_attrs(EdgeQuantumNumbers), - *pick_newtype_attrs(NodeQuantumNumbers), -] -nitpick_temp_patterns = [ - (r"py:(class|obj)", r"qrules\.quantum_numbers\." + name) - for name in nitpick_temp_names -] nitpick_ignore_regex = [ (r"py:(class|obj)", "json.encoder.JSONEncoder"), + (r"py:(class|obj)", r"frozendict(\.frozendict)?"), (r"py:(class|obj)", r"qrules\.topology\.EdgeType"), (r"py:(class|obj)", r"qrules\.topology\.KT"), (r"py:(class|obj)", r"qrules\.topology\.NewEdgeType"), (r"py:(class|obj)", r"qrules\.topology\.NewNodeType"), (r"py:(class|obj)", r"qrules\.topology\.NodeType"), (r"py:(class|obj)", r"qrules\.topology\.VT"), - *nitpick_temp_patterns, + *[ + (r"py:(class|obj)", r"qrules\.quantum_numbers\." + name) + for name in __get_newtypes(EdgeQuantumNumbers) + + __get_newtypes(NodeQuantumNumbers) + ], ] nitpicky = True primary_domain = "py" diff --git a/pyproject.toml b/pyproject.toml index c1e33e68..3af42c7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ dependencies = [ "PyYAML", "attrs >=20.1.0", # on_setattr and https://www.attrs.org/en/stable/api.html#next-gen + "frozendict", "jsonschema", "particle", "python-constraint", diff --git a/src/qrules/topology.py b/src/qrules/topology.py index 56437568..bb69981b 100644 --- a/src/qrules/topology.py +++ b/src/qrules/topology.py @@ -27,19 +27,12 @@ import attrs from attrs import define, field, frozen from attrs.validators import deep_iterable, deep_mapping, instance_of +from frozendict import frozendict from qrules._implementers import implement_pretty_repr if TYPE_CHECKING: - from collections.abc import ( - ItemsView, - Iterable, - Iterator, - KeysView, - Mapping, - Sequence, - ValuesView, - ) + from collections.abc import Iterable, Mapping, Sequence from IPython.lib.pretty import PrettyPrinter @@ -56,31 +49,8 @@ def __lt__(self, other: Any) -> bool: ... @total_ordering -class FrozenDict(abc.Hashable, abc.Mapping, Generic[KT, VT]): - """An **immutable** and **hashable** version of a `dict`. - - `FrozenDict` makes it possible to make classes hashable if they are decorated with - :func:`attr.frozen` and contain `~typing.Mapping`-like attributes. If these - attributes were to be implemented with a normal `dict`, the instance is strictly - speaking still mutable (even if those attributes are a `property`) and the class is - therefore not safely hashable. - - .. warning:: The keys have to be comparable, that is, they need to have a - :meth:`~object.__lt__` method. - """ - - def __init__(self, mapping: Mapping | None = None) -> None: - self.__mapping: dict[KT, VT] = {} - if mapping is not None: - self.__mapping = dict(mapping) - self.__hash = hash(None) - if len(self.__mapping) != 0: - self.__hash = 0 - for key_value_pair in self.items(): - self.__hash ^= hash(key_value_pair) - - def __repr__(self) -> str: - return f"{type(self).__name__}({self.__mapping})" +class FrozenDict(frozendict, Generic[KT, VT]): + """A sortable version of :code:`frozendict`.""" def _repr_pretty_(self, p: PrettyPrinter, cycle: bool) -> None: class_name = type(self).__name__ @@ -96,15 +66,6 @@ def _repr_pretty_(self, p: PrettyPrinter, cycle: bool) -> None: p.breakable() p.text("})") - def __iter__(self) -> Iterator[KT]: - return iter(self.__mapping) - - def __len__(self) -> int: - return len(self.__mapping) - - def __getitem__(self, key: KT) -> VT: - return self.__mapping[key] - def __gt__(self, other: Any) -> bool: if isinstance(other, abc.Mapping): sorted_self = _convert_mapping_to_sorted_tuple(self) @@ -117,18 +78,6 @@ def __gt__(self, other: Any) -> bool: ) raise NotImplementedError(msg) - def __hash__(self) -> int: - return self.__hash - - def keys(self) -> KeysView[KT]: - return self.__mapping.keys() - - def items(self) -> ItemsView[KT, VT]: - return self.__mapping.items() - - def values(self) -> ValuesView[VT]: - return self.__mapping.values() - def _convert_mapping_to_sorted_tuple( mapping: Mapping[KT, VT], diff --git a/tests/unit/test_topology.py b/tests/unit/test_topology.py index 427500c3..f85d2069 100644 --- a/tests/unit/test_topology.py +++ b/tests/unit/test_topology.py @@ -1,3 +1,5 @@ +import hashlib +import pickle # noqa: S403 import typing import pytest @@ -6,7 +8,7 @@ from qrules.topology import ( Edge, - FrozenDict, # noqa: F401 # pyright: ignore[reportUnusedImport] + FrozenDict, # pyright: ignore[reportUnusedImport] InteractionNode, MutableTopology, SimpleStateTransitionTopologyBuilder, @@ -39,6 +41,23 @@ def test_immutability(self): edge.ending_node_id += 1 +class TestFrozenDict: + def test_hash(self): + obj: FrozenDict = FrozenDict({}) + assert _compute_hash(obj) == "067705e70d037311d05daae1e32e1fce" + + obj = FrozenDict({"key1": "value1"}) + assert _compute_hash(obj) == "56b0520e2a3af550c0f488cd5de2d474" + + obj = FrozenDict({ + "key1": "value1", + "key2": 2, + "key3": (1, 2, 3), + "key4": FrozenDict({"nested_key": "nested_value"}), + }) + assert _compute_hash(obj) == "8568f73c07fce099311f010061f070c6" + + class TestInteractionNode: def test_constructor_exceptions(self): with pytest.raises(TypeError): @@ -188,6 +207,9 @@ def test_constructor_exceptions(self, nodes, edges): ): assert Topology(nodes, edges) + def test_hash(self, two_to_three_decay: Topology): + assert _compute_hash(two_to_three_decay) == "cbaea5d94038a3ad30888014a7b3ae20" + @pytest.mark.parametrize("repr_method", [repr, pretty]) def test_repr_and_eq(self, repr_method, two_to_three_decay: Topology): topology = eval(repr_method(two_to_three_decay)) @@ -299,3 +321,15 @@ def test_create_n_body_topology(n_initial: int, n_final: int, exception): assert len(topology.outgoing_edge_ids) == n_final assert len(topology.intermediate_edge_ids) == 0 assert len(topology.nodes) == 1 + + +def _compute_hash(obj) -> str: + b = _to_bytes(obj) + h = hashlib.md5(b) # noqa: S324 + return h.hexdigest() + + +def _to_bytes(obj) -> bytes: + if isinstance(obj, bytes | bytearray): + return obj + return pickle.dumps(obj) diff --git a/tests/unit/test_transition.py b/tests/unit/test_transition.py index dc9b19ad..dab38d54 100644 --- a/tests/unit/test_transition.py +++ b/tests/unit/test_transition.py @@ -1,4 +1,6 @@ # pyright: reportUnusedImport=false +import hashlib +import pickle # noqa: S403 from copy import deepcopy from fractions import Fraction @@ -44,6 +46,13 @@ def test_repr(self, repr_method, reaction: ReactionInfo): def test_hash(self, reaction: ReactionInfo): assert hash(deepcopy(reaction)) == hash(reaction) + def test_hash_value(self, reaction: ReactionInfo): + expected_hash = { + "canonical-helicity": "65106a44301f9340e633d09f66ad7d17", + "helicity": "9646d3ee5c5e8534deb8019435161f2e", + }[reaction.formalism] + assert _compute_hash(reaction) == expected_hash + class TestState: @pytest.mark.parametrize( @@ -106,3 +115,15 @@ def test_regex_pattern(self): "Delta(1900)++", "Delta(1920)++", ] + + +def _compute_hash(obj) -> str: + b = _to_bytes(obj) + h = hashlib.md5(b) # noqa: S324 + return h.hexdigest() + + +def _to_bytes(obj) -> bytes: + if isinstance(obj, bytes | bytearray): + return obj + return pickle.dumps(obj) diff --git a/uv.lock b/uv.lock index c6260339..4bc800cd 100644 --- a/uv.lock +++ b/uv.lock @@ -572,6 +572,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121 }, ] +[[package]] +name = "frozendict" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/59/19eb300ba28e7547538bdf603f1c6c34793240a90e1a7b61b65d8517e35e/frozendict-2.4.6.tar.gz", hash = "sha256:df7cd16470fbd26fc4969a208efadc46319334eb97def1ddf48919b351192b8e", size = 316416 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/7f/e80cdbe0db930b2ba9d46ca35a41b0150156da16dfb79edcc05642690c3b/frozendict-2.4.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3a05c0a50cab96b4bb0ea25aa752efbfceed5ccb24c007612bc63e51299336f", size = 37927 }, + { url = "https://files.pythonhosted.org/packages/29/98/27e145ff7e8e63caa95fb8ee4fc56c68acb208bef01a89c3678a66f9a34d/frozendict-2.4.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f5b94d5b07c00986f9e37a38dd83c13f5fe3bf3f1ccc8e88edea8fe15d6cd88c", size = 37945 }, + { url = "https://files.pythonhosted.org/packages/ac/f1/a10be024a9d53441c997b3661ea80ecba6e3130adc53812a4b95b607cdd1/frozendict-2.4.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4c789fd70879ccb6289a603cdebdc4953e7e5dea047d30c1b180529b28257b5", size = 117656 }, + { url = "https://files.pythonhosted.org/packages/46/a6/34c760975e6f1cb4db59a990d58dcf22287e10241c851804670c74c6a27a/frozendict-2.4.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da6a10164c8a50b34b9ab508a9420df38f4edf286b9ca7b7df8a91767baecb34", size = 117444 }, + { url = "https://files.pythonhosted.org/packages/62/dd/64bddd1ffa9617f50e7e63656b2a7ad7f0a46c86b5f4a3d2c714d0006277/frozendict-2.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9a8a43036754a941601635ea9c788ebd7a7efbed2becba01b54a887b41b175b9", size = 116801 }, + { url = "https://files.pythonhosted.org/packages/45/ae/af06a8bde1947277aad895c2f26c3b8b8b6ee9c0c2ad988fb58a9d1dde3f/frozendict-2.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c9905dcf7aa659e6a11b8051114c9fa76dfde3a6e50e6dc129d5aece75b449a2", size = 117329 }, + { url = "https://files.pythonhosted.org/packages/d2/df/be3fa0457ff661301228f4c59c630699568c8ed9b5480f113b3eea7d0cb3/frozendict-2.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:323f1b674a2cc18f86ab81698e22aba8145d7a755e0ac2cccf142ee2db58620d", size = 37522 }, + { url = "https://files.pythonhosted.org/packages/4a/6f/c22e0266b4c85f58b4613fec024e040e93753880527bf92b0c1bc228c27c/frozendict-2.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:eabd21d8e5db0c58b60d26b4bb9839cac13132e88277e1376970172a85ee04b3", size = 34056 }, + { url = "https://files.pythonhosted.org/packages/eb/7e/5d6e86b01742468e5265401529b60d4d24e4b61a751d24473a324da71b55/frozendict-2.4.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a76cee5c4be2a5d1ff063188232fffcce05dde6fd5edd6afe7b75b247526490e", size = 38143 }, + { url = "https://files.pythonhosted.org/packages/93/d0/3d66be6d154e2bbb4d49445c557f722b248c019b70982654e2440f303671/frozendict-2.4.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba5ef7328706db857a2bdb2c2a17b4cd37c32a19c017cff1bb7eeebc86b0f411", size = 37954 }, + { url = "https://files.pythonhosted.org/packages/b8/a2/5a178339345edff643240e48dd276581df64b1dd93eaa7d26556396a145b/frozendict-2.4.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:669237c571856be575eca28a69e92a3d18f8490511eff184937283dc6093bd67", size = 117093 }, + { url = "https://files.pythonhosted.org/packages/41/df/09a752239eb0661eeda0f34f14577c10edc6f3e4deb7652b3a3efff22ad4/frozendict-2.4.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0aaa11e7c472150efe65adbcd6c17ac0f586896096ab3963775e1c5c58ac0098", size = 116883 }, + { url = "https://files.pythonhosted.org/packages/22/d4/619d1cfbc74be5641d839a5a2e292f9eac494aa557bfe7c266542c4014a2/frozendict-2.4.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b8f2829048f29fe115da4a60409be2130e69402e29029339663fac39c90e6e2b", size = 116314 }, + { url = "https://files.pythonhosted.org/packages/41/b9/40042606a4ac458046ebeecc34cec2971e78e029ea3b6ad4e35833c7f8e6/frozendict-2.4.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:94321e646cc39bebc66954a31edd1847d3a2a3483cf52ff051cd0996e7db07db", size = 117017 }, + { url = "https://files.pythonhosted.org/packages/e1/6d/e99715f406d8f4297d08b5591365e7d91b39a24cdbaabd3861f95e283c52/frozendict-2.4.6-cp39-cp39-win_amd64.whl", hash = "sha256:74b6b26c15dddfefddeb89813e455b00ebf78d0a3662b89506b4d55c6445a9f4", size = 37815 }, + { url = "https://files.pythonhosted.org/packages/80/75/cad77ff4bb58277a557becf837345de8f6384d3b1d71f932d22a13223b9e/frozendict-2.4.6-cp39-cp39-win_arm64.whl", hash = "sha256:7088102345d1606450bd1801a61139bbaa2cb0d805b9b692f8d81918ea835da6", size = 34368 }, + { url = "https://files.pythonhosted.org/packages/04/13/d9839089b900fa7b479cce495d62110cddc4bd5630a04d8469916c0e79c5/frozendict-2.4.6-py311-none-any.whl", hash = "sha256:d065db6a44db2e2375c23eac816f1a022feb2fa98cbb50df44a9e83700accbea", size = 16148 }, + { url = "https://files.pythonhosted.org/packages/ba/d0/d482c39cee2ab2978a892558cf130681d4574ea208e162da8958b31e9250/frozendict-2.4.6-py312-none-any.whl", hash = "sha256:49344abe90fb75f0f9fdefe6d4ef6d4894e640fadab71f11009d52ad97f370b9", size = 16146 }, + { url = "https://files.pythonhosted.org/packages/a5/8e/b6bf6a0de482d7d7d7a2aaac8fdc4a4d0bb24a809f5ddd422aa7060eb3d2/frozendict-2.4.6-py313-none-any.whl", hash = "sha256:7134a2bb95d4a16556bb5f2b9736dceb6ea848fa5b6f3f6c2d6dba93b44b4757", size = 16146 }, +] + [[package]] name = "gitdb" version = "4.0.11" @@ -2128,6 +2155,7 @@ version = "0.10.2" source = { editable = "." } dependencies = [ { name = "attrs" }, + { name = "frozendict" }, { name = "jsonschema" }, { name = "particle" }, { name = "python-constraint" }, @@ -2243,6 +2271,7 @@ types = [ [package.metadata] requires-dist = [ { name = "attrs", specifier = ">=20.1.0" }, + { name = "frozendict" }, { name = "graphviz", marker = "extra == 'viz'" }, { name = "jsonschema" }, { name = "particle" },