From c6389b836dd5ac33a574e1de9e0c87660605ed47 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sun, 8 Feb 2026 13:26:10 -0700 Subject: [PATCH 01/29] Return HashKey fro HashDispatcher (and not id()) --- pyomo/common/collections/_hasher.py | 39 +++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/pyomo/common/collections/_hasher.py b/pyomo/common/collections/_hasher.py index 737459dae25..9f0c87497ef 100644 --- a/pyomo/common/collections/_hasher.py +++ b/pyomo/common/collections/_hasher.py @@ -12,6 +12,41 @@ from collections import defaultdict +class HashKey: + """Utility class to support hashing by object id() + + This class supports hashing unhashable objects using their id(). It + can be used as a key in a mixed-class :py:`dict` to prevent key + collisions between :py:`int` keys and an unhashable objects whose + id() is the same value. + + .. note:: + + This class is slotized for efficiency, but does not provide + special handling for updating the internal ``_hash`` (`id()`) + after deepcopying or pickling. As such, containers that use this + class (e.g., :py:`ComponentMap` and :py:`ComponentSet`) should + not pickle these objects and instead regenerate them when + restoring the container. + + """ + + __slots__ = ('_hash', '_val') + + def __init__(self, val): + self._hash = id(val) + self._val = val + + def __hash__(self): + return self._hash + + def __eq__(self, other): + return other.__class__ is HashKey and other._hash == self._hash + + def __repr__(self): + return f"{self._val} (key={self._hash})" + + class HashDispatcher(defaultdict): """Dispatch table for generating "universal" hashing of all Python objects. @@ -36,7 +71,7 @@ def _missing_impl(self, val): hash(val) self[val.__class__] = self._hashable except: - self[val.__class__] = self._unhashable + self[val.__class__] = HashKey return self[val.__class__](val) @staticmethod @@ -60,7 +95,7 @@ def hashable(self, obj, hashable=None): if fcn is None: raise KeyError(obj) return fcn is self._hashable - self[cls] = self._hashable if hashable else self._unhashable + self[cls] = self._hashable if hashable else HashKey #: The global 'hasher' instance for managing "universal" hashing. From 6e10267a38583c828462c02859c1e23a5b74accb Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sun, 8 Feb 2026 13:27:15 -0700 Subject: [PATCH 02/29] Improve tuple handling in hash dispatcher: only generate HashKeys when necessary --- pyomo/common/collections/_hasher.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/pyomo/common/collections/_hasher.py b/pyomo/common/collections/_hasher.py index 9f0c87497ef..8711e0cd985 100644 --- a/pyomo/common/collections/_hasher.py +++ b/pyomo/common/collections/_hasher.py @@ -67,6 +67,11 @@ def __init__(self, *args, **kwargs): self[tuple] = self._tuple def _missing_impl(self, val): + # Inherit the hasher from a base class, if found + for _type in val.__class__.__mro__[1:]: + if _type in self: + self[val.__class__] = ans = self[_type] + return ans(val) try: hash(val) self[val.__class__] = self._hashable @@ -83,7 +88,15 @@ def _unhashable(val): return id(val) def _tuple(self, val): - return tuple(self[i.__class__](i) for i in val) + try: + # if *this tuple* is hashable, then use it as the key + hash(val) + return val + except: + # duplicate the tuple, recursively processing all fields. + # The use of val.__class__ ensures that derived things (like + # namedtuples) have their class preserved. + return val.__class__(self[i.__class__](i) for i in val) def hashable(self, obj, hashable=None): if isinstance(obj, type): From ad99ee09cbfe960d88f8b7ccfa6982d2e608fe47 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sun, 8 Feb 2026 13:27:31 -0700 Subject: [PATCH 03/29] Minor cleanup --- pyomo/common/collections/_hasher.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pyomo/common/collections/_hasher.py b/pyomo/common/collections/_hasher.py index 8711e0cd985..4b160434c07 100644 --- a/pyomo/common/collections/_hasher.py +++ b/pyomo/common/collections/_hasher.py @@ -62,6 +62,8 @@ class HashDispatcher(defaultdict): appropriate hashing strategy to each element within the tuple. """ + __slots__ = () + def __init__(self, *args, **kwargs): super().__init__(lambda: self._missing_impl, *args, **kwargs) self[tuple] = self._tuple @@ -83,10 +85,6 @@ def _missing_impl(self, val): def _hashable(val): return val - @staticmethod - def _unhashable(val): - return id(val) - def _tuple(self, val): try: # if *this tuple* is hashable, then use it as the key From 6f793d990684a78121e60fe9b2cf2c0689e8ab74 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sun, 8 Feb 2026 13:28:09 -0700 Subject: [PATCH 04/29] Make HashDispatcher callable (support use in place of id()) --- pyomo/common/collections/_hasher.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyomo/common/collections/_hasher.py b/pyomo/common/collections/_hasher.py index 4b160434c07..e80e6f43b02 100644 --- a/pyomo/common/collections/_hasher.py +++ b/pyomo/common/collections/_hasher.py @@ -108,6 +108,10 @@ def hashable(self, obj, hashable=None): return fcn is self._hashable self[cls] = self._hashable if hashable else HashKey + def __call__(self, obj): + # Make the dispatcher callable so that it can be used in place of id() + return self[obj.__class__](obj) + #: The global 'hasher' instance for managing "universal" hashing. #: From 6ba2426ff7efe0741aaddf1949d49efe252c977c Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 9 Feb 2026 21:43:40 -0700 Subject: [PATCH 05/29] Minor ComponentMap performance improvements --- pyomo/common/collections/component_map.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/pyomo/common/collections/component_map.py b/pyomo/common/collections/component_map.py index 2d7216274d0..ef8599e996b 100644 --- a/pyomo/common/collections/component_map.py +++ b/pyomo/common/collections/component_map.py @@ -10,6 +10,7 @@ # ___________________________________________________________________________ from collections.abc import Mapping, MutableMapping +from operator import itemgetter from pyomo.common.autoslots import AutoSlots @@ -59,7 +60,8 @@ def __init__(self, *args, **kwds): # maps id_hash(obj) -> (obj,val) self._dict = {} # handle the dict-style initialization scenarios - self.update(*args, **kwds) + if args or kwargs: + self.update(*args, **kwargs) def __str__(self): """String representation of the mapping.""" @@ -88,7 +90,7 @@ def __delitem__(self, obj): raise KeyError(f"{obj} (key={_id})") from None def __iter__(self): - return (obj for obj, val in self._dict.values()) + return iter(self.keys()) def __len__(self): return self._dict.__len__() @@ -135,6 +137,15 @@ def __ne__(self, other): # names. # + def keys(self): + return map(itemgetter(0), self._dict.values()) + + def values(self): + return map(itemgetter(1), self._dict.values()) + + def items(self): + return self._dict.values() + def __contains__(self, obj): return hasher[obj.__class__](obj) in self._dict From 59f7ba2993dd463771f6147207b6cc6369837eb2 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 9 Feb 2026 21:47:54 -0700 Subject: [PATCH 06/29] Support deriving from ComponentMap/ComponentSet --- pyomo/common/collections/component_map.py | 30 ++++++++++++++++------- pyomo/common/collections/component_set.py | 28 ++++++++++++--------- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/pyomo/common/collections/component_map.py b/pyomo/common/collections/component_map.py index ef8599e996b..b4cbeae64b4 100644 --- a/pyomo/common/collections/component_map.py +++ b/pyomo/common/collections/component_map.py @@ -10,6 +10,7 @@ # ___________________________________________________________________________ from collections.abc import Mapping, MutableMapping +from functools import partial from operator import itemgetter from pyomo.common.autoslots import AutoSlots @@ -17,13 +18,13 @@ from ._hasher import hasher -def _rehash_keys(encode, val): +def _rehash_keys(keygen, encode, val): if encode: - return val + return list(val.values()) else: # object id() may have changed after unpickling, # so we rebuild the dictionary keys - return {hasher[obj.__class__](obj): (obj, v) for obj, v in val.values()} + return {keygen(v[0]): v for v in val} class ComponentMap(AutoSlots.Mixin, MutableMapping): @@ -52,7 +53,7 @@ class ComponentMap(AutoSlots.Mixin, MutableMapping): """ __slots__ = ("_dict",) - __autoslot_mappers__ = {"_dict": _rehash_keys} + __autoslot_mappers__ = {"_dict": partial(_rehash_keys, hasher.__call__)} # Expose a "public" interface to the global _hasher dict hasher = hasher @@ -102,10 +103,14 @@ def __len__(self): # We want a specialization of update() to avoid unnecessary calls to # id() when copying / merging ComponentMaps def update(self, *args, **kwargs): - if len(args) == 1 and not kwargs and isinstance(args[0], ComponentMap): + if len(args) == 1 and not kwargs and args[0].__class__ is self.__class__: return self._dict.update(args[0]._dict) return super().update(*args, **kwargs) + def _rekey_items(self, items): + """Utility method for mapping key-value pairs into local hash keys""" + return ((hasher[key.__class__](key), val) for key, val in items) + # We want to avoid generating Pyomo expressions due to comparing the # keys, so look up each entry from other in this dict. def __eq__(self, other): @@ -114,11 +119,18 @@ def __eq__(self, other): if not isinstance(other, Mapping) or len(self) != len(other): return False # Note we have already verified the dicts are the same size - for key, val in other.items(): - other_id = hasher[key.__class__](key) - if other_id not in self._dict: + if other.__class__ is self.__class__: + # shortcut for comparing ComponentMaps to each other: avoid + # regenerating any keys + other_items = ((key, val[1]) for key, val in other._dict.items()) + else: + other_items = self._rekey_items(other.items()) + + _dict = self._dict + for key, val in other_items: + if key not in _dict: return False - self_val = self._dict[other_id][1] + self_val = _dict[key][1] # Note: check "is" first to help avoid creation of Pyomo # expressions (for the case that the values contain the same # Pyomo component) diff --git a/pyomo/common/collections/component_set.py b/pyomo/common/collections/component_set.py index 009794a0c80..386f7d91c57 100644 --- a/pyomo/common/collections/component_set.py +++ b/pyomo/common/collections/component_set.py @@ -10,13 +10,14 @@ # ___________________________________________________________________________ from collections.abc import MutableSet, Set +from functools import partial from pyomo.common.autoslots import AutoSlots from ._hasher import hasher -def _rehash_keys(encode, val): +def _rehash_keys(keygen, encode, val): if encode: # TBD [JDS 2/2024]: if we # @@ -28,11 +29,11 @@ def _rehash_keys(encode, val): # autoslots.fast_deepcopy, but couldn't find an obvious bug. # There is no error if we just return the original dict, or if # we return a tuple(val.values) - return val + return tuple(val.values()) else: # object id() may have changed after unpickling, # so we rebuild the dictionary keys - return {hasher[obj.__class__](obj): obj for obj in val.values()} + return {keygen(obj): obj for obj in val} class ComponentSet(AutoSlots.Mixin, MutableSet): @@ -60,7 +61,7 @@ class ComponentSet(AutoSlots.Mixin, MutableSet): """ __slots__ = ("_data",) - __autoslot_mappers__ = {"_data": _rehash_keys} + __autoslot_mappers__ = {"_data": partial(_rehash_keys, hasher.__call__)} # Expose a "public" interface to the global _hasher dict hasher = hasher @@ -80,7 +81,8 @@ def update(self, iterable): if isinstance(iterable, ComponentSet): self._data.update(iterable._data) else: - self._data.update((hasher[val.__class__](val), val) for val in iterable) + for val in iterable: + self.add(val) # # Implement MutableSet abstract methods @@ -101,9 +103,10 @@ def add(self, val): def discard(self, val): """Remove an element. Do not raise an exception if absent.""" - _id = hasher[val.__class__](val) - if _id in self._data: - del self._data[_id] + try: + del self._data[hasher[val.__class__](val)] + except KeyError: + pass # # Overload MutableSet default implementations @@ -112,11 +115,12 @@ def discard(self, val): def __eq__(self, other): if self is other: return True - if not isinstance(other, Set): + if not isinstance(other, Set) or len(self._data) != len(other): return False - return len(self) == len(other) and all( - hasher[val.__class__](val) in self._data for val in other - ) + if other.__class__ is self.__class__: + return all(key in self._data for key in other._data) + else: + return all(map(self.__contains__, other)) def __ne__(self, other): return not (self == other) From fe4d4da07b7220e88bcad5c25d825c4efb1d15d0 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 9 Feb 2026 21:49:33 -0700 Subject: [PATCH 07/29] Standardize str() and KeyError for COmponentMap/Set --- pyomo/common/collections/component_map.py | 10 ++++------ pyomo/common/collections/component_set.py | 7 +++---- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/pyomo/common/collections/component_map.py b/pyomo/common/collections/component_map.py index b4cbeae64b4..e814e415721 100644 --- a/pyomo/common/collections/component_map.py +++ b/pyomo/common/collections/component_map.py @@ -66,8 +66,8 @@ def __init__(self, *args, **kwds): def __str__(self): """String representation of the mapping.""" - tmp = {f"{v[0]} (key={k})": v[1] for k, v in self._dict.items()} - return f"ComponentMap({tmp})" + tmp = ', '.join(f"{v[0]}: {v[1]}" for v in self._dict.values()) + return f"{self.__class__.__name__}({tmp})" # # Implement MutableMapping abstract methods @@ -77,8 +77,7 @@ def __getitem__(self, obj): try: return self._dict[hasher[obj.__class__](obj)][1] except KeyError: - _id = hasher[obj.__class__](obj) - raise KeyError(f"{obj} (key={_id})") from None + raise KeyError(obj) from None def __setitem__(self, obj, val): self._dict[hasher[obj.__class__](obj)] = (obj, val) @@ -87,8 +86,7 @@ def __delitem__(self, obj): try: del self._dict[hasher[obj.__class__](obj)] except KeyError: - _id = hasher[obj.__class__](obj) - raise KeyError(f"{obj} (key={_id})") from None + raise KeyError(obj) from None def __iter__(self): return iter(self.keys()) diff --git a/pyomo/common/collections/component_set.py b/pyomo/common/collections/component_set.py index 386f7d91c57..fff3c661e8e 100644 --- a/pyomo/common/collections/component_set.py +++ b/pyomo/common/collections/component_set.py @@ -73,8 +73,8 @@ def __init__(self, iterable=None): def __str__(self): """String representation of the mapping.""" - tmp = [f"{v} (key={k})" for k, v in self._data.items()] - return f"ComponentSet({tmp})" + tmp = (str(k) for k in self._data.values()) + return f"{self.__class__.__name__}({', '.join(tmp)})" def update(self, iterable): """Update a set with the union of itself and others.""" @@ -139,5 +139,4 @@ def remove(self, val): try: del self._data[hasher[val.__class__](val)] except KeyError: - _id = hasher[val.__class__](val) - raise KeyError(f"{val} (key={_id})") from None + raise KeyError(val) From c2238d31cb7b1474c2dba73399950d9942c25177 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 9 Feb 2026 21:50:11 -0700 Subject: [PATCH 08/29] Add ObjectIdMap/ObjectIdSet --- pyomo/common/collections/component_map.py | 55 +++++++++++++++++++++++ pyomo/common/collections/component_set.py | 42 +++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/pyomo/common/collections/component_map.py b/pyomo/common/collections/component_map.py index e814e415721..38794e661f1 100644 --- a/pyomo/common/collections/component_map.py +++ b/pyomo/common/collections/component_map.py @@ -205,3 +205,58 @@ def __getitem__(self, obj): return self._dict[_key][1] else: return self.__missing__(obj) + + +class ObjectIdMap(ComponentMap): + """A faster version of :py:class:`ComponentMap` + + :py:class:`ObjectIdMap` is a lighter-weight version of + :py:class:`ComponentMap`. By unconditionally using :py:`id()` to + generate all keys, this class performs approximately 25% faster than + :py:class:`ComponentMap` at the expense of being slightly more + fragile. + + It is _strongly_ recommended to only use Pyomo components as + :py:class:`ObjectIdMap` keys. + + .. warning:: + + Do not store keys that do not return persistent :py:func:`id()` + values. In particular, avoid certain immutable data types like + :py:`tuple` objects, strings, and long integers. Doing so may + result in failed lookups or duplicate entries. + + If you want to mix these keys with other unhashable objects (like + Pyomo :py:class:`Var` or :py:class:`Param` components), please + use :py:class:`ComponentMap`. + + """ + + __slots__ = () + __autoslot_mappers__ = {"_dict": partial(_rehash_keys, id)} + + def __getitem__(self, obj): + try: + return self._dict[id(obj)][1] + except KeyError: + raise KeyError(obj) from None + + def __setitem__(self, obj, val): + self._dict[id(obj)] = (obj, val) + + def __delitem__(self, obj): + try: + del self._dict[id(obj)] + except KeyError: + raise KeyError(obj) from None + + def __contains__(self, obj): + return id(obj) in self._dict + + def _rekey_items(self, items): + return ((id(key), val) for key, val in items) + + def __str__(self): + """String representation of the mapping.""" + tmp = [f"{v[0]} (key={k}): {v[1]}" for k, v in self._dict.items()] + return f"{self.__class__.__name__}({', '.join(tmp)})" diff --git a/pyomo/common/collections/component_set.py b/pyomo/common/collections/component_set.py index fff3c661e8e..b4b8a6fbfe7 100644 --- a/pyomo/common/collections/component_set.py +++ b/pyomo/common/collections/component_set.py @@ -140,3 +140,45 @@ def remove(self, val): del self._data[hasher[val.__class__](val)] except KeyError: raise KeyError(val) + + +class ObjectIdSet(ComponentSet): + """A faster version of :py:class:`ComponentSet` + + :py:class:`ObjectIdSet` is a lighter-weight version of + :py:class:`ComponentSet`. By unconditionally using :py:`id()` to + generate all keys, this class performs approximately 25% faster than + :py:class:`ComponentSet` at the expense of being slightly more + fragile. In particular, :py:`tuple` keys may unexpectedly generate + cache misses or result in multiple entries in the set. + + .. warning:: + + Do not store :py:`tuple` objects as keys in this data structure. + Doing so may result in failed lookups or duplicate entries. + + """ + + __slots__ = () + __autoslot_mappers__ = {"_data": partial(_rehash_keys, id)} + + def __contains__(self, val): + return id(val) in self._data + + def add(self, val): + """Add an element.""" + self._data[id(val)] = val + + def discard(self, val): + """Remove an element. Do not raise an exception if absent.""" + try: + del self._data[id(val)] + except KeyError: + pass + + def remove(self, val): + """Remove an element. If not a member, raise a KeyError.""" + try: + del self._data[id(val)] + except KeyError: + raise KeyError(val) from None From e60cabb309fbe9d50aeaa99543917b449bda3c33 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 9 Feb 2026 21:51:35 -0700 Subject: [PATCH 09/29] Minor code cleanup/fixes --- pyomo/common/collections/component_map.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyomo/common/collections/component_map.py b/pyomo/common/collections/component_map.py index 38794e661f1..d228a673ffa 100644 --- a/pyomo/common/collections/component_map.py +++ b/pyomo/common/collections/component_map.py @@ -57,7 +57,7 @@ class ComponentMap(AutoSlots.Mixin, MutableMapping): # Expose a "public" interface to the global _hasher dict hasher = hasher - def __init__(self, *args, **kwds): + def __init__(self, *args, **kwargs): # maps id_hash(obj) -> (obj,val) self._dict = {} # handle the dict-style initialization scenarios @@ -137,7 +137,8 @@ def __eq__(self, other): return True def __ne__(self, other): - return not (self == other) + """Return self!=other.""" + return not self.__eq__(other) # # The remaining methods have slow default From e3c8cbdc7f89502c5b4dbbf38b8063afedc91e3e Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 9 Feb 2026 21:52:05 -0700 Subject: [PATCH 10/29] NFC: update comments/docstrings --- pyomo/common/collections/component_map.py | 46 +++++++++++------------ pyomo/common/collections/component_set.py | 35 ++++++++--------- 2 files changed, 40 insertions(+), 41 deletions(-) diff --git a/pyomo/common/collections/component_map.py b/pyomo/common/collections/component_map.py index d228a673ffa..e692a39bfc8 100644 --- a/pyomo/common/collections/component_map.py +++ b/pyomo/common/collections/component_map.py @@ -28,33 +28,34 @@ def _rehash_keys(keygen, encode, val): class ComponentMap(AutoSlots.Mixin, MutableMapping): - """ - This class is a replacement for dict that allows Pyomo - modeling components to be used as entry keys. The - underlying mapping is based on the Python id() of the - object, which gets around the problem of hashing - subclasses of NumericValue. This class is meant for - creating mappings from Pyomo components to values. The - use of non-Pyomo components as entry keys should be - avoided. + """Mapping that admits unhashable objects as keys + + This class is a replacement for :py:`dict` that allows Pyomo + modeling components to be used as keys. The underlying mapping is + based on the Python :py:`id()` of the object, which gets around the + problem of hashing subclasses of :py:class:`NumericValue`. This + class is meant for creating mappings from Pyomo components to + values. A reference to the object is kept around as long as it has a corresponding entry in the container, so there is - no need to worry about id() clashes. + no need to worry about id() collisions. + + This class leverages :py:class:`AutoSlots` to update any id() keys + during pickling, restoration, or deepcopying. + + .. warning:: - We also override __setstate__ so that we can rebuild the - container based on possibly updated object ids after - a deepcopy or pickle. + An instance of this class should never be deepcopied/pickled + unless it is done so along with its component entries (e.g., as + part of a block). - *** An instance of this class should never be - deepcopied/pickled unless it is done so along with the - components for which it contains map entries (e.g., as - part of a block). *** """ __slots__ = ("_dict",) __autoslot_mappers__ = {"_dict": partial(_rehash_keys, hasher.__call__)} - # Expose a "public" interface to the global _hasher dict + # Expose a "public" interface to the global hasher dict (for + # backwards compatibility) hasher = hasher def __init__(self, *args, **kwargs): @@ -99,7 +100,7 @@ def __len__(self): # # We want a specialization of update() to avoid unnecessary calls to - # id() when copying / merging ComponentMaps + # the hasher when copying / merging ComponentMaps def update(self, *args, **kwargs): if len(args) == 1 and not kwargs and args[0].__class__ is self.__class__: return self._dict.update(args[0]._dict) @@ -112,6 +113,7 @@ def _rekey_items(self, items): # We want to avoid generating Pyomo expressions due to comparing the # keys, so look up each entry from other in this dict. def __eq__(self, other): + """Return self==other.""" if self is other: return True if not isinstance(other, Mapping) or len(self) != len(other): @@ -141,11 +143,7 @@ def __ne__(self, other): return not self.__eq__(other) # - # The remaining methods have slow default - # implementations for MutableMapping. In particular, - # they rely KeyError catching, which is slow for this - # class because KeyError messages use fully qualified - # names. + # The remaining methods have slow default implementations # def keys(self): diff --git a/pyomo/common/collections/component_set.py b/pyomo/common/collections/component_set.py index b4b8a6fbfe7..5532fde27f5 100644 --- a/pyomo/common/collections/component_set.py +++ b/pyomo/common/collections/component_set.py @@ -37,32 +37,33 @@ def _rehash_keys(keygen, encode, val): class ComponentSet(AutoSlots.Mixin, MutableSet): - """ - This class is a replacement for set that allows Pyomo - modeling components to be used as entries. The - underlying hash is based on the Python id() of the - object, which gets around the problem of hashing - subclasses of NumericValue. This class is meant for - creating sets of Pyomo components. The use of non-Pyomo - components as entries should be avoided (as the behavior - is undefined). + """Set that admits unhashable objects. + + This class is a replacement for :py:`set` that allows Pyomo modeling + components to be used as entries. The underlying hash is based on + the Python :py:`id()` of the object, which gets around the problem + of hashing subclasses of :py:class:`NumericValue`. This class is + meant for creating sets of Pyomo components. References to objects are kept around as long as they are entries in the container, so there is no need to - worry about id() clashes. + worry about id() collisions. + + This class leverages :py:class:`AutoSlots` to update any id() keys + during pickling, restoration, or deepcopying. + + .. warning:: - We also override __setstate__ so that we can rebuild the - container based on possibly updated object ids after - a deepcopy or pickle. + An instance of this class should never be deepcopied/pickled + unless it is done so along with its component entries (e.g., as + part of a block). - *** An instance of this class should never be - deepcopied/pickled unless it is done so along with - its component entries (e.g., as part of a block). *** """ __slots__ = ("_data",) __autoslot_mappers__ = {"_data": partial(_rehash_keys, hasher.__call__)} - # Expose a "public" interface to the global _hasher dict + # Expose a "public" interface to the global hasher dict (for + # backwards compatibility) hasher = hasher def __init__(self, iterable=None): From 9b3e0366ffcd4ad5f5e7adfb9c1e442b4fbab571 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 9 Feb 2026 21:53:48 -0700 Subject: [PATCH 11/29] Support cleaner fallback to ComponentMap when first positional argument is not callable --- pyomo/common/collections/component_map.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyomo/common/collections/component_map.py b/pyomo/common/collections/component_map.py index e692a39bfc8..ffba6168794 100644 --- a/pyomo/common/collections/component_map.py +++ b/pyomo/common/collections/component_map.py @@ -189,6 +189,9 @@ class DefaultComponentMap(ComponentMap): __slots__ = ("default_factory",) def __init__(self, default_factory=None, *args, **kwargs): + if default_factory is not None and not callable(default_factory): + args = (default_factory,) + args + default_factory = None super().__init__(*args, **kwargs) self.default_factory = default_factory From bfd50926e6f268f44c68a38e92be73dfa5ca5136 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 9 Feb 2026 21:54:22 -0700 Subject: [PATCH 12/29] Add / update tests --- pyomo/common/tests/test_component_map.py | 352 ++++++++++++++++-- pyomo/common/tests/test_component_set.py | 211 +++++++++++ .../tests/unit/kernel/test_component_map.py | 2 +- .../tests/unit/kernel/test_component_set.py | 2 +- 4 files changed, 541 insertions(+), 26 deletions(-) create mode 100644 pyomo/common/tests/test_component_set.py diff --git a/pyomo/common/tests/test_component_map.py b/pyomo/common/tests/test_component_map.py index 13cb49c06e1..79116b6780b 100644 --- a/pyomo/common/tests/test_component_map.py +++ b/pyomo/common/tests/test_component_map.py @@ -9,18 +9,185 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import pickle import pyomo.common.unittest as unittest -from pyomo.common.collections import ComponentMap, ComponentSet, DefaultComponentMap -from pyomo.environ import ConcreteModel, Block, Var, Constraint +from pyomo.common.collections._hasher import HashKey +from pyomo.common.collections.component_map import ( + ComponentMap, + DefaultComponentMap, + ObjectIdMap, +) +from pyomo.common.collections.component_set import ComponentSet +from pyomo.environ import ConcreteModel, Block, Var, Constraint, Param -class TestComponentMap(unittest.TestCase): +class ComponentMapBaseTests: + + def test_str(self): + m = ConcreteModel() + m.x = Var() + m.y = Param([1], mutable=True) + cm = self.CM() + cm[m.x] = m.y[1] + _id = id(m.x) + cm[_id] = 42 + cm[(5, m.x)] = 7 + self.assertEqual( + f"{self.CM.__name__}" + f"(x (key={_id}): y[1], {_id}: 42, (5, x (key={_id})): 7)", + str(cm), + ) + + def test_get_del_item(self): + m = ConcreteModel() + m.x = Var() + + cm = self.CM() + cm[m] = 10 + cm[m.x] = 20 + cm[3] = 30 + self.assertEqual(3, len(cm)) + self.assertEqual(cm[m], 10) + self.assertEqual(cm[m.x], 20) + self.assertEqual(cm[3], 30) + + del cm[m.x] + self.assertEqual(2, len(cm)) + self.assertEqual(cm[m], 10) + self.assertEqual(cm[3], 30) + + self.assertEqual(cm.get(m), 10) + self.assertEqual(cm.get(m, 100), 10) + self.assertEqual(cm.get(m.x), None) + self.assertEqual(cm.get(m.x, 100), 100) + self.assertEqual(cm.get(3), 30) + self.assertEqual(cm.get(3, 100), 30) + + with self.assertRaisesRegex(KeyError, repr(m.x)): + cm[m.x] + + with self.assertRaisesRegex(KeyError, repr(m.x)): + del cm[m.x] + + self.assertEqual(2, len(cm)) + self.assertEqual(cm[m], 10) + self.assertEqual(cm[3], 30) + + def test_iter(self): + m = ConcreteModel() + m.x = Var() + + cm = self.CM() + cm[m] = 10 + cm[m.x] = 20 + cm[3] = 30 + + self.assertEqual([m, m.x, 3], list(cm)) + self.assertEqual([m, m.x, 3], list(cm.keys())) + self.assertEqual([10, 20, 30], list(cm.values())) + self.assertEqual([(m, 10), (m.x, 20), (3, 30)], list(cm.items())) + + def test_eq(self): + m = ConcreteModel() + m.x = Var() + m.y = Var() + m.c = Constraint() + + cm1 = self.CM() + cm1[m] = 10 + cm1[m.x] = 20 + cm1[m.c] = 30 + + self.assertEqual(cm1, cm1) + + cm2 = self.CM() + cm2[m] = 10 + cm2[m.c] = 30 + self.assertNotEqual(cm1, cm2) + + cm2[m.y] = 20 + self.assertNotEqual(cm1, cm2) + + del cm2[m.y] + cm2[m.x] = 20 + self.assertEqual(cm1, cm2) + + self.assertNotEqual(cm1, {m: 10, m.c: 30}) + del cm1[m.x] + self.assertEqual(cm1, {m: 10, m.c: 30}) + self.assertNotEqual(cm1, {m: 10, m.c: 40}) + + def test_init_update(self): + m = ConcreteModel() + m.x = Var() + m.c = Constraint() + + cm1 = self.CM() + cm1[m] = 10 + cm1[m.x] = 20 + cm1[m.c] = 30 + + cm2 = self.CM(cm1) + self.assertIsNot(cm1, cm2) + self.assertIsNot(cm1._dict, cm2._dict) + self.assertEqual(cm1, cm2) + + cm3 = self.CM({m: 10, m.c: 30}) + del cm2[m.x] + self.assertEqual(cm2, cm3) + + cm3.update(cm1) + self.assertNotEqual(cm2, cm3) + self.assertEqual(cm1, cm3) + + def test_set_default(self): + m = ConcreteModel() + m.x = Var() + m.c = Constraint() + + cm = self.CM() + self.assertIs(cm.setdefault(m, m.x), m.x) + self.assertEqual(cm, {m: m.x}) + self.assertIs(cm.setdefault(m, m.c), m.x) + self.assertEqual(cm, {m: m.x}) + + cm.clear() + self.assertEqual(cm, {}) + self.assertIs(cm.setdefault(m, m.c), m.c) + self.assertEqual(cm, {m: m.c}) + + +class TestComponentMap(ComponentMapBaseTests, unittest.TestCase): + def setUp(self): + self.CM = ComponentMap + + def test_hasher(self): + m = self.CM() + a = 'str' + m[a] = 5 + self.assertTrue(m.hasher.hashable(a)) + self.assertTrue(m.hasher.hashable(str)) + self.assertEqual(m._dict, {a: (a, 5)}) + del m[a] + + m.hasher.hashable(a, False) + m[a] = 5 + self.assertFalse(m.hasher.hashable(a)) + self.assertFalse(m.hasher.hashable(str)) + self.assertEqual(m._dict, {HashKey(a): (a, 5)}) + + class TMP: + pass + + with self.assertRaises(KeyError): + m.hasher.hashable(TMP) + def test_tuple(self): m = ConcreteModel() m.v = Var() m.c = Constraint(expr=m.v >= 0) - m.cm = cm = ComponentMap() + m.cm = cm = self.CM() cm[(1, 2)] = 5 self.assertEqual(len(cm), 1) @@ -49,31 +216,81 @@ def test_tuple(self): self.assertIn((1, (2, m.v)), m.cm) self.assertNotIn((1, (2, m.v)), i.cm) - def test_hasher(self): - m = ComponentMap() - a = 'str' - m[a] = 5 - self.assertTrue(m.hasher.hashable(a)) - self.assertTrue(m.hasher.hashable(str)) - self.assertEqual(m._dict, {a: (a, 5)}) - del m[a] + def test_id_int_collision(self): + m = ConcreteModel() + m.x = Var() + cm = self.CM() - m.hasher.hashable(a, False) - m[a] = 5 - self.assertFalse(m.hasher.hashable(a)) - self.assertFalse(m.hasher.hashable(str)) - self.assertEqual(m._dict, {id(a): (a, 5)}) + cm[m.x] = 1 + cm[id(m.x)] = 2 + self.assertEqual(len(cm), 2) + self.assertIn(m.x, cm) + self.assertIn(id(m.x), cm) # Note: different from ObjectIdMap + self.assertEqual(cm[m.x], 1) - class TMP: - pass + a = (1, (m.x, 3)) + b = (1, (m.x, 3)) + self.assertNotEqual(id(a), id(b)) - with self.assertRaises(KeyError): - m.hasher.hashable(TMP) + cm[a] = 3 + cm[b] = 4 + self.assertEqual(len(cm), 3) # Note: different from ObjectIdMap + self.assertIn(a, cm) + self.assertIn(b, cm) + self.assertEqual(cm[a], 4) # Note: different from ObjectIdMap + self.assertEqual(cm[b], 4) + self.assertIn((1, (m.x, 3)), cm) # Note: different from ObjectIdMap + def test_pickle(self): + m = ConcreteModel() + m.x = Var() + m.c = Constraint() + + cm = self.CM() + cm[1] = 10 + cm[m.x] = 20 + cm[(1, (2, m.x))] = 30 + cm[m.c] = 40 + m.cm = cm + + i = pickle.loads(pickle.dumps(m)) + self.assertIsNot(i, m) + self.assertIsNot(i.cm, m.cm) + self.assertIn(1, i.cm) + self.assertEqual(i.cm[1], 10) + self.assertNotIn(m.x, i.cm) + self.assertIn(i.x, i.cm) + self.assertEqual(i.cm[i.x], 20) + self.assertNotIn((1, (2, m.x)), i.cm) + self.assertIn((1, (2, i.x)), i.cm) + self.assertEqual(i.cm[(1, (2, i.x))], 30) + self.assertNotIn(m.c, i.cm) + self.assertIn(i.c, i.cm) + self.assertEqual(i.cm[i.c], 40) + + _items = iter(i.cm._dict.items()) + k, v = next(_items) + self.assertEqual(k, 1) + self.assertEqual(v, (1, 10)) + k, v = next(_items) + self.assertEqual(k, HashKey(i.x)) + self.assertEqual(v, (i.x, 20)) + self.assertEqual(k._hash, id(i.x)) + k, v = next(_items) + self.assertEqual(k, (1, (2, HashKey(i.x)))) + self.assertEqual(v, ((1, (2, i.x)), 30)) + self.assertEqual(k[1][1]._hash, id(i.x)) + k, v = next(_items) + self.assertEqual(k, i.c) + self.assertEqual(v, (i.c, 40)) + + +class TestDefaultComponentMap(ComponentMapBaseTests, unittest.TestCase): + def setUp(self): + self.CM = DefaultComponentMap -class TestDefaultComponentMap(unittest.TestCase): def test_default_component_map(self): - dcm = DefaultComponentMap(ComponentSet) + dcm = self.CM(ComponentSet) m = ConcreteModel() m.x = Var() @@ -100,7 +317,7 @@ def test_default_component_map(self): self.assertIn(m.b, dcm[m.b.y]) def test_no_default_factory(self): - dcm = DefaultComponentMap() + dcm = self.CM() dcm['found'] = 5 self.assertEqual(len(dcm), 1) @@ -109,3 +326,90 @@ def test_no_default_factory(self): with self.assertRaisesRegex(KeyError, "'missing'"): dcm["missing"] + + +class TestObjectIdMap(ComponentMapBaseTests, unittest.TestCase): + def setUp(self): + self.CM = ObjectIdMap + + def test_str(self): + m = ConcreteModel() + m.x = Var() + m.y = Param([1], mutable=True) + cm = self.CM() + cm[m.x] = m.y[1] + _id = id(m.x) + cm[_id] = 42 + _idid = id(_id) + _tup = (5, m.x) + cm[_tup] = 7 + self.assertEqual( + f"{self.CM.__name__}" + f"(x (key={_id}): y[1], {_id} (key={_idid}): 42, {_tup} (key={id(_tup)}): 7)", + str(cm), + ) + + def test_id_int_collision(self): + m = ConcreteModel() + m.x = Var() + cm = self.CM() + + cm[m.x] = 1 + cm[id(m.x)] = 2 + self.assertEqual(len(cm), 2) + self.assertIn(m.x, cm) + self.assertNotIn(id(m.x), cm) # Note: different from ComponentMap + self.assertEqual(cm[m.x], 1) + + a = (1, (m.x, 3)) + b = (1, (m.x, 3)) + self.assertNotEqual(id(a), id(b)) + + cm[a] = 3 + cm[b] = 4 + self.assertEqual(len(cm), 4) # Note: different from ComponentMap + self.assertIn(a, cm) + self.assertIn(b, cm) + self.assertEqual(cm[a], 3) # Note: different from ComponentMap + self.assertEqual(cm[b], 4) + self.assertNotIn((1, (m.x, 3)), cm) # Note: different from ComponentMap + + def test_pickle(self): + m = ConcreteModel() + m.x = Var() + m.c = Constraint() + + cm = self.CM() + cm[1] = 10 + cm[m.x] = 20 + cm[(1, (2, m.x))] = 30 + cm[m.c] = 40 + m.cm = cm + + i = pickle.loads(pickle.dumps(m)) + self.assertIsNot(i, m) + self.assertIsNot(i.cm, m.cm) + self.assertIn(1, i.cm) + self.assertEqual(i.cm[1], 10) + self.assertNotIn(m.x, i.cm) + self.assertIn(i.x, i.cm) + self.assertEqual(i.cm[i.x], 20) + self.assertNotIn((1, (2, m.x)), i.cm) + self.assertNotIn((1, (2, i.x)), i.cm) # Note: different from ComponentMap + self.assertNotIn(m.c, i.cm) + self.assertIn(i.c, i.cm) + self.assertEqual(i.cm[i.c], 40) + + _items = iter(i.cm._dict.items()) + k, v = next(_items) + self.assertEqual(k, id(1)) + self.assertEqual(v, (1, 10)) + k, v = next(_items) + self.assertEqual(k, id(i.x)) + self.assertEqual(v, (i.x, 20)) + k, v = next(_items) + self.assertEqual(k, id(v[0])) + self.assertEqual(v, ((1, (2, i.x)), 30)) + k, v = next(_items) + self.assertEqual(k, id(i.c)) + self.assertEqual(v, (i.c, 40)) diff --git a/pyomo/common/tests/test_component_set.py b/pyomo/common/tests/test_component_set.py new file mode 100644 index 00000000000..b4697620361 --- /dev/null +++ b/pyomo/common/tests/test_component_set.py @@ -0,0 +1,211 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import pickle +import pyomo.common.unittest as unittest + +from pyomo.common.collections._hasher import HashKey +from pyomo.common.collections.component_set import ComponentSet, ObjectIdSet +from pyomo.environ import ConcreteModel, Var, Constraint + +class ComponentSetBaseTests: + + def test_str(self): + m = ConcreteModel() + m.x = Var() + cs = self.CS() + cs.add(m.x) + _id = id(m.x) + cs.add(_id) + cs.add((5, m.x)) + self.assertEqual( + f"{self.CS.__name__}" + f"(x, {_id}, (5, {repr(m.x)}))", + str(cs), + ) + + def test_add_remove_discard(self): + m = ConcreteModel() + m.x = Var() + + cs = self.CS() + cs.add(m) + cs.add(m.x) + cs.add(3) + self.assertEqual(3, len(cs)) + self.assertIn(m, cs) + self.assertIn(m.x, cs) + self.assertIn(3, cs) + + # Re-adding doesn't change anything + cs.add(m) + cs.add(m.x) + cs.add(3) + self.assertEqual(3, len(cs)) + self.assertIn(m, cs) + self.assertIn(m.x, cs) + self.assertIn(3, cs) + + cs.remove(m.x) + self.assertEqual(2, len(cs)) + self.assertIn(m, cs) + self.assertIn(3, cs) + + with self.assertRaisesRegex(KeyError, repr(m.x)): + cs.remove(m.x) + cs.discard(m.x) + self.assertEqual(2, len(cs)) + self.assertIn(m, cs) + self.assertIn(3, cs) + + cs.discard(m) + self.assertEqual(1, len(cs)) + self.assertIn(3, cs) + + def test_iter(self): + m = ConcreteModel() + m.x = Var() + + cs = self.CS([m, m.x, 3]) + self.assertEqual([m, m.x, 3], list(cs)) + + def test_eq(self): + m = ConcreteModel() + m.x = Var() + m.y = Var() + m.c = Constraint() + + cs1 = self.CS([m, m.x, m.c]) + self.assertEqual(cs1, cs1) + + cs2 = self.CS([m, m.c]) + self.assertNotEqual(cs1, cs2) + + cs2.add(m.y) + self.assertNotEqual(cs1, cs2) + + cs2.remove(m.y) + cs2.add(m.x) + self.assertEqual(cs1, cs2) + + self.assertNotEqual(cs1, {m, m.c}) + cs1.remove(m.x) + self.assertEqual(cs1, {m, m.c}) + + def test_clear(self): + m = ConcreteModel() + m.x = Var() + m.c = Constraint() + + cs1 = self.CS([m, m.x, m.c]) + cs2 = self.CS(cs1) + self.assertEqual(cs1, cs2) + cs1.clear() + self.assertNotEqual(cs1, cs2) + self.assertEqual(cs1, set()) + + def test_init_update(self): + m = ConcreteModel() + m.x = Var() + m.c = Constraint() + + cs1 = self.CS([m, m.x, m.c]) + + cs2 = self.CS(cs1) + self.assertIsNot(cs1, cs2) + self.assertIsNot(cs1._data, cs2._data) + self.assertEqual(cs1, cs2) + + cs3 = self.CS({m, m.c}) + cs2.discard(m.x) + self.assertEqual(cs2, cs3) + + cs3.update(cs1) + self.assertNotEqual(cs2, cs3) + self.assertEqual(cs1, cs3) + + +class TestComponentSet(ComponentSetBaseTests, unittest.TestCase): + def setUp(self): + self.CS = ComponentSet + + def test_pickle(self): + m = ConcreteModel() + m.x = Var() + m.c = Constraint() + + cs = self.CS([1, m.x, (1, (2, m.x)), m.c]) + m.cs = cs + + i = pickle.loads(pickle.dumps(m)) + self.assertIsNot(i, m) + self.assertIsNot(i.cs, m.cs) + self.assertIn(1, i.cs) + self.assertNotIn(m.x, i.cs) + self.assertIn(i.x, i.cs) + self.assertNotIn((1, (2, m.x)), i.cs) + self.assertIn((1, (2, i.x)), i.cs) + self.assertNotIn(m.c, i.cs) + self.assertIn(i.c, i.cs) + + _items = iter(i.cs._data.items()) + k, v = next(_items) + self.assertEqual(k, 1) + self.assertEqual(v, 1) + k, v = next(_items) + self.assertEqual(k, HashKey(i.x)) + self.assertEqual(v, i.x) + self.assertEqual(k._hash, id(i.x)) + k, v = next(_items) + self.assertEqual(k, (1, (2, HashKey(i.x)))) + self.assertEqual(v, (1, (2, i.x))) + self.assertEqual(k[1][1]._hash, id(i.x)) + k, v = next(_items) + self.assertEqual(k, i.c) + self.assertEqual(v, i.c) + + +class TestObjectIdSet(ComponentSetBaseTests, unittest.TestCase): + def setUp(self): + self.CS = ObjectIdSet + + def test_pickle(self): + m = ConcreteModel() + m.x = Var() + m.c = Constraint() + + cs = self.CS([1, m.x, (1, (2, m.x)), m.c]) + m.cs = cs + + i = pickle.loads(pickle.dumps(m)) + self.assertIsNot(i, m) + self.assertIsNot(i.cs, m.cs) + self.assertIn(1, i.cs) # Note: different from ComponentMap + self.assertNotIn(m.x, i.cs) + self.assertIn(i.x, i.cs) + self.assertNotIn((1, (2, m.x)), i.cs) + self.assertNotIn((1, (2, i.x)), i.cs) # Note: different from ComponentMap + self.assertNotIn(m.c, i.cs) + self.assertIn(i.c, i.cs) + + _items = iter(i.cs._data.items()) + k, v = next(_items) + self.assertEqual(k, id(v)) + self.assertEqual(v, 1) + k, v = next(_items) + self.assertEqual(k, id(i.x)) + self.assertEqual(v, i.x) + k, v = next(_items) + self.assertEqual(k, id(v)) + self.assertEqual(v, (1, (2, i.x))) + k, v = next(_items) + self.assertEqual(k, id(i.c)) + self.assertEqual(v, i.c) diff --git a/pyomo/core/tests/unit/kernel/test_component_map.py b/pyomo/core/tests/unit/kernel/test_component_map.py index 3a99b4ee54b..edb065cffcc 100644 --- a/pyomo/core/tests/unit/kernel/test_component_map.py +++ b/pyomo/core/tests/unit/kernel/test_component_map.py @@ -102,7 +102,7 @@ def test_type(self): def test_str(self): cmap = ComponentMap() - self.assertEqual(str(cmap), "ComponentMap({})") + self.assertEqual(str(cmap), "ComponentMap()") cmap.update(self._components) str(cmap) diff --git a/pyomo/core/tests/unit/kernel/test_component_set.py b/pyomo/core/tests/unit/kernel/test_component_set.py index 92f1671138b..01680658d83 100644 --- a/pyomo/core/tests/unit/kernel/test_component_set.py +++ b/pyomo/core/tests/unit/kernel/test_component_set.py @@ -103,7 +103,7 @@ def test_type(self): def test_str(self): cset = ComponentSet() - self.assertEqual(str(cset), "ComponentSet([])") + self.assertEqual(str(cset), "ComponentSet()") cset.update(self._components) str(cset) From a865f03a40c56680d3006ef87904e2b514324acb Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 9 Feb 2026 21:57:15 -0700 Subject: [PATCH 13/29] NFC: apply black --- pyomo/common/tests/test_component_set.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/pyomo/common/tests/test_component_set.py b/pyomo/common/tests/test_component_set.py index b4697620361..e7c4c8d0831 100644 --- a/pyomo/common/tests/test_component_set.py +++ b/pyomo/common/tests/test_component_set.py @@ -16,6 +16,7 @@ from pyomo.common.collections.component_set import ComponentSet, ObjectIdSet from pyomo.environ import ConcreteModel, Var, Constraint + class ComponentSetBaseTests: def test_str(self): @@ -26,11 +27,7 @@ def test_str(self): _id = id(m.x) cs.add(_id) cs.add((5, m.x)) - self.assertEqual( - f"{self.CS.__name__}" - f"(x, {_id}, (5, {repr(m.x)}))", - str(cs), - ) + self.assertEqual(f"{self.CS.__name__}" f"(x, {_id}, (5, {repr(m.x)}))", str(cs)) def test_add_remove_discard(self): m = ConcreteModel() @@ -111,7 +108,7 @@ def test_clear(self): cs1.clear() self.assertNotEqual(cs1, cs2) self.assertEqual(cs1, set()) - + def test_init_update(self): m = ConcreteModel() m.x = Var() @@ -131,7 +128,7 @@ def test_init_update(self): cs3.update(cs1) self.assertNotEqual(cs2, cs3) self.assertEqual(cs1, cs3) - + class TestComponentSet(ComponentSetBaseTests, unittest.TestCase): def setUp(self): From c8823a710a28e0c77af0c7936c7b8acb873304fc Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 9 Feb 2026 23:04:13 -0700 Subject: [PATCH 14/29] Avoid resolving expressions created during ComponentMap comparisons --- pyomo/common/collections/component_map.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyomo/common/collections/component_map.py b/pyomo/common/collections/component_map.py index ffba6168794..a881bbc647d 100644 --- a/pyomo/common/collections/component_map.py +++ b/pyomo/common/collections/component_map.py @@ -134,8 +134,10 @@ def __eq__(self, other): # Note: check "is" first to help avoid creation of Pyomo # expressions (for the case that the values contain the same # Pyomo component) - if self_val is not val and self_val != val: - return False + if self_val is not val: + val_diff = self_val != val + if val_diff.__class__ is not bool or val_diff: + return False return True def __ne__(self, other): From dcee582feba3630d8958f8229d2312a5b4951ba9 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 9 Feb 2026 23:46:44 -0700 Subject: [PATCH 15/29] Generate more human-readable str(ComponentMap)/str(COmponentSet) --- pyomo/common/collections/component_map.py | 3 ++- pyomo/common/collections/component_set.py | 3 ++- pyomo/common/tests/test_component_map.py | 2 +- pyomo/common/tests/test_component_set.py | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pyomo/common/collections/component_map.py b/pyomo/common/collections/component_map.py index a881bbc647d..22925b979f2 100644 --- a/pyomo/common/collections/component_map.py +++ b/pyomo/common/collections/component_map.py @@ -14,6 +14,7 @@ from operator import itemgetter from pyomo.common.autoslots import AutoSlots +from pyomo.common.formatting import tostr from ._hasher import hasher @@ -67,7 +68,7 @@ def __init__(self, *args, **kwargs): def __str__(self): """String representation of the mapping.""" - tmp = ', '.join(f"{v[0]}: {v[1]}" for v in self._dict.values()) + tmp = ', '.join(f"{tostr(v[0])}: {tostr(v[1])}" for v in self._dict.values()) return f"{self.__class__.__name__}({tmp})" # diff --git a/pyomo/common/collections/component_set.py b/pyomo/common/collections/component_set.py index 5532fde27f5..f9600b43cf5 100644 --- a/pyomo/common/collections/component_set.py +++ b/pyomo/common/collections/component_set.py @@ -13,6 +13,7 @@ from functools import partial from pyomo.common.autoslots import AutoSlots +from pyomo.common.formatting import tostr from ._hasher import hasher @@ -74,7 +75,7 @@ def __init__(self, iterable=None): def __str__(self): """String representation of the mapping.""" - tmp = (str(k) for k in self._data.values()) + tmp = (tostr(k) for k in self._data.values()) return f"{self.__class__.__name__}({', '.join(tmp)})" def update(self, iterable): diff --git a/pyomo/common/tests/test_component_map.py b/pyomo/common/tests/test_component_map.py index 79116b6780b..be70cb57636 100644 --- a/pyomo/common/tests/test_component_map.py +++ b/pyomo/common/tests/test_component_map.py @@ -35,7 +35,7 @@ def test_str(self): cm[(5, m.x)] = 7 self.assertEqual( f"{self.CM.__name__}" - f"(x (key={_id}): y[1], {_id}: 42, (5, x (key={_id})): 7)", + f"(x: y[1], {_id}: 42, (5, x): 7)", str(cm), ) diff --git a/pyomo/common/tests/test_component_set.py b/pyomo/common/tests/test_component_set.py index e7c4c8d0831..0c17a24893d 100644 --- a/pyomo/common/tests/test_component_set.py +++ b/pyomo/common/tests/test_component_set.py @@ -27,7 +27,7 @@ def test_str(self): _id = id(m.x) cs.add(_id) cs.add((5, m.x)) - self.assertEqual(f"{self.CS.__name__}" f"(x, {_id}, (5, {repr(m.x)}))", str(cs)) + self.assertEqual(f"{self.CS.__name__}" f"(x, {_id}, (5, x))", str(cs)) def test_add_remove_discard(self): m = ConcreteModel() From 156557a2803fcaa998280bf5a8171232d146618c Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 10 Feb 2026 10:43:10 -0700 Subject: [PATCH 16/29] ComponetMap keys/values/items should return views --- pyomo/common/collections/component_map.py | 94 +++++++++++++++++++---- pyomo/common/tests/test_component_map.py | 63 +++++++++++++-- 2 files changed, 133 insertions(+), 24 deletions(-) diff --git a/pyomo/common/collections/component_map.py b/pyomo/common/collections/component_map.py index 22925b979f2..c30ef887e62 100644 --- a/pyomo/common/collections/component_map.py +++ b/pyomo/common/collections/component_map.py @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from collections.abc import Mapping, MutableMapping +from collections.abc import Set, Mapping, MutableMapping from functools import partial from operator import itemgetter @@ -28,6 +28,68 @@ def _rehash_keys(keygen, encode, val): return {keygen(v[0]): v for v in val} +class ComponentMap_keys(Set): + __slots__ = ('_cm',) + + def __init__(self, cm): + self._cm = cm + + def __iter__(self): + return iter(map(itemgetter(0), self._cm._dict.values())) + + def __contains__(self, key): + return self._cm.__contains__(key) + + def __len__(self): + return self._cm.__len__() + + +class ComponentMap_items(Set): + __slots__ = ('_cm',) + + def __init__(self, cm): + self._cm = cm + + def __iter__(self): + return iter(self._cm._dict.values()) + + def __contains__(self, item): + try: + key, val = item + except (TypeError, ValueError): + return False + return key in self._cm and self._cm._value_eq(val, self._cm[key]) + + def __len__(self): + return self._cm.__len__() + + +class ComponentMap_values(Set): + __slots__ = ('_cm',) + + def __init__(self, cm): + self._cm = cm + + def __iter__(self): + return iter(map(itemgetter(1), self._cm._dict.values())) + + def __contains__(self, val): + """Returns True if `val` appears as a value in this ComponentMap + + .. warining:: + + This method is provided for API compatibility and is NOT + efficient (it is a linear scan through the underlying + `dict`). We *do not* recommend using it in large + applications of when performance matters. + + """ + return any(self._cm._value_eq(v, val) for v in self.__iter__()) + + def __len__(self): + return self._cm.__len__() + + class ComponentMap(AutoSlots.Mixin, MutableMapping): """Mapping that admits unhashable objects as keys @@ -111,6 +173,16 @@ def _rekey_items(self, items): """Utility method for mapping key-value pairs into local hash keys""" return ((hasher[key.__class__](key), val) for key, val in items) + @staticmethod + def _value_eq(a, b): + # Note: check "is" first to help avoid creation of Pyomo + # expressions (for the case that the values contain the same + # Pyomo component) + if a is b: + return True + diff = a != b + return not diff if diff.__class__ is bool else False + # We want to avoid generating Pyomo expressions due to comparing the # keys, so look up each entry from other in this dict. def __eq__(self, other): @@ -128,18 +200,8 @@ def __eq__(self, other): other_items = self._rekey_items(other.items()) _dict = self._dict - for key, val in other_items: - if key not in _dict: - return False - self_val = _dict[key][1] - # Note: check "is" first to help avoid creation of Pyomo - # expressions (for the case that the values contain the same - # Pyomo component) - if self_val is not val: - val_diff = self_val != val - if val_diff.__class__ is not bool or val_diff: - return False - return True + _eq = self._value_eq + return all(key in _dict and _eq(val, _dict[key][1]) for key, val in other_items) def __ne__(self, other): """Return self!=other.""" @@ -150,13 +212,13 @@ def __ne__(self, other): # def keys(self): - return map(itemgetter(0), self._dict.values()) + return ComponentMap_keys(self) def values(self): - return map(itemgetter(1), self._dict.values()) + return ComponentMap_values(self) def items(self): - return self._dict.values() + return ComponentMap_items(self) def __contains__(self, obj): return hasher[obj.__class__](obj) in self._dict diff --git a/pyomo/common/tests/test_component_map.py b/pyomo/common/tests/test_component_map.py index be70cb57636..78d48cd8bec 100644 --- a/pyomo/common/tests/test_component_map.py +++ b/pyomo/common/tests/test_component_map.py @@ -34,9 +34,7 @@ def test_str(self): cm[_id] = 42 cm[(5, m.x)] = 7 self.assertEqual( - f"{self.CM.__name__}" - f"(x: y[1], {_id}: 42, (5, x): 7)", - str(cm), + f"{self.CM.__name__}" f"(x: y[1], {_id}: 42, (5, x): 7)", str(cm) ) def test_get_del_item(self): @@ -74,19 +72,68 @@ def test_get_del_item(self): self.assertEqual(cm[m], 10) self.assertEqual(cm[3], 30) - def test_iter(self): + def test_iters(self): m = ConcreteModel() m.x = Var() cm = self.CM() cm[m] = 10 cm[m.x] = 20 - cm[3] = 30 + cm[3] = 10 self.assertEqual([m, m.x, 3], list(cm)) - self.assertEqual([m, m.x, 3], list(cm.keys())) - self.assertEqual([10, 20, 30], list(cm.values())) - self.assertEqual([(m, 10), (m.x, 20), (3, 30)], list(cm.items())) + + k = cm.keys() + self.assertEqual([m, m.x, 3], list(k)) + self.assertEqual(3, len(k)) + self.assertIn(m, k) + self.assertIn(m.x, k) + self.assertNotIn(4, k) + + v = cm.values() + self.assertEqual([10, 20, 10], list(v)) + self.assertEqual(3, len(v)) + self.assertIn(10, v) + self.assertIn(20, v) + self.assertNotIn(30, v) + + i = cm.items() + self.assertEqual([(m, 10), (m.x, 20), (3, 10)], list(i)) + self.assertEqual(3, len(i)) + self.assertIn((m, 10), i) + self.assertIn((m.x, 20), i) + self.assertIn((3, 10), i) + self.assertNotIn((3, 30), i) + self.assertNotIn((4, 10), i) + self.assertNotIn('hi', i) + self.assertNotIn(50, i) + self.assertNotIn((1, 2, 3), i) + + # These are views... and should update to reflect the current state + del cm[m] + + self.assertEqual([m.x, 3], list(k)) + self.assertEqual(2, len(k)) + self.assertNotIn(m, k) + self.assertIn(m.x, k) + self.assertNotIn(4, k) + + self.assertEqual([20, 10], list(v)) + self.assertEqual(2, len(v)) + self.assertIn(10, v) + self.assertIn(20, v) + self.assertNotIn(30, v) + + self.assertEqual([(m.x, 20), (3, 10)], list(i)) + self.assertEqual(2, len(i)) + self.assertNotIn((m, 10), i) + self.assertIn((m.x, 20), i) + self.assertIn((3, 10), i) + self.assertNotIn((3, 30), i) + self.assertNotIn((4, 10), i) + self.assertNotIn('hi', i) + self.assertNotIn(50, i) + self.assertNotIn((1, 2, 3), i) def test_eq(self): m = ConcreteModel() From ad1fa4f2b585f71bcc8dc07bad65c6e3b61de708 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 2 Jun 2026 09:53:27 -0600 Subject: [PATCH 17/29] Improve/test HashKey repr/str --- pyomo/common/collections/_hasher.py | 3 +++ pyomo/common/tests/test_component_map.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/pyomo/common/collections/_hasher.py b/pyomo/common/collections/_hasher.py index 3d6adc0012e..955e085de9f 100644 --- a/pyomo/common/collections/_hasher.py +++ b/pyomo/common/collections/_hasher.py @@ -42,6 +42,9 @@ def __eq__(self, other): return other.__class__ is HashKey and other._hash == self._hash def __repr__(self): + return f"HashKey({self._val!r}, key={self._hash})" + + def __str__(self): return f"{self._val} (key={self._hash})" diff --git a/pyomo/common/tests/test_component_map.py b/pyomo/common/tests/test_component_map.py index d2c78af3878..5bbb57e5c82 100644 --- a/pyomo/common/tests/test_component_map.py +++ b/pyomo/common/tests/test_component_map.py @@ -222,6 +222,10 @@ def test_hasher(self): self.assertFalse(m.hasher.hashable(str)) self.assertEqual(m._dict, {HashKey(a): (a, 5)}) + h = HashKey(a) + self.assertEqual(repr(h), f"HashKey('str', key={id(a)})") + self.assertEqual(str(h), f"str (key={id(a)})") + class TMP: pass From facfa68ef0c63f65acadd48bd15d773cdfe945c9 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 2 Jun 2026 09:53:38 -0600 Subject: [PATCH 18/29] NFC: update docs --- pyomo/common/collections/component_map.py | 29 ++++++++++++++--------- pyomo/common/collections/component_set.py | 23 ++++++++++++------ 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/pyomo/common/collections/component_map.py b/pyomo/common/collections/component_map.py index 83a1b39d01d..629de68f7fd 100644 --- a/pyomo/common/collections/component_map.py +++ b/pyomo/common/collections/component_map.py @@ -27,6 +27,8 @@ def _rehash_keys(keygen, encode, val): class ComponentMap_keys(Set): + """A dictionary keys view object for :class:`ComponentMap`""" + __slots__ = ('_cm',) def __init__(self, cm): @@ -43,6 +45,8 @@ def __len__(self): class ComponentMap_items(Set): + """A dictionary items view object for :class:`ComponentMap`""" + __slots__ = ('_cm',) def __init__(self, cm): @@ -63,6 +67,8 @@ def __len__(self): class ComponentMap_values(Set): + """A dictionary values view object for :class:`ComponentMap`""" + __slots__ = ('_cm',) def __init__(self, cm): @@ -74,12 +80,12 @@ def __iter__(self): def __contains__(self, val): """Returns True if `val` appears as a value in this ComponentMap - .. warining:: + .. warning:: This method is provided for API compatibility and is NOT efficient (it is a linear scan through the underlying `dict`). We *do not* recommend using it in large - applications of when performance matters. + applications or when performance matters. """ return any(self._cm._value_eq(v, val) for v in self.__iter__()) @@ -277,23 +283,24 @@ class ObjectIdMap(ComponentMap): :py:class:`ObjectIdMap` is a lighter-weight version of :py:class:`ComponentMap`. By unconditionally using :py:`id()` to - generate all keys, this class performs approximately 25% faster than + generate all keys, this class performs approximately 50% faster than :py:class:`ComponentMap` at the expense of being slightly more fragile. It is _strongly_ recommended to only use Pyomo components as - :py:class:`ObjectIdMap` keys. + :class:`ObjectIdMap` keys. .. warning:: - Do not store keys that do not return persistent :py:func:`id()` - values. In particular, avoid certain immutable data types like - :py:`tuple` objects, strings, and long integers. Doing so may - result in failed lookups or duplicate entries. + **DO NOT** store keys that do not return persistent + :py:func:`id()` values. In particular, avoid certain immutable + data types like :class:`tuple` or other immutable objects, + strings, and long integers. Doing so may result in failed + lookups or duplicate entries. - If you want to mix these keys with other unhashable objects (like - Pyomo :py:class:`Var` or :py:class:`Param` components), please - use :py:class:`ComponentMap`. + If you want to mix immutable data types with other unhashable + objects (like Pyomo :class:`Var` or :class:`Param` components), + please use :class:`ComponentMap`. """ diff --git a/pyomo/common/collections/component_set.py b/pyomo/common/collections/component_set.py index 7f3544b555f..7c8d349e2e3 100644 --- a/pyomo/common/collections/component_set.py +++ b/pyomo/common/collections/component_set.py @@ -72,7 +72,7 @@ def __init__(self, iterable=None): self.update(iterable) def __str__(self): - """String representation of the mapping.""" + """String representation of the set.""" tmp = (tostr(k) for k in self._data.values()) return f"{self.__class__.__name__}({', '.join(tmp)})" @@ -135,7 +135,7 @@ def clear(self): self._data.clear() def remove(self, val): - """Remove an element. If not a member, raise a KeyError.""" + """Remove an element. If not a member, raise a :class:`KeyError`.""" try: del self._data[hasher[val.__class__](val)] except KeyError: @@ -147,15 +147,24 @@ class ObjectIdSet(ComponentSet): :py:class:`ObjectIdSet` is a lighter-weight version of :py:class:`ComponentSet`. By unconditionally using :py:`id()` to - generate all keys, this class performs approximately 25% faster than + hash all members, this class performs approximately 50% faster than :py:class:`ComponentSet` at the expense of being slightly more - fragile. In particular, :py:`tuple` keys may unexpectedly generate - cache misses or result in multiple entries in the set. + fragile. + + It is _strongly_ recommended to only store Pyomo components in + :class:`ObjectIdSet` containers. .. warning:: - Do not store :py:`tuple` objects as keys in this data structure. - Doing so may result in failed lookups or duplicate entries. + **DO NOT** store objects that do not return persistent + :py:func:`id()` values. In particular, avoid certain immutable + data types like :class:`tuple` or other immutable objects, + strings, and long integers. Doing so may result in failed + lookups or duplicate entries. + + If you want to mix immutable data types with other unhashable + objects (like Pyomo :class:`Var` or :class:`Param` components), + please use :class:`ComponentSet`. """ From 756077101640dd0af62e5a8efecb8754b314578e Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 2 Jun 2026 10:51:20 -0600 Subject: [PATCH 19/29] Make update() API consistent with dict/set --- pyomo/common/collections/component_map.py | 5 +++-- pyomo/common/collections/component_set.py | 13 +++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/pyomo/common/collections/component_map.py b/pyomo/common/collections/component_map.py index 629de68f7fd..a73d3b49a74 100644 --- a/pyomo/common/collections/component_map.py +++ b/pyomo/common/collections/component_map.py @@ -169,8 +169,9 @@ def __len__(self): # We want a specialization of update() to avoid unnecessary calls to # the hasher when copying / merging ComponentMaps def update(self, *args, **kwargs): - if len(args) == 1 and not kwargs and args[0].__class__ is self.__class__: - return self._dict.update(args[0]._dict) + if len(args) == 1 and args[0].__class__ is self.__class__: + self._dict.update(args[0]._dict) + args = () return super().update(*args, **kwargs) def _rekey_items(self, items): diff --git a/pyomo/common/collections/component_set.py b/pyomo/common/collections/component_set.py index 7c8d349e2e3..edef15bb685 100644 --- a/pyomo/common/collections/component_set.py +++ b/pyomo/common/collections/component_set.py @@ -76,13 +76,14 @@ def __str__(self): tmp = (tostr(k) for k in self._data.values()) return f"{self.__class__.__name__}({', '.join(tmp)})" - def update(self, iterable): + def update(self, *iterables): """Update a set with the union of itself and others.""" - if isinstance(iterable, ComponentSet): - self._data.update(iterable._data) - else: - for val in iterable: - self.add(val) + for iterable in *iterables: + if iterable.__class__ is self.__class__: + self._data.update(iterable._data) + else: + for val in iterable: + self.add(val) # # Implement MutableSet abstract methods From 1a4fecce4e252cb93bbf63280273a9abe5b245f4 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 2 Jun 2026 10:58:56 -0600 Subject: [PATCH 20/29] Fix typo --- pyomo/common/collections/component_set.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/common/collections/component_set.py b/pyomo/common/collections/component_set.py index edef15bb685..67c8562e84f 100644 --- a/pyomo/common/collections/component_set.py +++ b/pyomo/common/collections/component_set.py @@ -78,7 +78,7 @@ def __str__(self): def update(self, *iterables): """Update a set with the union of itself and others.""" - for iterable in *iterables: + for iterable in iterables: if iterable.__class__ is self.__class__: self._data.update(iterable._data) else: From 5c1dd710ef8c13cbec770adc477a8d429008a645 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 2 Jun 2026 11:39:19 -0600 Subject: [PATCH 21/29] We don't need a full HashKey class; just a flag to add in a native tuple --- pyomo/common/collections/_hasher.py | 45 +++++++----------------- pyomo/common/tests/test_component_map.py | 14 +++----- pyomo/common/tests/test_component_set.py | 8 ++--- 3 files changed, 19 insertions(+), 48 deletions(-) diff --git a/pyomo/common/collections/_hasher.py b/pyomo/common/collections/_hasher.py index 955e085de9f..c0d4a4d6993 100644 --- a/pyomo/common/collections/_hasher.py +++ b/pyomo/common/collections/_hasher.py @@ -10,42 +10,17 @@ from collections import defaultdict -class HashKey: +class _HashKey: """Utility class to support hashing by object id() - This class supports hashing unhashable objects using their id(). It - can be used as a key in a mixed-class :py:`dict` to prevent key - collisions between :py:`int` keys and an unhashable objects whose - id() is the same value. - - .. note:: - - This class is slotized for efficiency, but does not provide - special handling for updating the internal ``_hash`` (`id()`) - after deepcopying or pickling. As such, containers that use this - class (e.g., :py:`ComponentMap` and :py:`ComponentSet`) should - not pickle these objects and instead regenerate them when - restoring the container. + This class should never be instantiated, and should never be + accessed referenced by user code. Instead this provides a simple + :class:`type` that we can use as an internal flag to differentiate + between an :class:`int` key and the result from :func:`id()`. """ - __slots__ = ('_hash', '_val') - - def __init__(self, val): - self._hash = id(val) - self._val = val - - def __hash__(self): - return self._hash - - def __eq__(self, other): - return other.__class__ is HashKey and other._hash == self._hash - - def __repr__(self): - return f"HashKey({self._val!r}, key={self._hash})" - - def __str__(self): - return f"{self._val} (key={self._hash})" + pass class HashDispatcher(defaultdict): @@ -79,13 +54,17 @@ def _missing_impl(self, val): hash(val) self[val.__class__] = self._hashable except: - self[val.__class__] = HashKey + self[val.__class__] = self._unhashable return self[val.__class__](val) @staticmethod def _hashable(val): return val + @staticmethod + def _unhashable(val): + return _HashKey, id(val) + def _tuple(self, val): try: # if *this tuple* is hashable, then use it as the key @@ -107,7 +86,7 @@ def hashable(self, obj, hashable=None): if fcn is None: raise KeyError(obj) return fcn is self._hashable - self[cls] = self._hashable if hashable else HashKey + self[cls] = self._hashable if hashable else self._unhashable def __call__(self, obj): # Make the dispatcher callable so that it can be used in place of id() diff --git a/pyomo/common/tests/test_component_map.py b/pyomo/common/tests/test_component_map.py index 5bbb57e5c82..e07df4680a8 100644 --- a/pyomo/common/tests/test_component_map.py +++ b/pyomo/common/tests/test_component_map.py @@ -10,7 +10,7 @@ import pickle import pyomo.common.unittest as unittest -from pyomo.common.collections._hasher import HashKey +from pyomo.common.collections._hasher import _HashKey from pyomo.common.collections.component_map import ( ComponentMap, DefaultComponentMap, @@ -220,11 +220,7 @@ def test_hasher(self): m[a] = 5 self.assertFalse(m.hasher.hashable(a)) self.assertFalse(m.hasher.hashable(str)) - self.assertEqual(m._dict, {HashKey(a): (a, 5)}) - - h = HashKey(a) - self.assertEqual(repr(h), f"HashKey('str', key={id(a)})") - self.assertEqual(str(h), f"str (key={id(a)})") + self.assertEqual(m._dict, {(_HashKey, id(a)): (a, 5)}) class TMP: pass @@ -322,13 +318,11 @@ def test_pickle(self): self.assertEqual(k, 1) self.assertEqual(v, (1, 10)) k, v = next(_items) - self.assertEqual(k, HashKey(i.x)) + self.assertEqual(k, (_HashKey, id(i.x))) self.assertEqual(v, (i.x, 20)) - self.assertEqual(k._hash, id(i.x)) k, v = next(_items) - self.assertEqual(k, (1, (2, HashKey(i.x)))) + self.assertEqual(k, (1, (2, (_HashKey, id(i.x))))) self.assertEqual(v, ((1, (2, i.x)), 30)) - self.assertEqual(k[1][1]._hash, id(i.x)) k, v = next(_items) self.assertEqual(k, i.c) self.assertEqual(v, (i.c, 40)) diff --git a/pyomo/common/tests/test_component_set.py b/pyomo/common/tests/test_component_set.py index 0c17a24893d..26f9dff299e 100644 --- a/pyomo/common/tests/test_component_set.py +++ b/pyomo/common/tests/test_component_set.py @@ -12,7 +12,7 @@ import pickle import pyomo.common.unittest as unittest -from pyomo.common.collections._hasher import HashKey +from pyomo.common.collections._hasher import _HashKey from pyomo.common.collections.component_set import ComponentSet, ObjectIdSet from pyomo.environ import ConcreteModel, Var, Constraint @@ -158,13 +158,11 @@ def test_pickle(self): self.assertEqual(k, 1) self.assertEqual(v, 1) k, v = next(_items) - self.assertEqual(k, HashKey(i.x)) + self.assertEqual(k, (_HashKey, id(i.x))) self.assertEqual(v, i.x) - self.assertEqual(k._hash, id(i.x)) k, v = next(_items) - self.assertEqual(k, (1, (2, HashKey(i.x)))) + self.assertEqual(k, (1, (2, (_HashKey, id(i.x))))) self.assertEqual(v, (1, (2, i.x))) - self.assertEqual(k[1][1]._hash, id(i.x)) k, v = next(_items) self.assertEqual(k, i.c) self.assertEqual(v, i.c) From 484b6f7ad0cc383f5accfdc59ed93ad5b7232650 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 2 Jun 2026 14:42:51 -0600 Subject: [PATCH 22/29] Support ComponentMap as the substitution map for ExpressionReplacementVisitor --- pyomo/core/expr/visitor.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyomo/core/expr/visitor.py b/pyomo/core/expr/visitor.py index 210c0ec32e7..c49f3fc6c3c 100644 --- a/pyomo/core/expr/visitor.py +++ b/pyomo/core/expr/visitor.py @@ -16,6 +16,7 @@ logger = logging.getLogger('pyomo.core') +from pyomo.common.collections import ComponentMap from pyomo.common.deprecation import deprecated, deprecation_warning from pyomo.common.errors import DeveloperError, TemplateExpressionError from pyomo.common.numeric_types import ( @@ -996,6 +997,10 @@ def __init__( ): if substitute is None: substitute = {} + elif isinstance(substitute, ComponentMap): + substitute = { + k if k.__class__ is int else id(k): v for k, v in substitute.items() + } # Note: preserving the attribute names from the previous # implementation of the expression walker. self.substitute = substitute From 11cc4697b34b31278ddae8aa6adc17fcdcd4316e Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 2 Jun 2026 14:43:25 -0600 Subject: [PATCH 23/29] Don't look up ComponentMap values by key id() --- .../tests/solvers/test_gurobi_minlp_walker.py | 60 +++++++++---------- .../tests/solvers/test_gurobi_minlp_writer.py | 32 +++++----- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py index 19a8498f08e..658ad555ff4 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py @@ -91,13 +91,13 @@ def test_var_domains(self): # we need to update here in order to be able to test expr. visitor.grb_model.update() - x1 = visitor.var_map[id(m.x1)] - x2 = visitor.var_map[id(m.x2)] - x3 = visitor.var_map[id(m.x3)] - y1 = visitor.var_map[id(m.y1)] - y2 = visitor.var_map[id(m.y2)] - y3 = visitor.var_map[id(m.y3)] - z1 = visitor.var_map[id(m.z1)] + x1 = visitor.var_map[m.x1] + x2 = visitor.var_map[m.x2] + x3 = visitor.var_map[m.x3] + y1 = visitor.var_map[m.y1] + y2 = visitor.var_map[m.y2] + y3 = visitor.var_map[m.y3] + z1 = visitor.var_map[m.z1] self.assertEqual(x1.lb, 0) self.assertEqual(x1.ub, float('inf')) @@ -144,11 +144,11 @@ def test_var_bounds(self): # we need to update here in order to be able to test expr. visitor.grb_model.update() - x2 = visitor.var_map[id(m.x2)] - x3 = visitor.var_map[id(m.x3)] - y1 = visitor.var_map[id(m.y1)] - y2 = visitor.var_map[id(m.y2)] - z1 = visitor.var_map[id(m.z1)] + x2 = visitor.var_map[m.x2] + x3 = visitor.var_map[m.x3] + y1 = visitor.var_map[m.y1] + y2 = visitor.var_map[m.y2] + z1 = visitor.var_map[m.z1] self.assertEqual(x2.lb, -34) self.assertEqual(x2.ub, 45) @@ -174,8 +174,8 @@ def test_write_addition(self): visitor = self.get_visitor() _, expr = visitor.walk_expression(m.c.body) - x1 = visitor.var_map[id(m.x1)] - x2 = visitor.var_map[id(m.x2)] + x1 = visitor.var_map[m.x1] + x2 = visitor.var_map[m.x2] # This is a linear expression self.assertEqual(expr.size(), 2) @@ -191,8 +191,8 @@ def test_write_subtraction(self): visitor = self.get_visitor() _, expr = visitor.walk_expression(m.c.body) - x1 = visitor.var_map[id(m.x1)] - x2 = visitor.var_map[id(m.x2)] + x1 = visitor.var_map[m.x1] + x2 = visitor.var_map[m.x2] # Also linear, whoot! self.assertEqual(expr.size(), 2) @@ -208,8 +208,8 @@ def test_write_product(self): visitor = self.get_visitor() _, expr = visitor.walk_expression(m.c.body) - x1 = visitor.var_map[id(m.x1)] - x2 = visitor.var_map[id(m.x2)] + x1 = visitor.var_map[m.x1] + x2 = visitor.var_map[m.x2] # This is quadratic self.assertEqual(expr.size(), 1) @@ -227,7 +227,7 @@ def test_write_product_with_fixed_var(self): visitor = self.get_visitor() _, expr = visitor.walk_expression(m.c.body) - x1 = visitor.var_map[id(m.x1)] + x1 = visitor.var_map[m.x1] # this is linear self.assertEqual(expr.size(), 1) @@ -279,8 +279,8 @@ def test_write_division_linear(self): visitor = self.get_visitor() _, expr = visitor.walk_expression(m.c.body) - x1 = visitor.var_map[id(m.x1)] - x2 = visitor.var_map[id(m.x2)] + x1 = visitor.var_map[m.x1] + x2 = visitor.var_map[m.x2] # linear self.assertEqual(expr.size(), 2) @@ -297,7 +297,7 @@ def test_write_linear_power_expression_var_const(self): visitor = self.get_visitor() _, expr = visitor.walk_expression(m.c.body) - x1 = visitor.var_map[id(m.x1)] + x1 = visitor.var_map[m.x1] # It's just a single var self.assertIs(expr, x1) @@ -335,7 +335,7 @@ def test_write_quadratic_power_expression_var_const(self): _, expr = visitor.walk_expression(m.c.body) # This is quadratic - x1 = visitor.var_map[id(m.x1)] + x1 = visitor.var_map[m.x1] self.assertEqual(expr.size(), 1) lin_expr = expr.getLinExpr() @@ -386,8 +386,8 @@ def test_write_power_expression_var_var(self): # You can't actually use this in a model in Gurobi 12, but you can build the # expression... (It fails during the solve.) - x1 = visitor.var_map[id(m.x1)] - x2 = visitor.var_map[id(m.x2)] + x1 = visitor.var_map[m.x1] + x2 = visitor.var_map[m.x2] opcode, data, parent = self._get_nl_expr_tree(visitor, expr) @@ -402,7 +402,7 @@ def test_write_power_expression_const_var(self): visitor = self.get_visitor() _, expr = visitor.walk_expression(m.c.body) - x2 = visitor.var_map[id(m.x2)] + x2 = visitor.var_map[m.x2] opcode, data, parent = self._get_nl_expr_tree(visitor, expr) @@ -438,7 +438,7 @@ def test_write_absolute_value_of_var(self): # expr is actually an auxiliary variable. We should # get a constraint: # expr == abs(x1) - x1 = visitor.var_map[id(m.x1)] + x1 = visitor.var_map[m.x1] self.assertIsInstance(expr, gurobipy.Var) grb_model = visitor.grb_model @@ -507,7 +507,7 @@ def test_write_expression_with_mutable_param(self): _, expr = visitor.walk_expression(m.c.body) # expr is nonlinear - x2 = visitor.var_map[id(m.x2)] + x2 = visitor.var_map[m.x2] opcode, data, parent = self._get_nl_expr_tree(visitor, expr) @@ -526,7 +526,7 @@ def test_monomial_expression(self): visitor = self.get_visitor() _, expr = visitor.walk_expression(const_expr) - x1 = visitor.var_map[id(m.x1)] + x1 = visitor.var_map[m.x1] self.assertEqual(expr.size(), 1) self.assertEqual(expr.getConstant(), 0.0) self.assertIs(expr.getVar(0), x1) @@ -552,7 +552,7 @@ def test_log_expression(self): _, expr = visitor.walk_expression(m.c.body) # expr is nonlinear - x1 = visitor.var_map[id(m.x1)] + x1 = visitor.var_map[m.x1] opcode, data, parent = self._get_nl_expr_tree(visitor, expr) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py index 5b4f3cd33ed..43dc1dfa51b 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py @@ -87,13 +87,13 @@ def test_small_model(self): ).write(m, symbolic_solver_labels=True) self.assertEqual(len(var_map), 7) - x1 = var_map[id(m.x1)] - x2 = var_map[id(m.x2)] - x3 = var_map[id(m.x3)] - y1 = var_map[id(m.y1)] - y2 = var_map[id(m.y2)] - y3 = var_map[id(m.y3)] - z1 = var_map[id(m.z1)] + x1 = var_map[m.x1] + x2 = var_map[m.x2] + x3 = var_map[m.x3] + y1 = var_map[m.y1] + y2 = var_map[m.y2] + y3 = var_map[m.y3] + z1 = var_map[m.z1] self.assertEqual(grb_model.numVars, 9) self.assertEqual(grb_model.numIntVars, 4) @@ -199,7 +199,7 @@ def test_write_NPV_negation_in_RHS(self): ).write(m, symbolic_solver_labels=True) self.assertEqual(len(var_map), 1) - x1 = var_map[id(m.x1)] + x1 = var_map[m.x1] self.assertEqual(grb_model.numVars, 1) self.assertEqual(grb_model.numIntVars, 0) @@ -245,7 +245,7 @@ def test_writer_ignores_deactivated_logical_constraints(self): ).write(m, symbolic_solver_labels=True) self.assertEqual(len(var_map), 1) - x1 = var_map[id(m.x1)] + x1 = var_map[m.x1] self.assertEqual(grb_model.numVars, 1) self.assertEqual(grb_model.numIntVars, 0) @@ -289,8 +289,8 @@ def test_named_expression_quadratic(self): ).write(m, symbolic_solver_labels=True) self.assertEqual(len(var_map), 2) - x = var_map[id(m.x)] - y = var_map[id(m.y)] + x = var_map[m.x] + y = var_map[m.y] self.assertEqual(grb_model.numVars, 2) self.assertEqual(grb_model.numIntVars, 0) @@ -352,8 +352,8 @@ def test_named_expression_nonlinear(self): ).write(m, symbolic_solver_labels=True) self.assertEqual(len(var_map), 2) - x = var_map[id(m.x)] - y = var_map[id(m.y)] + x = var_map[m.x] + y = var_map[m.y] reverse_var_map = {grbv: pyov for pyov, grbv in var_map.items()} self.assertEqual(grb_model.numVars, 4) @@ -454,9 +454,9 @@ def test_unbounded_because_of_multiplying_by_0(self): ).write(m, symbolic_solver_labels=True) self.assertEqual(len(var_map), 3) - x1 = var_map[id(m.x1)] - x2 = var_map[id(m.x2)] - x3 = var_map[id(m.x3)] + x1 = var_map[m.x1] + x2 = var_map[m.x2] + x3 = var_map[m.x3] self.assertEqual(grb_model.numVars, 4) self.assertEqual(grb_model.numIntVars, 0) From 7ee41f0574d61eec7ece08f86d8460266d29518f Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 2 Jun 2026 14:43:46 -0600 Subject: [PATCH 24/29] Track updated exception message --- pyomo/gdp/tests/test_hull.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/gdp/tests/test_hull.py b/pyomo/gdp/tests/test_hull.py index 15bb5463e82..34553cda3c8 100644 --- a/pyomo/gdp/tests/test_hull.py +++ b/pyomo/gdp/tests/test_hull.py @@ -2335,7 +2335,7 @@ def test_mapping_method_errors(self): with LoggingIntercept(log, 'pyomo.gdp.hull', logging.ERROR): self.assertRaisesRegex( KeyError, - r".*disjunction", + r".*disjunct.ScalarDisjunction object", hull.get_disaggregation_constraint, m.d[1].transformation_block.disaggregatedVars.w, m.disjunction, From c455f6b742bd7f267cd124230adac7bd4071d137 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 2 Jun 2026 15:24:27 -0600 Subject: [PATCH 25/29] Catch that comparing numpy types can produce numpy bools --- pyomo/common/collections/component_map.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyomo/common/collections/component_map.py b/pyomo/common/collections/component_map.py index a73d3b49a74..b2aedbfe9cf 100644 --- a/pyomo/common/collections/component_map.py +++ b/pyomo/common/collections/component_map.py @@ -13,6 +13,7 @@ from pyomo.common.autoslots import AutoSlots from pyomo.common.formatting import tostr +from pyomo.common.numeric_types import native_logical_types from ._hasher import hasher @@ -88,7 +89,7 @@ def __contains__(self, val): applications or when performance matters. """ - return any(self._cm._value_eq(v, val) for v in self.__iter__()) + return any(self._cm._value_eq(v, val) for v in self) def __len__(self): return self._cm.__len__() @@ -186,7 +187,7 @@ def _value_eq(a, b): if a is b: return True diff = a != b - return not diff if diff.__class__ is bool else False + return (not diff) if diff.__class__ in native_logical_types else False # We want to avoid generating Pyomo expressions due to comparing the # keys, so look up each entry from other in this dict. From 9365fffa6330a3dcc59a372be5a491497c0df38b Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 2 Jun 2026 15:57:53 -0600 Subject: [PATCH 26/29] Guard against object collection under ExpressionReplacementVisitor --- pyomo/core/expr/visitor.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyomo/core/expr/visitor.py b/pyomo/core/expr/visitor.py index c49f3fc6c3c..50b59f4330f 100644 --- a/pyomo/core/expr/visitor.py +++ b/pyomo/core/expr/visitor.py @@ -968,7 +968,7 @@ def replace_expressions( ---------- expr : Pyomo expression The source expression - substitution_map : dict + substitution_map : dict | ComponentMap A dictionary mapping object ids in the source to the replacement objects. descend_into_named_expressions : bool True if replacement should go into named expression objects, False to halt at @@ -998,6 +998,12 @@ def __init__( if substitute is None: substitute = {} elif isinstance(substitute, ComponentMap): + # ComponentMaps hold references to the keys that they took + # the id() of. Those *could* be the only references to + # those objects, so we want to keep a reference to the + # ComponentMap to guarantee that they don't fall out of + # scope and are collected. + self._cm_substitute = substitute substitute = { k if k.__class__ is int else id(k): v for k, v in substitute.items() } From 295d8b62b70aefb8d65bf96ee3d75c40ab207096 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 2 Jun 2026 15:58:23 -0600 Subject: [PATCH 27/29] Update to track simplification of string representations --- pyomo/util/components.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyomo/util/components.py b/pyomo/util/components.py index 5cd9fbf7d3b..1d451f18740 100644 --- a/pyomo/util/components.py +++ b/pyomo/util/components.py @@ -33,7 +33,7 @@ def rename_components(model, component_list, prefix): >>> c_list = list(model.component_objects(ctype=pyo.Var, descend_into=True)) >>> new = rename_components(model, component_list=c_list, prefix='special_') >>> str(new) - "ComponentMap({'special_x (key=...)': 'x', 'special_y (key=...)': 'y'})" + 'ComponentMap(special_x: x, special_y: y)' Returns ------- @@ -46,7 +46,8 @@ def rename_components(model, component_list, prefix): generator since this can lead to an infinite loop """ - # Need to collect any Reference first so that we can record the old mapping of data objects before renaming + # Need to collect any Reference first so that we can record the old + # mapping of data objects before renaming refs = {} for c in component_list: if c.is_reference(): From a6ac21f6aeef126e740b7c1eb14e91810f1140bb Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 2 Jun 2026 17:36:48 -0600 Subject: [PATCH 28/29] Expose ObjectIdMap / ObjectIdSet through pyomo.common.collections --- pyomo/common/collections/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyomo/common/collections/__init__.py b/pyomo/common/collections/__init__.py index ab29d7070a6..bf4270af39f 100644 --- a/pyomo/common/collections/__init__.py +++ b/pyomo/common/collections/__init__.py @@ -12,6 +12,6 @@ from collections.abc import Mapping, MutableMapping, MutableSet, Sequence, Set from .bunch import Bunch -from .component_map import ComponentMap, DefaultComponentMap -from .component_set import ComponentSet +from .component_map import ComponentMap, DefaultComponentMap, ObjectIdMap +from .component_set import ComponentSet, ObjectIdSet from .orderedset import OrderedSet From 5ebac4b2203c7605039567b86412530c97c82bed Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 2 Jun 2026 21:47:25 -0600 Subject: [PATCH 29/29] Update test for pypy --- pyomo/common/tests/test_component_map.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pyomo/common/tests/test_component_map.py b/pyomo/common/tests/test_component_map.py index e07df4680a8..69e19ed81af 100644 --- a/pyomo/common/tests/test_component_map.py +++ b/pyomo/common/tests/test_component_map.py @@ -17,6 +17,7 @@ ObjectIdMap, ) from pyomo.common.collections.component_set import ComponentSet +from pyomo.common.envvar import is_pypy from pyomo.environ import ConcreteModel, Block, Var, Constraint, Param @@ -401,7 +402,11 @@ def test_id_int_collision(self): cm[id(m.x)] = 2 self.assertEqual(len(cm), 2) self.assertIn(m.x, cm) - self.assertNotIn(id(m.x), cm) # Note: different from ComponentMap + # In pypy, ints from id() hash consistently; in cpython they do not + if is_pypy: + self.assertIn(id(m.x), cm) # Note: different from ComponentMap + else: + self.assertNotIn(id(m.x), cm) # Note: different from ComponentMap self.assertEqual(cm[m.x], 1) a = (1, (m.x, 3))