diff --git a/docs/docs/api-reference/index.md b/docs/docs/api-reference/index.md index 3b5a6d35..9b05c045 100644 --- a/docs/docs/api-reference/index.md +++ b/docs/docs/api-reference/index.md @@ -13,7 +13,7 @@ available in EasyScience. - [fitting](fitting.md) – Fitting and sampling, including `Fitter`, `Sampler` and the available minimizers. - [global_object](global_object.md) – Global singleton providing shared - services (logger, map, undo/redo stack, script manager). + services (logger, object registry, undo/redo stack, script manager). - [job](job.md) – Job and experiment abstractions for running and organizing analyses. - [utils](utils.md) – Miscellaneous utility functions and helpers (class diff --git a/docs/docs/user-guide/parameter-dependency-serialization.md b/docs/docs/user-guide/parameter-dependency-serialization.md index a7f9bf01..2a8254e8 100644 --- a/docs/docs/user-guide/parameter-dependency-serialization.md +++ b/docs/docs/user-guide/parameter-dependency-serialization.md @@ -163,8 +163,8 @@ reported the same way, with the underlying error message appended. first and call `resolve_all_parameter_dependencies` on the collection as a whole. - **Keep the referenced objects alive.** Lookup goes through the global - object map, so a parameter that has been garbage-collected cannot be - found. + object registry, so a parameter that has been garbage-collected cannot + be found. - **Do not load the same data twice in one session.** Serializer ids are restored verbatim. If the original parameters, or an earlier load of the same file, are still alive, a dependency can be bound to that diff --git a/src/easyscience/base_classes/model_base.py b/src/easyscience/base_classes/model_base.py index 6abc8373..23a55212 100644 --- a/src/easyscience/base_classes/model_base.py +++ b/src/easyscience/base_classes/model_base.py @@ -147,7 +147,7 @@ class definition. try: temp_param = getattr(cls_instance, key) setattr(cls_instance, '_' + key, value) - cls_instance._global_object.map.prune(temp_param.unique_name) + cls_instance._global_object.registry.discard(temp_param.unique_name) except Exception as e: raise SyntaxError(f"""Could not set parameter {key} during `from_dict` with full deserialized variable. \n' This should be fixed in the class definition. Error: {e}""") from e diff --git a/src/easyscience/base_classes/new_base.py b/src/easyscience/base_classes/new_base.py index 55a93bad..a47f4275 100644 --- a/src/easyscience/base_classes/new_base.py +++ b/src/easyscience/base_classes/new_base.py @@ -36,7 +36,7 @@ def __init__(self, unique_name: Optional[str] = None, display_name: Optional[str if not isinstance(unique_name, str): raise TypeError('Unique name has to be a string.') self._unique_name = unique_name - self._global_object.map.add_vertex(self, obj_type='created') + self._global_object.registry.add(self) if display_name is not None and not isinstance(display_name, str): raise TypeError('Display name must be a string or None') self._display_name = display_name @@ -51,7 +51,7 @@ def unique_name(self, new_unique_name: str): """ Set a new unique name for the object. - The old name is still kept in the map. + The old name is still kept in the registry. Parameters ---------- @@ -66,7 +66,7 @@ def unique_name(self, new_unique_name: str): if not isinstance(new_unique_name, str): raise TypeError('Unique name has to be a string.') self._unique_name = new_unique_name - self._global_object.map.add_vertex(self) + self._global_object.registry.add(self) self._default_unique_name = False @property diff --git a/src/easyscience/global_object/__init__.py b/src/easyscience/global_object/__init__.py index 0cf8a585..f810d0c0 100644 --- a/src/easyscience/global_object/__init__.py +++ b/src/easyscience/global_object/__init__.py @@ -3,5 +3,5 @@ from .global_object import GlobalObject # noqa: F401 from .logger import Logger # noqa: F401 -from .map import Map # noqa: F401 +from .object_registry import ObjectRegistry # noqa: F401 from .undo_redo import UndoStack # noqa: F401 diff --git a/src/easyscience/global_object/global_object.py b/src/easyscience/global_object/global_object.py index aeaec90e..61940c0f 100644 --- a/src/easyscience/global_object/global_object.py +++ b/src/easyscience/global_object/global_object.py @@ -3,7 +3,7 @@ from ..utils.classUtils import singleton from .logger import Logger -from .map import Map +from .object_registry import ObjectRegistry @singleton @@ -16,7 +16,7 @@ class GlobalObject: """ __log = Logger() - __map = Map() + __registry = ObjectRegistry() __stack = None __debug = False @@ -27,8 +27,8 @@ def __init__(self): self.debug: bool = self.__debug # Stack. This is where the undo/redo operations are stored. self.stack = self.__stack - # Map. This is the conduit database between all global object species - self.map: Map = self.__map + # Registry. All live EasyScience objects, looked up by unique name + self.registry: ObjectRegistry = self.__registry def instantiate_stack(self): """ @@ -56,9 +56,7 @@ def generate_unique_name(self, name_prefix: str) -> str: str Generated unique name. """ - names_with_prefix = [ - name for name in self.map.vertices() if name.startswith(name_prefix + '_') - ] + names_with_prefix = [name for name in self.registry if name.startswith(name_prefix + '_')] if names_with_prefix: name_with_prefix_count = [0] for name in names_with_prefix: diff --git a/src/easyscience/global_object/map.py b/src/easyscience/global_object/map.py deleted file mode 100644 index c79fb9ac..00000000 --- a/src/easyscience/global_object/map.py +++ /dev/null @@ -1,337 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -import gc -import sys -import weakref -from typing import List -from typing import Optional - - -class _EntryList(list): - def __init__(self, *args, my_type=None, **kwargs): - super(_EntryList, self).__init__(*args, **kwargs) - self.__known_types = {'argument', 'created', 'created_internal', 'returned'} - self.finalizer = None - self._type = [] - if my_type in self.__known_types: - self._type.append(my_type) - - def __repr__(self) -> str: - s = 'Map entry of type: ' - if self._type: - s += ', '.join(self._type) - else: - s += 'Undefined' - if self.finalizer is None: - s += '. Without a finalizer.' - else: - s += '. With a finalizer.' - return s - - def __delitem__(self, key): - super(_EntryList, self).__delitem__(key) - - def remove_type(self, old_type: str): - if old_type in self.__known_types and old_type in self._type: - self._type.remove(old_type) - - def reset_type(self, default_type: str = None): - self._type = [] - self.type = default_type - - @property - def type(self) -> List[str]: - return self._type - - @type.setter - def type(self, value: str): - if value in self.__known_types and value not in self._type: - self._type.append(value) - - @property - def is_argument(self) -> bool: - return 'argument' in self._type - - @property - def is_created(self) -> bool: - return 'created' in self._type - - @property - def is_created_internal(self) -> bool: - return 'created_internal' in self._type - - @property - def is_returned(self) -> bool: - return 'returned' in self._type - - -class Map: - def __init__(self): - # A dictionary of object names and their corresponding objects - self._store = weakref.WeakValueDictionary() - # A dict with object names as keys and a list of their object types as values, with weak references - self.__type_dict = {} - - def _snapshot_items(self): - """ - Return a stable snapshot of __type_dict items. - - Some callers iterate over __type_dict while other threads or - weakref finalizers may modify it. Creating a list snapshot (with - a retry loop) prevents RuntimeError: dictionary changed size - during iteration. - """ - while True: - try: - return list(self.__type_dict.items()) - except RuntimeError: - # Dict changed during snapshot creation, retry - continue - - def vertices(self) -> List[str]: - """ - Returns the vertices of a map. - - Uses a retry loop to handle RuntimeError that can occur when the - WeakValueDictionary is modified during iteration (e.g., by - garbage collection). - """ - while True: - try: - return list(self._store) - except RuntimeError: - # Dictionary changed size during iteration, retry - continue - - def edges(self): - """Returns the edges of a map.""" - return self.__generate_edges() - - @property - def argument_objs(self) -> List[str]: - return self._nested_get('argument') - - @property - def created_objs(self) -> List[str]: - return self._nested_get('created') - - @property - def created_internal(self) -> List[str]: - return self._nested_get('created_internal') - - @property - def returned_objs(self) -> List[str]: - return self._nested_get('returned') - - def _nested_get(self, obj_type: str) -> List[str]: - """Access a nested object in root by key sequence.""" - # Create a stable snapshot of the dict items to avoid RuntimeError - # when the dict is modified during iteration (e.g., by finalizers). - while True: - try: - items = self._snapshot_items() - return [key for key, item in items if obj_type in item.type] - except RuntimeError: - # In case the snapshot itself raises (very rare), retry - continue - - def get_item_by_key(self, item_id: str) -> object: - if item_id in self._store: - return self._store[item_id] - raise ValueError('Item not in map.') - - def is_known(self, vertex: object) -> bool: - """ - Check if a vertex is known in the map. - - All objects should have a 'unique_name' attribute. - """ - return vertex.unique_name in self._store - - def find_type(self, vertex: object) -> List[str]: - if self.is_known(vertex): - return self.__type_dict[vertex.unique_name].type - - def reset_type(self, obj, default_type: str): - if obj.unique_name in self.__type_dict: - self.__type_dict[obj.unique_name].reset_type(default_type) - - def change_type(self, obj, new_type: str): - if obj.unique_name in self.__type_dict: - self.__type_dict[obj.unique_name].type = new_type - - def add_vertex(self, obj: object, obj_type: str = None): - name = obj.unique_name - if name in self._store: - raise ValueError(f'Object name {name} already exists in the graph.') - # Clean up stale entry in __type_dict if the weak reference was collected - # but the finalizer hasn't run yet - if name in self.__type_dict: - del self.__type_dict[name] - - self._store[name] = obj - - entry_list = _EntryList() - entry_list.finalizer = weakref.finalize(obj, self.prune_type_dict, name) - entry_list.type = obj_type - self.__type_dict[name] = entry_list # Add objects type to the list of types - - def add_edge(self, start_obj: object, end_obj: object): - if start_obj.unique_name in self.__type_dict: - self.__type_dict[start_obj.unique_name].append(end_obj.unique_name) - else: - raise AttributeError('Start object not in map.') - - def get_edges(self, start_obj) -> List[str]: - if start_obj.unique_name in self.__type_dict: - return list(self.__type_dict[start_obj.unique_name]) - else: - raise AttributeError - - def __generate_edges(self) -> list: - """ - A static method generating the edges of the map. - - Edges are represented as sets with one (a loop back to the - vertex) or two vertices - """ - edges = [] - # Iterate over a snapshot of items and snapshot neighbour lists to - # avoid concurrent modification issues. - for vertex, neighbours in self._snapshot_items(): - neighbours_snapshot = list(neighbours) - for neighbour in neighbours_snapshot: - if {neighbour, vertex} not in edges: - edges.append({vertex, neighbour}) - return edges - - def prune_vertex_from_edge(self, parent_obj, child_obj): - vertex1 = parent_obj.unique_name - if child_obj is None: - return - vertex2 = child_obj.unique_name - - if vertex1 in self.__type_dict and vertex2 in self.__type_dict[vertex1]: - del self.__type_dict[vertex1][self.__type_dict[vertex1].index(vertex2)] - - def prune_type_dict(self, key: str): - if key in self.__type_dict: - del self.__type_dict[key] - - def prune(self, key: str): - if key in self.__type_dict: - del self.__type_dict[key] - if key in self._store: - del self._store[key] - - def find_isolated_vertices(self) -> list: - """Returns a list of isolated vertices.""" - isolated = [] - for vertex, neighbours in self._snapshot_items(): - if not list(neighbours): - isolated.append(vertex) - return isolated - - def find_path(self, start_vertex: str, end_vertex: str, path=[]) -> list: - """Find a path from start_vertex to end_vertex in map.""" - - graph = self.__type_dict - path = path + [start_vertex] - if start_vertex == end_vertex: - return path - if start_vertex not in graph: - return [] - for vertex in graph[start_vertex]: - if vertex not in path: - extended_path = self.find_path(vertex, end_vertex, path) - if extended_path: - return extended_path - return [] - - def find_all_paths(self, start_vertex: str, end_vertex: str, path=[]) -> list: - """Find all paths from start_vertex to end_vertex in map.""" - - graph = self.__type_dict - path = path + [start_vertex] - if start_vertex == end_vertex: - return [path] - if start_vertex not in graph: - return [] - paths = [] - for vertex in graph[start_vertex]: - if vertex not in path: - extended_paths = self.find_all_paths(vertex, end_vertex, path) - for p in extended_paths: - paths.append(p) - return paths - - def reverse_route(self, end_vertex: str, start_vertex: Optional[str] = None) -> List: - """ - In this case we have an object and want to know the connections - to get to another in reverse. - - We might not know the start_object. In which case we follow the - shortest path to a base vertex. - - Parameters - ---------- - end_vertex : str - Final vertex in the path. - start_vertex : Optional[str], default=None - Starting vertex. If omitted, the shortest reverse route is - chosen automatically. - - Returns - ------- - List - Reverse path from ``end_vertex`` back to ``start_vertex``. - """ - path_length = sys.maxsize - optimum_path = [] - if start_vertex is None: - # We now have to find where to begin..... Iterate over a snapshot - for possible_start, vertices in self._snapshot_items(): - vertices_snapshot = list(vertices) - if end_vertex in vertices_snapshot: - temp_path = self.find_path(possible_start, end_vertex) - if len(temp_path) < path_length: - path_length = len(temp_path) - optimum_path = temp_path - else: - optimum_path = self.find_path(start_vertex, end_vertex) - optimum_path.reverse() - return optimum_path - - def is_connected(self, vertices_encountered=None, start_vertex=None) -> bool: - """Determines if the map is connected.""" - if vertices_encountered is None: - vertices_encountered = set() - graph = self.__type_dict - vertices = list(graph) - if not start_vertex: - # chose a vertex from graph as a starting point - start_vertex = vertices[0] - vertices_encountered.add(start_vertex) - if len(vertices_encountered) != len(vertices): - for vertex in list(graph[start_vertex]): - if vertex not in vertices_encountered and self.is_connected( - vertices_encountered, vertex - ): - return True - else: - return True - return False - - def _clear(self): - """ - Reset the map to an empty state. - - Only to be used for testing - """ - self._store.clear() - self.__type_dict.clear() - gc.collect() - - def __repr__(self) -> str: - return f'Map object of {len(self._store)} vertices.' diff --git a/src/easyscience/global_object/object_registry.py b/src/easyscience/global_object/object_registry.py new file mode 100644 index 00000000..3fb5b007 --- /dev/null +++ b/src/easyscience/global_object/object_registry.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import weakref +from typing import Iterator + + +class ObjectRegistry: + """Global registry of EasyScience objects, indexed by ``unique_name``. + + Registered objects must have a ``unique_name`` attribute. Values are + held weakly, so an entry disappears once nothing else references the + object. + + Supports ``name in registry``, ``registry[name]``, ``len(registry)`` + and iteration over the registered names. + """ + + def __init__(self): + # A dict with object names as keys and a list of their object + # types as values, with weak references + self._store = weakref.WeakValueDictionary() + + def __getitem__(self, name: str) -> object: + return self._store[name] + + def __contains__(self, name: object) -> bool: + return name in self._store + + def __iter__(self) -> Iterator[str]: + # Iterate over a snapshot, so objects collected mid-loop are safe + return iter(list(self._store)) + + def __len__(self) -> int: + return len(self._store) + + def names(self) -> list[str]: + """All registered unique names, including retained old names.""" + return list(self._store) + + def objects(self) -> list[object]: + """All registered objects that are still alive. + + The list holds strong references, so it keeps those objects alive + until it is released. An object renamed via ``unique_name`` keeps + its old name registered, so it can appear more than once. + """ + return list(self._store.values()) + + def add(self, obj: object) -> None: + """Register ``obj`` under its ``unique_name``. + + Parameters + ---------- + obj : object + The object to register. It must have a ``unique_name`` + attribute. + + Raises + ------ + ValueError + If the name is already registered. + """ + name = obj.unique_name + if name in self._store: + raise ValueError(f'Object name {name} already exists in the registry.') + self._store[name] = obj + + def discard(self, name: str) -> None: + """Remove ``name`` from the registry, if present.""" + self._store.pop(name, None) + + def clear(self) -> None: + """Empty the registry.""" + self._store.clear() + + def __repr__(self) -> str: + return f'ObjectRegistry object of {len(self._store)} entries.' diff --git a/src/easyscience/variable/parameter.py b/src/easyscience/variable/parameter.py index 499bdd81..0d930954 100644 --- a/src/easyscience/variable/parameter.py +++ b/src/easyscience/variable/parameter.py @@ -916,15 +916,15 @@ def _process_dependency_unique_names(self, dependency_expression: str): inputted_unique_names += re.findall('(".+?")', dependency_expression) clean_dependency_string = dependency_expression - existing_unique_names = self._global_object.map.vertices() # Add the unique names of the parameters to the ASTEVAL interpreter for name in inputted_unique_names: stripped_name = name.strip('\'"') - if stripped_name not in existing_unique_names: + try: + dependent_parameter = self._global_object.registry[stripped_name] + except KeyError: raise ValueError( f'A Parameter with unique_name {stripped_name} does not exist. Please check your dependency expression.' - ) # noqa: E501 - dependent_parameter = self._global_object.map.get_item_by_key(stripped_name) + ) from None # noqa: E501 if isinstance(dependent_parameter, DescriptorNumber): self._dependency_map['__' + stripped_name + '__'] = dependent_parameter clean_dependency_string = clean_dependency_string.replace( @@ -1338,9 +1338,9 @@ def resolve_pending_dependencies(self) -> None: def _find_parameter_by_serializer_id(self, serializer_id: str) -> Optional['DescriptorNumber']: """ Find a parameter by its serializer_id from all parameters in the - global map. + object registry. """ - for obj in self._global_object.map._store.values(): + for obj in self._global_object.registry.objects(): if isinstance(obj, DescriptorNumber) and hasattr( obj, '_DescriptorNumber__serializer_id' ): diff --git a/tests/integration/fitting/test_stateless_calculator.py b/tests/integration/fitting/test_stateless_calculator.py index 79a2917b..e14d6d95 100644 --- a/tests/integration/fitting/test_stateless_calculator.py +++ b/tests/integration/fitting/test_stateless_calculator.py @@ -41,9 +41,9 @@ def fit_func_for(self, model: CoefficientModel): @pytest.fixture(autouse=True) def clear(): - global_object.map._clear() + global_object.registry.clear() yield - global_object.map._clear() + global_object.registry.clear() if global_object.stack: global_object.stack.clear() global_object.stack.enabled = False diff --git a/tests/unit/base_classes/test_easy_list.py b/tests/unit/base_classes/test_easy_list.py index f2c5ea6a..93fc5b86 100644 --- a/tests/unit/base_classes/test_easy_list.py +++ b/tests/unit/base_classes/test_easy_list.py @@ -76,7 +76,7 @@ def component(self): class TestEasyList: @pytest.fixture(autouse=True) def clear(self): - global_object.map._clear() + global_object.registry.clear() @pytest.fixture def populated_list(self): @@ -560,8 +560,8 @@ def test_from_dict_round_trip(self): a2 = Alpha(unique_name='a2') el = EasyList(a1, a2, unique_name='my_list', protected_types=Alpha) d = el.to_dict() - # Clear the global map so deserialized objects can reuse the same unique names - global_object.map._clear() + # Clear the global registry so deserialized objects can reuse the same unique names + global_object.registry.clear() el2 = EasyList.from_dict(d) assert len(el2) == 2 assert el2[0].unique_name == 'a1' @@ -620,7 +620,7 @@ def test_to_dict_nested_list_uses_override(self): assert nested['protected_types'][0]['@class'] == 'Alpha' assert nested['data'][0]['unique_name'] == 'a1' - global_object.map._clear() + global_object.registry.clear() outer2 = EasyList.from_dict(d) assert isinstance(outer2[0], EasyList) assert outer2[0]._protected_types == [Alpha] @@ -632,7 +632,7 @@ def test_from_dict_round_trip_keyword_only_arguments(self): d = el.to_dict() assert d['unique_name'] == 'my_list' assert d['display_name'] == 'My list' - global_object.map._clear() + global_object.registry.clear() el2 = EasyList.from_dict(d) assert el2.unique_name == 'my_list' assert el2.display_name == 'My list' diff --git a/tests/unit/base_classes/test_model_base.py b/tests/unit/base_classes/test_model_base.py index c8b6fbf9..5a1e6eb2 100644 --- a/tests/unit/base_classes/test_model_base.py +++ b/tests/unit/base_classes/test_model_base.py @@ -104,8 +104,8 @@ def nested_model(self, monkeypatch): @pytest.fixture def clear(self): - # Clear the global object map before each test - global_object.map._clear() + # Clear the global object registry before each test + global_object.registry.clear() def test_init(self): # When Then @@ -195,7 +195,7 @@ def test_from_dict(self, monkeypatch, clear): ) ), ) - global_object.map._clear() + global_object.registry.clear() # Then new_model = MockModelComponent.from_dict(obj_dict) # Expect @@ -204,11 +204,10 @@ def test_from_dict(self, monkeypatch, clear): assert new_model._room_temperature.value == 22.0 assert isinstance(new_model._temperature, Parameter) assert isinstance(new_model._room_temperature, DescriptorNumber) - assert len(global_object.map.vertices()) == 4 - assert global_object.map.get_item_by_key('Parameter_0') is new_model._temperature + assert len(global_object.registry) == 4 + assert global_object.registry['Parameter_0'] is new_model._temperature assert ( - len([param for param in global_object.map.vertices() if param.startswith('Parameter')]) - == 1 + len([param for param in global_object.registry if param.startswith('Parameter')]) == 1 ) def test_from_dict_nested(self, monkeypatch, clear): @@ -236,7 +235,7 @@ def test_from_dict_nested(self, monkeypatch, clear): ) ), ) - global_object.map._clear() + global_object.registry.clear() # Then new_model = MockModelFull.from_dict(obj_dict) # Expect @@ -250,15 +249,14 @@ def test_from_dict_nested(self, monkeypatch, clear): assert isinstance(new_model._area, Parameter) assert isinstance(new_model.component._temperature, Parameter) assert isinstance(new_model.component._room_temperature, DescriptorNumber) - assert len(global_object.map.vertices()) == 7 - assert global_object.map.get_item_by_key('Parameter_0') in [ + assert len(global_object.registry) == 7 + assert global_object.registry['Parameter_0'] in [ new_model._pressure, new_model._area, new_model.component._temperature, ] assert ( - len([param for param in global_object.map.vertices() if param.startswith('Parameter')]) - == 3 + len([param for param in global_object.registry if param.startswith('Parameter')]) == 3 ) def test_from_dict_not_easyscience(self): @@ -314,7 +312,7 @@ def test_from_dict_parameter_setting_failure(self, monkeypatch, clear): 'temperature', property(lambda self: (_ for _ in ()).throw(Exception('Simulated failure'))), ) - global_object.map._clear() + global_object.registry.clear() # Then Expect with pytest.raises( SyntaxError, diff --git a/tests/unit/base_classes/test_model_with_easy_list.py b/tests/unit/base_classes/test_model_with_easy_list.py index 5bc9d50e..debb78df 100644 --- a/tests/unit/base_classes/test_model_with_easy_list.py +++ b/tests/unit/base_classes/test_model_with_easy_list.py @@ -15,8 +15,8 @@ @pytest.fixture(autouse=True) def clear(): - """Clear global object map before each test.""" - global_object.map._clear() + """Clear global object registry before each test.""" + global_object.registry.clear() @pytest.mark.parametrize( @@ -86,7 +86,7 @@ def test_dict_round_trip_unnamed_model_keeps_coefficient_names(): ) d = model.to_dict() - global_object.map._clear() # the explicit name is kept, so free it first + global_object.registry.clear() # the explicit name is kept, so free it first new_model = CoefficientModel.from_dict(d) assert 'unique_name' not in d diff --git a/tests/unit/base_classes/test_new_base.py b/tests/unit/base_classes/test_new_base.py index bd86298f..f15252f2 100644 --- a/tests/unit/base_classes/test_new_base.py +++ b/tests/unit/base_classes/test_new_base.py @@ -10,8 +10,8 @@ class TestNewBase: @pytest.fixture def clear(self): - # Clear the global object map before each test - global_object.map._clear() + # Clear the global object registry before each test + global_object.registry.clear() def test_constructor_defaults(self, clear): # When Then @@ -20,7 +20,7 @@ def test_constructor_defaults(self, clear): assert obj._unique_name == 'NewBase_0' assert obj._default_unique_name is True assert obj._display_name is None - assert global_object.map.get_item_by_key('NewBase_0') is obj + assert global_object.registry['NewBase_0'] is obj def test_constructor_with_arguments(self): # When Then @@ -29,7 +29,7 @@ def test_constructor_with_arguments(self): assert obj._unique_name == 'custom_name' assert obj._default_unique_name is False assert obj._display_name == 'My Object' - assert global_object.map.get_item_by_key('custom_name') is obj + assert global_object.registry['custom_name'] is obj def test_constructor_invalid_unique_name(self): # When Then Expect @@ -71,8 +71,8 @@ def test_unique_name_setter(self, clear): # Expect assert obj.unique_name == 'new_unique_name' assert obj._default_unique_name is False - assert global_object.map.get_item_by_key('new_unique_name') is obj - assert global_object.map.get_item_by_key('NewBase_0') is obj + assert global_object.registry['new_unique_name'] is obj + assert global_object.registry['NewBase_0'] is obj def test_unique_name_setter_invalid(self): # When @@ -187,7 +187,7 @@ def test_from_dict(self): assert isinstance(obj, NewBase) assert obj.unique_name == 'from_dict_name' assert obj.display_name == 'From Dict Object' - assert global_object.map.get_item_by_key('from_dict_name') is obj + assert global_object.registry['from_dict_name'] is obj def test_from_dict_not_easyscience(self): # When @@ -242,7 +242,7 @@ def test_copy(self): assert isinstance(obj_copy, NewBase) assert obj_copy.unique_name != obj.unique_name assert obj_copy.display_name == obj.display_name - assert global_object.map.get_item_by_key(obj_copy.unique_name) is obj_copy + assert global_object.registry[obj_copy.unique_name] is obj_copy def test_deepcopy(self): # When @@ -255,4 +255,4 @@ def test_deepcopy(self): assert isinstance(obj_deepcopy, NewBase) assert obj_deepcopy.unique_name != obj.unique_name assert obj_deepcopy.display_name == obj.display_name - assert global_object.map.get_item_by_key(obj_deepcopy.unique_name) is obj_deepcopy + assert global_object.registry[obj_deepcopy.unique_name] is obj_deepcopy diff --git a/tests/unit/base_classes/test_serializer_base.py b/tests/unit/base_classes/test_serializer_base.py index 5dd159d8..c4c4d998 100644 --- a/tests/unit/base_classes/test_serializer_base.py +++ b/tests/unit/base_classes/test_serializer_base.py @@ -99,11 +99,11 @@ class TestSerializerBase: @pytest.fixture def clear(self): """Clear everything before and after each test""" - global_object.map._clear() + global_object.registry.clear() if global_object.stack: global_object.stack.clear() yield - global_object.map._clear() + global_object.registry.clear() if global_object.stack: global_object.stack.clear() @@ -326,7 +326,7 @@ def test_convert_to_dict_nested_parameter_round_trip(self, serializer, clear): assert nested['@class'] == 'Parameter' json.dumps(nested) - global_object.map._clear() + global_object.registry.clear() restored = Parameter.from_dict(nested) assert restored is not model._p @@ -570,7 +570,7 @@ def test_concrete_serializer_implementation(self, clear): assert encoded['value'] == 100 # Test decode - global_object.map._clear() # Clear before decode + global_object.registry.clear() # Clear before decode decoded = ConcreteSerializer.decode(encoded) assert isinstance(decoded, MockSerializableObj) assert decoded.name == 'concrete_test' diff --git a/tests/unit/global_object/test_entry_list_comprehensive.py b/tests/unit/global_object/test_entry_list_comprehensive.py deleted file mode 100644 index 7a15ba2f..00000000 --- a/tests/unit/global_object/test_entry_list_comprehensive.py +++ /dev/null @@ -1,320 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from unittest.mock import MagicMock - -from easyscience.global_object.map import _EntryList - - -class TestEntryListComprehensive: - """Comprehensive tests for _EntryList class""" - - def test_inheritance(self): - """Test that _EntryList properly inherits from list""" - entry = _EntryList([1, 2, 3]) - - # Should have list functionality - assert len(entry) == 3 - assert entry[0] == 1 - assert 2 in entry - - # Can append like a normal list - entry.append(4) - assert len(entry) == 4 - - def test_init_with_list_data(self): - """Test initialization with existing list data""" - data = ['a', 'b', 'c'] - entry = _EntryList(data, my_type='created') - - assert len(entry) == 3 - assert entry[0] == 'a' - assert 'created' in entry.type - - def test_known_types_validation(self): - """Test validation of known types""" - entry = _EntryList() - known_types = {'argument', 'created', 'created_internal', 'returned'} - - # Test all known types - for known_type in known_types: - entry.type = known_type - assert known_type in entry.type - - # Test unknown type - entry.type = 'unknown_type' - assert 'unknown_type' not in entry.type - - def test_finalizer_attribute(self): - """Test finalizer attribute handling""" - entry = _EntryList() - - # Initially None - assert entry.finalizer is None - - # Can set to any value - mock_finalizer = MagicMock() - entry.finalizer = mock_finalizer - assert entry.finalizer is mock_finalizer - - def test_delitem_override(self): - """Test __delitem__ override""" - entry = _EntryList(['a', 'b', 'c']) - - # Should work like normal list deletion - del entry[1] - assert len(entry) == 2 - assert entry == ['a', 'c'] - - def test_repr_variations(self): - """Test various __repr__ scenarios""" - # Empty type, no finalizer - entry = _EntryList() - repr_str = str(entry) - assert 'Undefined' in repr_str - assert 'Without' in repr_str - - # Single type, no finalizer - entry.type = 'created' - repr_str = str(entry) - assert 'created' in repr_str - assert 'Without' in repr_str - - # Multiple types, no finalizer - entry.type = 'argument' - repr_str = str(entry) - assert 'created' in repr_str - assert 'argument' in repr_str - assert 'Without' in repr_str - - # With finalizer - entry.finalizer = MagicMock() - repr_str = str(entry) - assert 'Without' not in repr_str - - def test_type_setter_edge_cases(self): - """Test edge cases for type setter""" - entry = _EntryList() - - # Setting None should not crash - entry.type = None - assert None not in entry.type - - # Setting empty string - entry.type = '' - assert '' not in entry.type - - # Setting numeric value - entry.type = 123 - assert 123 not in entry.type - - def test_remove_type_edge_cases(self): - """Test edge cases for remove_type""" - entry = _EntryList() - entry.type = 'created' - entry.type = 'argument' - - # Remove type that exists - entry.remove_type('created') - assert 'created' not in entry.type - assert 'argument' in entry.type - - # Remove type that doesn't exist - entry.remove_type('returned') # Not in list - assert 'argument' in entry.type # Should be unchanged - - # Remove unknown type - entry.remove_type('unknown') - assert 'argument' in entry.type # Should be unchanged - - # Remove None - entry.remove_type(None) - assert 'argument' in entry.type # Should be unchanged - - def test_reset_type_variations(self): - """Test reset_type with various inputs""" - entry = _EntryList() - entry.type = 'created' - entry.type = 'argument' - - # Reset with valid type - entry.reset_type('returned') - assert entry.type == ['returned'] - - # Reset with invalid type - entry.type = 'created' # Add something first - entry.reset_type('invalid') - assert entry.type == [] # Should be empty since invalid - - # Reset with None - entry.type = 'created' - entry.reset_type(None) - assert entry.type == [] - - def test_boolean_properties_combinations(self): - """Test boolean properties with multiple types""" - entry = _EntryList() - - # Add multiple types - entry.type = 'created' - entry.type = 'argument' - entry.type = 'returned' - - # All should be true - assert entry.is_created - assert entry.is_argument - assert entry.is_returned - assert not entry.is_created_internal # Not added - - # Remove one - entry.remove_type('argument') - assert entry.is_created - assert not entry.is_argument - assert entry.is_returned - - def test_list_functionality_preserved(self): - """Test that list functionality is preserved""" - entry = _EntryList() - - # Basic list operations - entry.append('a') - entry.append('b') - entry.insert(1, 'x') - assert entry == ['a', 'x', 'b'] - - # List methods - assert entry.index('x') == 1 - assert entry.count('a') == 1 - - # Slicing - subset = entry[1:] - assert subset == ['x', 'b'] - - # Iteration - items = [item for item in entry] - assert items == ['a', 'x', 'b'] - - def test_type_persistence_during_list_operations(self): - """Test that type information persists during list operations""" - entry = _EntryList(my_type='created') - entry.type = 'argument' - - # Perform list operations - entry.append('item1') - entry.extend(['item2', 'item3']) - entry.sort() - - # Type information should be preserved - assert entry.is_created - assert entry.is_argument - - def test_equality_with_regular_list(self): - """Test equality comparison with regular list""" - entry = _EntryList(['a', 'b', 'c']) - regular_list = ['a', 'b', 'c'] - - # Should be equal in content - assert entry == regular_list - assert regular_list == entry - - # But not the same type - assert type(entry) != type(regular_list) - assert isinstance(entry, _EntryList) - assert isinstance(entry, list) - - def test_copy_behavior(self): - """Test copying behavior""" - import copy - - entry = _EntryList(['a', 'b'], my_type='created') - entry.finalizer = MagicMock() - - # Shallow copy - shallow_copy = copy.copy(entry) - assert shallow_copy == entry - assert shallow_copy.type == entry.type - assert shallow_copy.finalizer is entry.finalizer - - # Deep copy - deep_copy = copy.deepcopy(entry) - assert deep_copy == entry - assert deep_copy.type == entry.type - # Finalizer might be different in deep copy - - def test_memory_efficiency(self): - """Test that _EntryList doesn't consume excessive memory""" - # Create many entries - entries = [] - for i in range(1000): - entry = _EntryList([f'item_{i}'], my_type='created') - entries.append(entry) - - # Should not raise memory errors - assert len(entries) == 1000 - assert all(entry.is_created for entry in entries) - - def test_thread_safety_simulation(self): - """Test basic thread safety aspects (simulation)""" - import threading - import time - - entry = _EntryList() - results = [] - - def add_type(type_name, delay=0.001): - time.sleep(delay) - entry.type = type_name - results.append(type_name) - - # Simulate concurrent access - threads = [] - for i, type_name in enumerate(['created', 'argument', 'returned']): - thread = threading.Thread(target=add_type, args=(type_name,)) - threads.append(thread) - - for thread in threads: - thread.start() - - for thread in threads: - thread.join() - - # All types should be added (order might vary) - assert len(set(entry.type)) == 3 - assert all(t in entry.type for t in ['created', 'argument', 'returned']) - - def test_pickle_serialization(self): - """Test pickle serialization support""" - import pickle - - entry = _EntryList(['a', 'b', 'c'], my_type='created') - entry.type = 'argument' - - # Serialize - pickled = pickle.dumps(entry) - - # Deserialize - unpickled = pickle.loads(pickled) - - # Should preserve data and types - assert unpickled == entry - assert unpickled.type == entry.type - assert unpickled.is_created - assert unpickled.is_argument - - def test_subclassing(self): - """Test that _EntryList can be subclassed""" - - class CustomEntryList(_EntryList): - def __init__(self, *args, custom_attr=None, **kwargs): - super().__init__(*args, **kwargs) - self.custom_attr = custom_attr - - def custom_method(self): - return f'Custom: {len(self)} items' - - custom = CustomEntryList(['x', 'y'], my_type='created', custom_attr='test') - - assert custom.custom_attr == 'test' - assert custom.custom_method() == 'Custom: 2 items' - assert custom.is_created - assert len(custom) == 2 diff --git a/tests/unit/global_object/test_global_object.py b/tests/unit/global_object/test_global_object.py index 4345ee33..f634d996 100644 --- a/tests/unit/global_object/test_global_object.py +++ b/tests/unit/global_object/test_global_object.py @@ -16,7 +16,7 @@ def test_init(self): # Expect assert global_object.log == GlobalObject().log - assert global_object.map == GlobalObject().map + assert global_object.registry == GlobalObject().registry assert global_object.stack == GlobalObject().stack assert global_object.debug == GlobalObject().debug @@ -50,10 +50,10 @@ def test_generate_unique_name_already_taken(self): @pytest.fixture def clear_global_map(self): - """Clear global map before and after each test""" - global_object.map._clear() + """Clear global registry before and after each test""" + global_object.registry.clear() yield - global_object.map._clear() + global_object.registry.clear() def test_singleton_behavior(self): """Test that GlobalObject is truly a singleton""" @@ -64,7 +64,7 @@ def test_singleton_behavior(self): # Then assert obj1 is obj2 assert id(obj1) == id(obj2) - assert obj1.map is obj2.map + assert obj1.registry is obj2.registry assert obj1.log is obj2.log def test_instantiate_stack(self, clear_global_map): @@ -94,7 +94,7 @@ def test_debug_property(self): assert global_obj.debug is global_obj2.debug def test_generate_unique_name_empty_map(self, clear_global_map): - """Test unique name generation with empty map""" + """Test unique name generation with empty registry""" # Given global_obj = GlobalObject() @@ -157,10 +157,10 @@ def test_integration_with_parameter_creation(self, clear_global_map): param2 = Parameter(display_name='test2', value=2.0) # Then - assert len(global_obj.map.vertices()) == 2 - assert param1.unique_name in global_obj.map.vertices() - assert param2.unique_name in global_obj.map.vertices() + assert len(global_obj.registry) == 2 + assert param1.unique_name in global_obj.registry + assert param2.unique_name in global_obj.registry # Test retrieval - retrieved1 = global_obj.map.get_item_by_key(param1.unique_name) + retrieved1 = global_obj.registry[param1.unique_name] assert retrieved1 is param1 diff --git a/tests/unit/global_object/test_integration_comprehensive.py b/tests/unit/global_object/test_integration_comprehensive.py index 38bdfa5c..5b7667bd 100644 --- a/tests/unit/global_object/test_integration_comprehensive.py +++ b/tests/unit/global_object/test_integration_comprehensive.py @@ -15,13 +15,11 @@ class Container(ModelBase): - """ModelBase holding arbitrary named children, linked in the global map.""" + """ModelBase holding arbitrary named children.""" def __init__(self, display_name=None, **children): super().__init__(display_name=display_name) self._children = children - for child in children.values(): - global_object.map.add_edge(self, child) def __getattr__(self, key): children = self.__dict__.get('_children', {}) @@ -36,11 +34,11 @@ class TestGlobalObjectIntegration: @pytest.fixture def clear_all(self): """Clear everything before and after each test""" - global_object.map._clear() + global_object.registry.clear() if global_object.stack: global_object.stack.clear() yield - global_object.map._clear() + global_object.registry.clear() if global_object.stack: global_object.stack.clear() @@ -52,10 +50,8 @@ def test_parameter_lifecycle_integration(self, clear_all): # When - Create parameter param = Parameter(display_name='test_param', value=10.0, unit='m') - # Then - Should be registered in global map - assert global_obj.map.is_known(param) - assert param.unique_name in global_obj.map.vertices() - assert 'created' in global_obj.map.find_type(param) + # Then - Should be registered in global registry + assert param.unique_name in global_obj.registry # When - Modify parameter with undo/redo enabled if not global_obj.stack: @@ -77,18 +73,18 @@ def test_parameter_lifecycle_integration(self, clear_all): # When - Delete parameter param_name = param.unique_name - original_count = len(global_obj.map.vertices()) + original_count = len(global_obj.registry) del param gc.collect() - # Then - Should be removed from global map (via weak references) + # Then - Should be removed from global registry (via weak references) # Note: garbage collection timing can vary, so we check count decrease # or explicit cleanup - final_count = len(global_obj.map.vertices()) - if param_name in global_obj.map.vertices(): + final_count = len(global_obj.registry) + if param_name in global_obj.registry: # If still present, manually clean up for test consistency - global_obj.map.prune(param_name) - assert param_name not in global_obj.map.vertices() + global_obj.registry.discard(param_name) + assert param_name not in global_obj.registry def test_model_parameter_relationship(self, clear_all): """Test a model containing parameters integration""" @@ -101,15 +97,14 @@ def test_model_parameter_relationship(self, clear_all): # When - Create a container with parameters obj = Container(display_name='rectangle', length=param1, width=param2) - # Then - All should be in global map - assert global_obj.map.is_known(obj) - assert global_obj.map.is_known(param1) - assert global_obj.map.is_known(param2) + # Then - All should be in global registry + assert obj.unique_name in global_obj.registry + assert param1.unique_name in global_obj.registry + assert param2.unique_name in global_obj.registry - # Check relationships in map - obj_edges = global_obj.map.get_edges(obj) - assert param1.unique_name in obj_edges - assert param2.unique_name in obj_edges + # Check that the container holds the parameters + assert obj.length is param1 + assert obj.width is param2 # When - Modify through the container if not global_obj.stack: @@ -150,37 +145,9 @@ def test_unique_name_generation_integration(self, clear_all): assert obj.unique_name == 'Container_0' assert desc.unique_name == 'DescriptorBool_0' - def test_map_vertex_type_management(self, clear_all): - """Test comprehensive vertex type management""" - # Given - global_obj = GlobalObject() - param = Parameter(display_name='test', value=1.0) - - # When - Check initial type - initial_types = global_obj.map.find_type(param) - assert 'created' in initial_types - - # When - Change type - global_obj.map.change_type(param, 'argument') - updated_types = global_obj.map.find_type(param) - assert 'created' in updated_types - assert 'argument' in updated_types - - # When - Reset type - global_obj.map.reset_type(param, 'returned') - final_types = global_obj.map.find_type(param) - assert final_types == ['returned'] - - # When - Filter by type - returned_objs = global_obj.map.returned_objs - assert param.unique_name in returned_objs - - created_objs = global_obj.map.created_objs - assert param.unique_name not in created_objs - @pytest.mark.skip(reason='Weak reference timing can be inconsistent in tests') def test_weak_reference_cleanup_integration(self, clear_all): - """Test that weak references properly clean up the map""" + """Test that weak references properly clean up the registry""" # Given global_obj = GlobalObject() @@ -194,25 +161,25 @@ def test_weak_reference_cleanup_integration(self, clear_all): obj_name = obj.unique_name # Then - All should be present - assert global_obj.map.is_known(param1) - assert global_obj.map.is_known(param2) - assert global_obj.map.is_known(obj) + assert param1_name in global_obj.registry + assert param2_name in global_obj.registry + assert obj_name in global_obj.registry # When - Delete objects del param1 gc.collect() # Then - param1 should be removed - assert param1_name not in global_obj.map.vertices() - assert param2_name in global_obj.map.vertices() - assert obj_name in global_obj.map.vertices() + assert param1_name not in global_obj.registry + assert param2_name in global_obj.registry + assert obj_name in global_obj.registry # When - Delete remaining objects del param2, obj gc.collect() - # Then - Map should be empty - assert len(global_obj.map.vertices()) == 0 + # Then - registry should be empty + assert len(global_obj.registry) == 0 def test_complex_undo_redo_scenario(self, clear_all): """Test complex undo/redo scenario with multiple objects""" @@ -263,72 +230,23 @@ def test_complex_undo_redo_scenario(self, clear_all): assert box.width.value == 7.0 assert box.height.value == 4.0 - def test_map_path_finding_integration(self, clear_all): - """Test map path finding with real object relationships""" - # Given - global_obj = GlobalObject() - - # Create a hierarchy: container -> sub_container -> parameter - param = Parameter(display_name='value', value=42.0) - sub_container = Container(display_name='sub', value=param) - main_container = Container(display_name='main', sub=sub_container) - - # When - Find path from main to parameter - path = global_obj.map.find_path(main_container.unique_name, param.unique_name) - - # Then - Should find the path through the hierarchy - assert len(path) >= 2 - assert path[0] == main_container.unique_name - assert path[-1] == param.unique_name - - # When - Find reverse path - reverse_path = global_obj.map.reverse_route(param.unique_name, main_container.unique_name) - - # Then - Should be the reverse - assert reverse_path[0] == param.unique_name - assert reverse_path[-1] == main_container.unique_name - - def test_map_connectivity_with_isolated_objects(self, clear_all): - """Test map connectivity detection""" - # Given - global_obj = GlobalObject() - - # When - Create connected objects - param1 = Parameter(display_name='connected1', value=1.0) - param2 = Parameter(display_name='connected2', value=2.0) - container = Container(display_name='container', p1=param1, p2=param2) - - # Then - Map should be connected - # TODO: Depending on implementation, connectivity might vary - # assert global_obj.map.is_connected() - - # When - Create isolated object - isolated = Parameter(display_name='isolated', value=99.0) - # Remove its automatic connection by clearing edges - # (In real usage, isolated objects would be rare) - - # Then - Map might still be connected depending on implementation - # This tests the connectivity algorithm - connectivity = global_obj.map.is_connected() - assert isinstance(connectivity, bool) - def test_error_handling_integration(self, clear_all): """Test error handling across the global object system""" # Given global_obj = GlobalObject() # When - Try to get non-existent object - with pytest.raises(ValueError, match='Item not in map'): - global_obj.map.get_item_by_key('non_existent') + with pytest.raises(KeyError): + global_obj.registry['non_existent'] # When - Try to add object with duplicate name param1 = Parameter(display_name='test', value=1.0) param1_name = param1.unique_name - # Create another with same unique name (should fail in add_vertex) + # Create another with same unique name (should fail in add) with pytest.raises(ValueError, match='already exists'): # This simulates what would happen if we tried to add duplicate - global_obj.map.add_vertex(param1) # param1 already added during creation + global_obj.registry.add(param1) # param1 already added during creation def test_memory_pressure_simulation(self, clear_all): """Test system behavior under memory pressure""" @@ -342,7 +260,7 @@ def test_memory_pressure_simulation(self, clear_all): obj = Container(display_name=f'obj_{i}', param=param) objects.append((param, obj)) - initial_vertex_count = len(global_obj.map.vertices()) + initial_vertex_count = len(global_obj.registry) assert initial_vertex_count == 200 # 100 params + 100 objs # When - Delete half the objects @@ -354,16 +272,16 @@ def test_memory_pressure_simulation(self, clear_all): # Force garbage collection gc.collect() - # Then - Map should have fewer vertices - current_vertex_count = len(global_obj.map.vertices()) + # Then - registry should have fewer vertices + current_vertex_count = len(global_obj.registry) assert current_vertex_count <= initial_vertex_count # When - Delete all remaining objects del objects gc.collect() - # Then - Map should be mostly empty (might have some remaining references) - final_vertex_count = len(global_obj.map.vertices()) + # Then - registry should be mostly empty (might have some remaining references) + final_vertex_count = len(global_obj.registry) assert final_vertex_count < initial_vertex_count def test_serialization_integration_with_global_state(self, clear_all): @@ -375,24 +293,24 @@ def test_serialization_integration_with_global_state(self, clear_all): param = Parameter(display_name='test_param', value=123.45, unit='kg') obj = CoefficientModel(display_name='test_obj', coefficients=[param]) - original_vertex_count = len(global_obj.map.vertices()) + original_vertex_count = len(global_obj.registry) # When - Serialize objects param_dict = param.to_dict() obj_dict = obj.to_dict() # Clear global state - global_obj.map._clear() - assert len(global_obj.map.vertices()) == 0 + global_obj.registry.clear() + assert len(global_obj.registry) == 0 # When - Deserialize objects new_param = Parameter.from_dict(param_dict) new_obj = CoefficientModel.from_dict(obj_dict) - # Then - Should be registered in global map again - assert len(global_obj.map.vertices()) >= 2 - assert global_obj.map.is_known(new_param) - assert global_obj.map.is_known(new_obj) + # Then - Should be registered in global registry again + assert len(global_obj.registry) >= 2 + assert new_param.unique_name in global_obj.registry + assert new_obj.unique_name in global_obj.registry # Objects should be functionally equivalent assert new_param.display_name == param.display_name @@ -470,6 +388,6 @@ def create_objects(thread_id, count=10): assert len(results) == 15 # 3 threads * 5 objects each assert len(set(results)) == 15 # All unique names should be unique - # All objects should be in the map + # All objects should be in the registry for unique_name in results: - assert unique_name in global_obj.map.vertices() + assert unique_name in global_obj.registry diff --git a/tests/unit/global_object/test_map.py b/tests/unit/global_object/test_map.py deleted file mode 100644 index 4062db2f..00000000 --- a/tests/unit/global_object/test_map.py +++ /dev/null @@ -1,759 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -import gc -from unittest.mock import MagicMock - -import pytest - -from easyscience import Parameter -from easyscience import global_object -from easyscience.base_classes import NewBase -from easyscience.global_object.map import Map -from easyscience.global_object.map import _EntryList - - -class TestEntryList: - """Test the _EntryList helper class""" - - def test_init_default(self): - """Test _EntryList initialization with defaults""" - entry = _EntryList() - assert entry._type == [] - assert entry.finalizer is None - - def test_init_with_known_type(self): - """Test _EntryList initialization with known type""" - entry = _EntryList(my_type='created') - assert 'created' in entry._type - - def test_init_with_unknown_type(self): - """Test _EntryList initialization with unknown type""" - entry = _EntryList(my_type='unknown') - assert entry._type == [] - - def test_repr(self): - """Test string representation""" - entry = _EntryList() - repr_str = str(entry) - assert 'Undefined' in repr_str - assert 'finalizer' in repr_str - - entry.type = 'created' - repr_str = str(entry) - assert 'created' in repr_str - assert 'finalizer' in repr_str - - def test_type_property_getter(self): - """Test type property getter""" - entry = _EntryList() - assert entry.type == [] - - def test_type_property_setter_valid(self): - """Test type property setter with valid types""" - entry = _EntryList() - entry.type = 'created' - assert 'created' in entry.type - - entry.type = 'argument' - assert 'argument' in entry.type - assert 'created' in entry.type # Should be additive - - def test_type_property_setter_invalid(self): - """Test type property setter with invalid type""" - entry = _EntryList() - entry.type = 'invalid' - assert entry.type == [] - - def test_type_property_setter_duplicate(self): - """Test type property setter doesn't add duplicates""" - entry = _EntryList() - entry.type = 'created' - entry.type = 'created' # Try to add again - assert entry.type.count('created') == 1 - - def test_remove_type(self): - """Test removing a type""" - entry = _EntryList() - entry.type = 'created' - entry.type = 'argument' - - entry.remove_type('created') - assert 'created' not in entry.type - assert 'argument' in entry.type - - def test_remove_type_not_present(self): - """Test removing a type that's not present""" - entry = _EntryList() - entry.type = 'created' - entry.remove_type('argument') # Not present - assert 'created' in entry.type - - def test_reset_type(self): - """Test resetting types""" - entry = _EntryList() - entry.type = 'created' - entry.type = 'argument' - - entry.reset_type('returned') - assert entry.type == ['returned'] - - def test_reset_type_default_none(self): - """Test resetting types with no default""" - entry = _EntryList() - entry.type = 'created' - - entry.reset_type() - assert entry.type == [] - - def test_boolean_properties(self): - """Test boolean property helpers""" - entry = _EntryList() - - # Test all false initially - assert not entry.is_argument - assert not entry.is_created - assert not entry.is_created_internal - assert not entry.is_returned - - # Test each property - entry.type = 'argument' - assert entry.is_argument - - entry.type = 'created' - assert entry.is_created - - entry.type = 'created_internal' - assert entry.is_created_internal - - entry.type = 'returned' - assert entry.is_returned - - -class TestMap: - @pytest.fixture - def clear(self): - global_object.map._clear() - - @pytest.fixture - def base_object(self): - return NewBase(display_name='test') - - @pytest.fixture - def parameter_object(self): - return Parameter(display_name='test2', value=2) - - def test_add_vertex(self, clear, base_object, parameter_object): - # When Then Expect - assert len(global_object.map._store) == 2 - assert len(global_object.map._Map__type_dict) == 2 - - def test_clear(self, clear, base_object): - # When - assert len(global_object.map._store) == 1 - assert len(global_object.map._Map__type_dict) == 1 - # Then - global_object.map._clear() - # Expect - assert len(global_object.map._store) == 0 - assert global_object.map._Map__type_dict == {} - - def test_weakref(self, clear): - # When - test_obj = NewBase(display_name='test') - assert len(global_object.map._store) == 1 - assert len(global_object.map._Map__type_dict) == 1 - # Then - del test_obj - gc.collect() - # Expect - assert len(global_object.map._store) == 0 - assert len(global_object.map._Map__type_dict) == 0 - - def test_vertices(self, clear, base_object, parameter_object): - # When Then Expect - assert global_object.map.vertices() == [ - base_object.unique_name, - parameter_object.unique_name, - ] - - def test_get_item_by_key(self, clear, base_object, parameter_object): - # When Then Expect - assert global_object.map.get_item_by_key(base_object.unique_name) == base_object - assert global_object.map.get_item_by_key(parameter_object.unique_name) == parameter_object - - @pytest.mark.parametrize( - 'cls, kwargs', [(NewBase, {'display_name': 'test'}), (Parameter, {'value': 2.0})] - ) - def test_identical_unique_names_exception(self, clear, cls, kwargs): - # When - test_obj = cls(unique_name='test', **kwargs) - # Then Expect - with pytest.raises(ValueError): - test_obj2 = cls(unique_name='test', **kwargs) - - def test_unique_name_change_still_in_map(self, clear, base_object, parameter_object): - # When - assert global_object.map.get_item_by_key('NewBase_0') == base_object - assert global_object.map.get_item_by_key('Parameter_0') == parameter_object - # Then - base_object.unique_name = 'test3' - parameter_object.unique_name = 'test4' - # Expect - assert global_object.map.get_item_by_key('NewBase_0') == base_object - assert global_object.map.get_item_by_key('Parameter_0') == parameter_object - assert global_object.map.get_item_by_key('test3') == base_object - assert global_object.map.get_item_by_key('test4') == parameter_object - - def test_add_vertex_duplicate_name_error(self, clear, base_object): - """Test that adding vertex with duplicate name raises error""" - # When/Then - with pytest.raises(ValueError, match='already exists'): - # Try to add another object with same unique_name - duplicate_obj = NewBase(display_name='duplicate', unique_name=base_object.unique_name) - - def test_add_vertex_with_object_type(self, clear): - """Test adding vertex with specific object type""" - # Given - obj = NewBase(display_name='test') - - # When - Object is automatically added during construction - # Then - assert global_object.map.is_known(obj) - assert 'created' in global_object.map.find_type(obj) - - def test_is_known_object(self, clear, base_object): - """Test is_known method""" - # When/Then - assert global_object.map.is_known(base_object) is True - - # Test with unknown object - unknown_obj = MagicMock() - unknown_obj.unique_name = 'unknown' - assert global_object.map.is_known(unknown_obj) is False - - def test_find_type_known_object(self, clear, base_object): - """Test find_type method""" - # When/Then - types = global_object.map.find_type(base_object) - assert isinstance(types, list) - assert 'created' in types - - def test_find_type_unknown_object(self, clear): - """Test find_type with unknown object""" - # Given - unknown_obj = MagicMock() - unknown_obj.unique_name = 'unknown' - - # When/Then - result = global_object.map.find_type(unknown_obj) - assert result is None - - def test_returned_objs_access_safe_under_modification(self, clear): - """Ensure accessing returned_objs doesn't raise when entries change size during iteration.""" - objs = [NewBase(display_name=f'race_{i}') for i in range(8)] - # Mark all as returned - for o in objs: - global_object.map.change_type(o, 'returned') - - # Repeatedly access returned_objs while deleting objects and forcing GC to - # try to trigger concurrent modification. This used to raise RuntimeError. - for _ in range(200): - _ = global_object.map.returned_objs # should not raise - if _ and objs: - # delete one object and collect to trigger finalizer/prune - del objs[0] - gc.collect() - # If we got here without exceptions, consider the access safe - assert True - - def test_reset_type(self, clear, base_object): - """Test resetting object type""" - # Given - original_types = global_object.map.find_type(base_object) - - # When - global_object.map.reset_type(base_object, 'argument') - - # Then - new_types = global_object.map.find_type(base_object) - assert new_types == ['argument'] - assert new_types != original_types - - def test_change_type(self, clear, base_object): - """Test changing object type""" - # Given - original_types = global_object.map.find_type(base_object) - original_count = len(original_types) - - # When - global_object.map.change_type(base_object, 'argument') - - # Then - new_types = global_object.map.find_type(base_object) - assert 'argument' in new_types - assert len(new_types) >= original_count # Should be at least the same or more - - def test_add_edge(self, clear, base_object, parameter_object): - """Test adding edges between objects""" - # When - global_object.map.add_edge(base_object, parameter_object) - - # Then - edges = global_object.map.get_edges(base_object) - assert parameter_object.unique_name in edges - - def test_add_edge_unknown_start_object(self, clear, parameter_object): - """Test adding edge with unknown start object""" - # Given - unknown_obj = MagicMock() - unknown_obj.unique_name = 'unknown' - - # When/Then - with pytest.raises(AttributeError, match='Start object not in map'): - global_object.map.add_edge(unknown_obj, parameter_object) - - def test_get_edges(self, clear, base_object, parameter_object): - """Test getting edges for an object""" - # Given - global_object.map.add_edge(base_object, parameter_object) - - # When - edges = global_object.map.get_edges(base_object) - - # Then - assert isinstance(edges, list) - assert parameter_object.unique_name in edges - - def test_get_edges_unknown_object(self, clear): - """Test getting edges for unknown object""" - # Given - unknown_obj = MagicMock() - unknown_obj.unique_name = 'unknown' - - # When/Then - with pytest.raises(AttributeError): - global_object.map.get_edges(unknown_obj) - - def test_prune_vertex_from_edge(self, clear, base_object, parameter_object): - """Test removing edge between objects""" - # Given - global_object.map.add_edge(base_object, parameter_object) - assert parameter_object.unique_name in global_object.map.get_edges(base_object) - - # When - global_object.map.prune_vertex_from_edge(base_object, parameter_object) - - # Then - edges = global_object.map.get_edges(base_object) - assert parameter_object.unique_name not in edges - - def test_prune_vertex_from_edge_none_child(self, clear, base_object): - """Test pruning edge with None child""" - # When/Then - Should not raise error - global_object.map.prune_vertex_from_edge(base_object, None) - - def test_prune_vertex(self, clear, base_object): - """Test pruning a vertex completely""" - # Given - unique_name = base_object.unique_name - assert global_object.map.is_known(base_object) - - # When - global_object.map.prune(unique_name) - - # Then - assert not global_object.map.is_known(base_object) - assert unique_name not in global_object.map.vertices() - - def test_edges_generation(self, clear, base_object, parameter_object): - """Test edge generation""" - # Given - global_object.map.add_edge(base_object, parameter_object) - - # When - edges = global_object.map.edges() - - # Then - assert isinstance(edges, list) - expected_edge = {base_object.unique_name, parameter_object.unique_name} - assert expected_edge in edges - - def test_type_filtering_properties(self, clear): - """Test type filtering properties""" - # Given - obj1 = NewBase(display_name='obj1') # 'created' type - obj2 = Parameter(display_name='obj2', value=1) # 'created' type - - global_object.map.change_type(obj1, 'argument') - global_object.map.change_type(obj2, 'returned') - - # When/Then - argument_objs = global_object.map.argument_objs - created_objs = global_object.map.created_objs - returned_objs = global_object.map.returned_objs - - assert obj1.unique_name in argument_objs - assert obj1.unique_name in created_objs # Should still be there - assert obj2.unique_name in created_objs - assert obj2.unique_name in returned_objs - - def test_find_path_simple(self, clear, base_object, parameter_object): - """Test finding path between objects""" - # Given - global_object.map.add_edge(base_object, parameter_object) - - # When - path = global_object.map.find_path(base_object.unique_name, parameter_object.unique_name) - - # Then - assert path == [base_object.unique_name, parameter_object.unique_name] - - def test_find_path_same_vertex(self, clear, base_object): - """Test finding path to same vertex""" - # When - path = global_object.map.find_path(base_object.unique_name, base_object.unique_name) - - # Then - assert path == [base_object.unique_name] - - def test_find_path_no_path(self, clear, base_object, parameter_object): - """Test finding path when no path exists""" - # When - No edge added - path = global_object.map.find_path(base_object.unique_name, parameter_object.unique_name) - - # Then - assert path == [] - - def test_find_all_paths(self, clear, base_object, parameter_object): - """Test finding all paths between objects""" - # Given - global_object.map.add_edge(base_object, parameter_object) - - # When - paths = global_object.map.find_all_paths( - base_object.unique_name, parameter_object.unique_name - ) - - # Then - assert len(paths) == 1 - assert paths[0] == [base_object.unique_name, parameter_object.unique_name] - - def test_reverse_route_with_start(self, clear, base_object, parameter_object): - """Test reverse route with specified start""" - # Given - global_object.map.add_edge(base_object, parameter_object) - - # When - route = global_object.map.reverse_route( - parameter_object.unique_name, base_object.unique_name - ) - - # Then - assert route == [parameter_object.unique_name, base_object.unique_name] - - def test_reverse_route_without_start(self, clear, base_object, parameter_object): - """Test reverse route without specified start""" - # Given - global_object.map.add_edge(base_object, parameter_object) - - # When - route = global_object.map.reverse_route(parameter_object.unique_name) - - # Then - assert len(route) >= 1 - assert route[0] == parameter_object.unique_name - - def test_is_connected_single_vertex(self, clear, base_object): - """Test connectivity with single vertex""" - # When/Then - assert global_object.map.is_connected() is True - - def test_is_connected_multiple_vertices(self, clear, base_object, parameter_object): - """Test connectivity with multiple connected vertices""" - # Given - global_object.map.add_edge(base_object, parameter_object) - - # When/Then - assert global_object.map.is_connected() is True - - def test_map_repr(self, clear, base_object, parameter_object): - """Test map string representation""" - # When - repr_str = str(global_object.map) - - # Then - assert 'Map object' in repr_str - assert '2' in repr_str # Should show vertex count - - def test_get_item_by_key_not_found(self, clear): - """Test getting item by non-existent key""" - # When/Then - with pytest.raises(ValueError, match='Item not in map'): - global_object.map.get_item_by_key('non_existent') - - def test_clear_with_finalizers(self, clear): - """Test clearing map properly calls finalizers""" - # Given - obj = NewBase(display_name='test') - original_count = len(global_object.map.vertices()) - - # When - global_object.map._clear() - - # Then - assert len(global_object.map.vertices()) == 0 - - def test_map_initialization(self): - """Test Map initialization""" - # When - test_map = Map() - - # Then - assert len(test_map.vertices()) == 0 - assert test_map.edges() == [] - - def test_vertices_retry_on_runtime_error(self, clear): - """Test that vertices() retries when RuntimeError occurs during iteration. - - This tests the thread-safety fix for WeakValueDictionary modification - during iteration (e.g., by garbage collection). - """ - # Given - test_map = Map() - - # Create a mock _store that raises RuntimeError on first iteration attempt - call_count = 0 - original_store = test_map._store - - class MockWeakValueDict: - def __init__(self): - self.data = {} - self.iteration_count = 0 - - def __iter__(self): - self.iteration_count += 1 - if self.iteration_count == 1: - # First iteration raises RuntimeError (simulates GC interference) - raise RuntimeError('dictionary changed size during iteration') - # Subsequent iterations succeed - return iter(self.data) - - def __len__(self): - return len(self.data) - - mock_store = MockWeakValueDict() - test_map._store = mock_store - - # When - vertices = test_map.vertices() - - # Then - assert vertices == [] - assert mock_store.iteration_count == 2 # Should have retried once - - def test_add_vertex_cleans_stale_type_dict_entry(self, clear): - """Test that add_vertex cleans up stale __type_dict entries. - - This can happen when a weak reference was collected but the finalizer - hasn't run yet, and a new object is created with the same unique_name. - """ - # Given - test_map = Map() - - # Manually add a stale entry to __type_dict (simulating GC collected but finalizer not run) - stale_name = 'StaleObject_0' - test_map._Map__type_dict[stale_name] = _EntryList() - - # Create a mock object with the same unique_name - mock_obj = MagicMock() - mock_obj.unique_name = stale_name - - # When - Adding the object should clean up the stale entry first - test_map.add_vertex(mock_obj, 'created') - - # Then - Object should be added successfully - assert stale_name in test_map._store - assert stale_name in test_map._Map__type_dict - assert test_map._Map__type_dict[stale_name].type == ['created'] - - def test_prune_key_not_in_store(self, clear): - """Test that prune handles case when key is not in _store. - - This defensive check prevents KeyError when the weak reference has - already been garbage collected but __type_dict entry remains. - """ - # Given - test_map = Map() - - # Manually add entry to __type_dict without corresponding _store entry - orphan_key = 'OrphanObject_0' - test_map._Map__type_dict[orphan_key] = _EntryList() - - # When - Pruning should not raise error - test_map.prune(orphan_key) - - # Then - Entry should be removed from __type_dict - assert orphan_key not in test_map._Map__type_dict - - def test_prune_key_in_both_dicts(self, clear, base_object): - """Test that prune removes key from both _store and __type_dict.""" - # Given - unique_name = base_object.unique_name - assert unique_name in global_object.map._store - assert unique_name in global_object.map._Map__type_dict - - # When - global_object.map.prune(unique_name) - - # Then - assert unique_name not in global_object.map._Map__type_dict - # Note: _store entry may or may not exist depending on weak ref state - - def test_prune_nonexistent_key(self, clear): - """Test that prune handles nonexistent key gracefully.""" - # When/Then - Should not raise error - global_object.map.prune('nonexistent_key') - - def test_reset_type_unknown_object(self, clear): - """Test reset_type with object not in map.""" - # Given - unknown_obj = MagicMock() - unknown_obj.unique_name = 'unknown' - - # When/Then - Should not raise error - global_object.map.reset_type(unknown_obj, 'argument') - - def test_change_type_unknown_object(self, clear): - """Test change_type with object not in map.""" - # Given - unknown_obj = MagicMock() - unknown_obj.unique_name = 'unknown' - - # When/Then - Should not raise error - global_object.map.change_type(unknown_obj, 'argument') - - def test_find_path_start_not_in_graph(self, clear): - """Test find_path when start vertex is not in graph.""" - # When - path = global_object.map.find_path('nonexistent', 'also_nonexistent') - - # Then - assert path == [] - - def test_find_all_paths_start_not_in_graph(self, clear): - """Test find_all_paths when start vertex is not in graph.""" - # When - paths = global_object.map.find_all_paths('nonexistent', 'also_nonexistent') - - # Then - assert paths == [] - - def test_find_isolated_vertices(self, clear, base_object, parameter_object): - """Test finding isolated vertices (vertices with no outgoing edges).""" - # Given - No edges added, both objects are isolated - - # When - isolated = global_object.map.find_isolated_vertices() - - # Then - assert base_object.unique_name in isolated - assert parameter_object.unique_name in isolated - - def test_find_isolated_vertices_with_edges(self, clear, base_object, parameter_object): - """Test finding isolated vertices when some have edges.""" - # Given - global_object.map.add_edge(base_object, parameter_object) - - # When - isolated = global_object.map.find_isolated_vertices() - - # Then - # base_object has an edge, so it's not isolated - assert base_object.unique_name not in isolated - # parameter_object has no outgoing edges, so it's isolated - assert parameter_object.unique_name in isolated - - def test_prune_vertex_from_edge_edge_not_exists(self, clear, base_object, parameter_object): - """Test pruning edge that doesn't exist.""" - # Given - No edge added between objects - - # When/Then - Should not raise error - global_object.map.prune_vertex_from_edge(base_object, parameter_object) - - def test_prune_vertex_from_edge_parent_not_in_map(self, clear, parameter_object): - """Test pruning edge when parent is not in map.""" - # Given - unknown_obj = MagicMock() - unknown_obj.unique_name = 'unknown' - - # When/Then - Should not raise error (vertex1 not in type_dict) - global_object.map.prune_vertex_from_edge(unknown_obj, parameter_object) - - def test_created_internal_property(self, clear): - """Test created_internal property.""" - # Given - obj = NewBase(display_name='internal_obj') - global_object.map.change_type(obj, 'created_internal') - - # When - internal_objs = global_object.map.created_internal - - # Then - assert obj.unique_name in internal_objs - - def test_clear_empties_both_dicts(self, clear, base_object, parameter_object): - """Test that _clear() properly empties both _store and __type_dict.""" - # Given - assert len(global_object.map._store) == 2 - assert len(global_object.map._Map__type_dict) == 2 - - # When - global_object.map._clear() - - # Then - assert len(global_object.map._store) == 0 - assert len(global_object.map._Map__type_dict) == 0 - - def test_entry_list_delitem(self): - """Test _EntryList __delitem__ method.""" - # Given - entry = _EntryList() - entry.append('item1') - entry.append('item2') - entry.append('item3') - - # When - del entry[1] - - # Then - assert len(entry) == 2 - assert 'item2' not in entry - assert 'item1' in entry - assert 'item3' in entry - - def test_entry_list_repr_with_finalizer(self): - """Test _EntryList repr when finalizer is set.""" - # Given - entry = _EntryList() - entry.type = 'created' - entry.finalizer = MagicMock() # Non-None finalizer - - # When - repr_str = str(entry) - - # Then - assert 'created' in repr_str - assert 'With a finalizer' in repr_str - - def test_entry_list_remove_type_unknown(self): - """Test removing a type that's not in known types.""" - # Given - entry = _EntryList() - entry.type = 'created' - - # When - Try to remove unknown type - entry.remove_type('unknown_type') - - # Then - Should not change anything - assert 'created' in entry.type diff --git a/tests/unit/global_object/test_object_registry.py b/tests/unit/global_object/test_object_registry.py new file mode 100644 index 00000000..8a1db286 --- /dev/null +++ b/tests/unit/global_object/test_object_registry.py @@ -0,0 +1,232 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import gc + +import pytest + +from easyscience import Parameter +from easyscience import global_object +from easyscience.base_classes import NewBase +from easyscience.global_object.object_registry import ObjectRegistry + + +class _Named: + """Minimal weak-referenceable object that can be registered in an ObjectRegistry.""" + + def __init__(self, unique_name): + self.unique_name = unique_name + + +class TestObjectRegistry: + @pytest.fixture + def clear(self): + global_object.registry.clear() + + @pytest.fixture + def base_object(self): + return NewBase(display_name='test') + + @pytest.fixture + def parameter_object(self): + return Parameter(display_name='test2', value=2) + + def test_add(self, clear, base_object, parameter_object): + # When Then Expect + assert global_object.registry.objects() == [base_object, parameter_object] + + def test_clear(self, clear, base_object): + # When + assert global_object.registry.names() == [base_object.unique_name] + # Then + global_object.registry.clear() + # Expect + assert global_object.registry.names() == [] + assert global_object.registry.objects() == [] + + def test_weakref(self, clear): + # When + test_obj = NewBase(display_name='test') + assert global_object.registry.names() == [test_obj.unique_name] + # Then + del test_obj + gc.collect() + # Expect + assert global_object.registry.names() == [] + + def test_names(self, clear, base_object, parameter_object): + # When Then Expect + assert global_object.registry.names() == [ + base_object.unique_name, + parameter_object.unique_name, + ] + + def test_getitem(self, clear, base_object, parameter_object): + # When Then Expect + assert global_object.registry[base_object.unique_name] == base_object + assert global_object.registry[parameter_object.unique_name] == parameter_object + + def test_getitem_not_found(self, clear): + """Test getting item by non-existent key""" + # When/Then + with pytest.raises(KeyError): + global_object.registry['non_existent'] + + def test_contains_len_iter(self, clear, base_object, parameter_object): + # When Then Expect + assert base_object.unique_name in global_object.registry + assert 'non_existent' not in global_object.registry + assert len(global_object.registry) == 2 + assert list(global_object.registry) == [ + base_object.unique_name, + parameter_object.unique_name, + ] + + def test_iter_survives_collection_mid_loop(self): + """Iteration uses a snapshot, so collecting an object mid-loop is safe.""" + # Given + test_registry = ObjectRegistry() + objs = [_Named('a'), _Named('b')] + for obj in objs: + test_registry.add(obj) + del obj + + # When + seen = [] + for name in test_registry: + seen.append(name) + objs.clear() + gc.collect() + + # Then + assert seen == ['a', 'b'] + assert len(test_registry) == 0 + + @pytest.mark.parametrize( + 'cls, kwargs', [(NewBase, {'display_name': 'test'}), (Parameter, {'value': 2.0})] + ) + def test_identical_unique_names_exception(self, clear, cls, kwargs): + # When + test_obj = cls(unique_name='test', **kwargs) + # Then Expect + with pytest.raises(ValueError): + test_obj2 = cls(unique_name='test', **kwargs) + + def test_add_duplicate_name_error(self, clear, base_object): + """Test that adding an object with a duplicate name raises error""" + # When/Then + with pytest.raises(ValueError, match='already exists'): + # Try to add another object with same unique_name + duplicate_obj = NewBase(display_name='duplicate', unique_name=base_object.unique_name) + + def test_unique_name_change_still_in_registry(self, clear, base_object, parameter_object): + # When + assert global_object.registry['NewBase_0'] == base_object + assert global_object.registry['Parameter_0'] == parameter_object + # Then + base_object.unique_name = 'test3' + parameter_object.unique_name = 'test4' + # Expect + assert global_object.registry['NewBase_0'] == base_object + assert global_object.registry['Parameter_0'] == parameter_object + assert global_object.registry['test3'] == base_object + assert global_object.registry['test4'] == parameter_object + + def test_discard(self, clear, base_object): + """Test discarding a name completely""" + # Given + unique_name = base_object.unique_name + assert unique_name in global_object.registry + + # When + global_object.registry.discard(unique_name) + + # Then + assert unique_name not in global_object.registry + assert base_object not in global_object.registry.objects() + + def test_discard_nonexistent_key(self, clear): + """Test that discard handles nonexistent key gracefully.""" + # When/Then - Should not raise error + global_object.registry.discard('nonexistent_key') + + def test_registry_repr(self, clear, base_object, parameter_object): + """Test registry string representation""" + # When + repr_str = str(global_object.registry) + + # Then + assert 'ObjectRegistry object' in repr_str + assert '2' in repr_str # Should show entry count + + def test_registry_initialization(self): + """Test ObjectRegistry initialization""" + # When + test_registry = ObjectRegistry() + + # Then + assert len(test_registry) == 0 + + def test_discard_then_reuse_name(self): + """Discarding a reused name removes the new object, even after the old one is collected.""" + # Given + test_registry = ObjectRegistry() + obj_a = _Named('n') + test_registry.add(obj_a) + test_registry.discard('n') + obj_b = _Named('n') + test_registry.add(obj_b) + + # When + del obj_a + gc.collect() + test_registry.discard('n') + + # Then + assert 'n' not in test_registry + assert obj_b not in test_registry.objects() + + def test_clear_then_reuse_name(self): + """Discarding a name reused after clearing removes the new object.""" + # Given + test_registry = ObjectRegistry() + obj_a = _Named('n') + test_registry.add(obj_a) + test_registry.clear() + obj_b = _Named('n') + test_registry.add(obj_b) + + # When + del obj_a + gc.collect() + test_registry.discard('n') + + # Then + assert 'n' not in test_registry + assert obj_b not in test_registry.objects() + + def test_objects_contains_live_objects_only(self): + """Objects that have been collected are not returned.""" + # Given + test_registry = ObjectRegistry() + kept = _Named('kept') + released = _Named('released') + test_registry.add(kept) + test_registry.add(released) + + # When + del released + gc.collect() + + # Then + assert test_registry.objects() == [kept] + + def test_objects_lists_renamed_object_once_per_name(self, clear, base_object): + """A renamed object keeps its old name, so it is returned once for each name.""" + # When + base_object.unique_name = 'renamed' + + # Then + objects = global_object.registry.objects() + assert len(objects) == 2 + assert all(obj is base_object for obj in objects) diff --git a/tests/unit/job/test_job.py b/tests/unit/job/test_job.py index 2e48dd21..a8bac322 100644 --- a/tests/unit/job/test_job.py +++ b/tests/unit/job/test_job.py @@ -14,9 +14,9 @@ @pytest.fixture(autouse=True) def clear(): - global_object.map._clear() + global_object.registry.clear() yield - global_object.map._clear() + global_object.registry.clear() class Analysis(AnalysisBase): diff --git a/tests/unit/variable/test_descriptor_array.py b/tests/unit/variable/test_descriptor_array.py index cafe0e66..fe063540 100644 --- a/tests/unit/variable/test_descriptor_array.py +++ b/tests/unit/variable/test_descriptor_array.py @@ -39,7 +39,7 @@ def descriptor_dimensionless(self): @pytest.fixture def clear(self): - global_object.map._clear() + global_object.registry.clear() def test_init(self, descriptor: DescriptorArray): # When Then Expect diff --git a/tests/unit/variable/test_descriptor_base.py b/tests/unit/variable/test_descriptor_base.py index d52db693..ba8a20d6 100644 --- a/tests/unit/variable/test_descriptor_base.py +++ b/tests/unit/variable/test_descriptor_base.py @@ -16,7 +16,7 @@ def descriptor(self): # This avoids the error: TypeError: Can't instantiate abstract class DescriptorBase with abstract methods __init__ DescriptorBase.__abstractmethods__ = set() DescriptorBase.__repr__ = lambda x: 'DescriptorBase' - self.objs_before_new_descriptor = len(global_object.map.created_objs) + self.objs_before_new_descriptor = len(global_object.registry) descriptor = DescriptorBase( description='description', url='url', @@ -26,7 +26,7 @@ def descriptor(self): @pytest.fixture def clear(self): - global_object.map._clear() + global_object.registry.clear() @pytest.mark.parametrize( 'unique_name', @@ -89,7 +89,7 @@ def test_init(self, descriptor: DescriptorBase): assert descriptor._description == 'description' assert descriptor._url == 'url' assert descriptor._display_name == 'display_name' - assert len(global_object.map.created_objs) - self.objs_before_new_descriptor == 1 + assert len(global_object.registry) - self.objs_before_new_descriptor == 1 def test_display_name(self, descriptor: DescriptorBase): # When Then Expect diff --git a/tests/unit/variable/test_descriptor_bool.py b/tests/unit/variable/test_descriptor_bool.py index 7269734a..8aa6befe 100644 --- a/tests/unit/variable/test_descriptor_bool.py +++ b/tests/unit/variable/test_descriptor_bool.py @@ -20,7 +20,7 @@ def descriptor(self): @pytest.fixture def clear(self): - global_object.map._clear() + global_object.registry.clear() def test_init(self, descriptor: DescriptorBool): # When Then Expect diff --git a/tests/unit/variable/test_descriptor_number.py b/tests/unit/variable/test_descriptor_number.py index 8a88dbc4..381c1db0 100644 --- a/tests/unit/variable/test_descriptor_number.py +++ b/tests/unit/variable/test_descriptor_number.py @@ -24,7 +24,7 @@ def descriptor(self): @pytest.fixture def clear(self): - global_object.map._clear() + global_object.registry.clear() def test_init(self, descriptor: DescriptorNumber): # When Then Expect diff --git a/tests/unit/variable/test_descriptor_str.py b/tests/unit/variable/test_descriptor_str.py index 91ce0bde..bc9533e7 100644 --- a/tests/unit/variable/test_descriptor_str.py +++ b/tests/unit/variable/test_descriptor_str.py @@ -20,7 +20,7 @@ def descriptor(self): @pytest.fixture def clear(self): - global_object.map._clear() + global_object.registry.clear() def test_init(self, descriptor: DescriptorStr): # When Then Expect diff --git a/tests/unit/variable/test_parameter.py b/tests/unit/variable/test_parameter.py index e937688e..dd76faf1 100644 --- a/tests/unit/variable/test_parameter.py +++ b/tests/unit/variable/test_parameter.py @@ -36,7 +36,7 @@ def normal_parameter(self) -> Parameter: @pytest.fixture def clear(self): - global_object.map._clear() + global_object.registry.clear() def compare_parameters(self, parameter1: Parameter, parameter2: Parameter): assert parameter1.value == parameter2.value diff --git a/tests/unit/variable/test_parameter_dependency_serialization.py b/tests/unit/variable/test_parameter_dependency_serialization.py index bf16241e..8e855d48 100644 --- a/tests/unit/variable/test_parameter_dependency_serialization.py +++ b/tests/unit/variable/test_parameter_dependency_serialization.py @@ -18,9 +18,9 @@ class TestParameterDependencySerialization: @pytest.fixture def clear_global_map(self): """This fixture pattern: - - Clears the map before each test (clean slate) + - Clears the registry before each test (clean slate) - Yields control to the test - - Clears the map after each test (cleanup) + - Clears the registry after each test (cleanup) Dependency serialization tests require more robust setup-yield-cleanup pattern because they involve complex @@ -28,12 +28,10 @@ def clear_global_map(self): and dependency resolution that are particularly sensitive to global state contamination. """ - # The global map uses weakref.WeakValueDictionary() for object storage, - # but also maintains strong references in __type_dict that need explicit cleanup. - global_object.map._clear() + global_object.registry.clear() yield # final cleanup after test - global_object.map._clear() + global_object.registry.clear() def test_independent_parameter_serialization(self, clear_global_map): """Test that independent parameters serialize normally without dependency info.""" @@ -48,7 +46,7 @@ def test_independent_parameter_serialization(self, clear_global_map): assert '_independent' not in serialized # Deserialize - global_object.map._clear() + global_object.registry.clear() new_param = Parameter.from_dict(serialized) # Should be identical @@ -78,7 +76,7 @@ def test_dependent_parameter_serialization(self, clear_global_map): assert serialized['_independent'] is False # Deserialize - global_object.map._clear() + global_object.registry.clear() new_b = Parameter.from_dict(serialized) # Should have pending dependency info @@ -109,7 +107,7 @@ def test_dependency_resolution_after_deserialization(self, clear_global_map): params_data = {'a': a.to_dict(), 'b': b.to_dict(), 'c': c.to_dict()} # Clear and deserialize (manual approach) - global_object.map._clear() + global_object.registry.clear() new_params = {} for name, data in params_data.items(): new_params[name] = Parameter.from_dict(data) @@ -122,7 +120,7 @@ def test_dependency_resolution_after_deserialization(self, clear_global_map): resolve_all_parameter_dependencies(new_params) # Alternative simplified approach using the helper function: - # global_object.map._clear() + # global_object.registry.clear() # new_params = deserialize_and_resolve_parameters(params_data) # After resolution, c should be dependent and functional @@ -154,7 +152,7 @@ def test_dependency_resolution_after_deserialization_desired_unit(self, clear_gl params_data = {'a': a.to_dict(), 'b': b.to_dict(), 'c': c.to_dict()} # Clear and deserialize (manual approach) - global_object.map._clear() + global_object.registry.clear() new_params = {} for name, data in params_data.items(): new_params[name] = Parameter.from_dict(data) @@ -167,7 +165,7 @@ def test_dependency_resolution_after_deserialization_desired_unit(self, clear_gl resolve_all_parameter_dependencies(new_params) # Alternative simplified approach using the helper function: - # global_object.map._clear() + # global_object.registry.clear() # new_params = deserialize_and_resolve_parameters(params_data) # After resolution, c should be dependent and functional @@ -202,7 +200,7 @@ def test_unique_name_dependency_serialization(self, clear_global_map): ) # Deserialize both and resolve - global_object.map._clear() + global_object.registry.clear() c = Parameter( display_name='c', value=0.0 ) # Dummy to occupy unique name, to force new unique_names @@ -242,7 +240,7 @@ def test_json_serialization_roundtrip(self, clear_global_map): json_str = json.dumps(params_data, default=str) # Deserialize from JSON - global_object.map._clear() + global_object.registry.clear() loaded_data = json.loads(json_str) new_params = {} for name, data in loaded_data.items(): @@ -282,7 +280,7 @@ def test_multiple_dependent_parameters(self, clear_global_map): params_data = {'x': x.to_dict(), 'y': y.to_dict(), 'z': z.to_dict()} # Deserialize and resolve - global_object.map._clear() + global_object.registry.clear() new_params = {} for name, data in params_data.items(): new_params[name] = Parameter.from_dict(data) @@ -317,7 +315,7 @@ def test_dependency_with_descriptor_number(self, clear_global_map): # Serialize all params_data = {'x': x.to_dict(), 'y': y.to_dict(), 'z': z.to_dict()} # Deserialize and resolve - global_object.map._clear() + global_object.registry.clear() new_params = {} for name, data in params_data.items(): if name == 'x': @@ -347,7 +345,7 @@ def test_get_parameters_with_pending_dependencies(self, clear_global_map): # Serialize and deserialize params_data = {'a': a.to_dict(), 'b': b.to_dict()} - global_object.map._clear() + global_object.registry.clear() new_params = {} for name, data in params_data.items(): new_params[name] = Parameter.from_dict(data) @@ -374,8 +372,8 @@ def test_error_handling_missing_dependency(self, clear_global_map): # Serialize b but not a b_data = b.to_dict() - # Deserialize without a in the global map - global_object.map._clear() + # Deserialize without a in the global registry + global_object.registry.clear() new_b = Parameter.from_dict(b_data) # Should raise error when trying to resolve @@ -423,7 +421,7 @@ def test_serializer_id_system_order_independence(self, clear_global_map, order): assert '_dependency_map_serializer_ids' in params_data['z'] # THEN - global_object.map._clear() + global_object.registry.clear() new_params = {} # Load in the specified order @@ -468,8 +466,8 @@ def test_deserialize_and_resolve_parameters_helper(self, clear_global_map): # Serialize all parameters params_data = {'a': a.to_dict(), 'b': b.to_dict(), 'c': c.to_dict()} - # Clear global map - global_object.map._clear() + # Clear global registry + global_object.registry.clear() # Use the helper function instead of manual deserialization + resolution new_params = deserialize_and_resolve_parameters(params_data) @@ -519,7 +517,7 @@ def test_nested_dependent_parameter_round_trip(self, clear_global_map): assert model_dict['b']['_dependency_string'] == '3 * a' assert '_dependency_map_serializer_ids' in model_dict['b'] - global_object.map._clear() + global_object.registry.clear() new_model = PairModel.from_dict(model_dict) new_model.b.resolve_pending_dependencies()