From 076b0bb78fa905bce892c9b5f8e906093e71a16d Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Sun, 27 Sep 2026 22:13:15 +0200 Subject: [PATCH 01/11] removed vestiges of ObjBase and such --- docs/docs/tutorials/fitting-bayesian.ipynb | 6 +- docs/docs/tutorials/fitting-qens.ipynb | 4 +- docs/docs/tutorials/fitting-sans.ipynb | 4 +- docs/docs/tutorials/progress-callback.ipynb | 40 +- .../docs/user-guide/controlling-log-output.md | 4 - pyproject.toml | 5 +- src/easyscience/__init__.py | 8 +- src/easyscience/base_classes/__init__.py | 5 +- src/easyscience/base_classes/based_base.py | 269 --------- .../base_classes/collection_base.py | 18 - src/easyscience/base_classes/easy_list.py | 27 +- src/easyscience/base_classes/obj_base.py | 18 - .../fitting/minimizers/minimizer_base.py | 6 +- .../fitting/minimizers/minimizer_bumps.py | 4 +- .../fitting/minimizers/minimizer_dfo.py | 6 +- .../fitting/minimizers/minimizer_lmfit.py | 8 +- src/easyscience/fitting/multi_fitter.py | 3 +- .../fitting/samplers/sampler_bumps.py | 4 +- .../global_object/hugger/hugger.py | 164 ------ .../global_object/hugger/property.py | 281 --------- src/easyscience/io/serializer_base.py | 29 - src/easyscience/job/analysis.py | 19 +- src/easyscience/job/experiment.py | 13 +- src/easyscience/job/job.py | 10 +- src/easyscience/job/theoreticalmodel.py | 15 +- src/easyscience/legacy/__init__.py | 7 - src/easyscience/legacy/collection_base.py | 338 ----------- src/easyscience/legacy/dict.py | 179 ------ src/easyscience/legacy/json.py | 139 ----- src/easyscience/legacy/legacy_core.py | 220 ------- src/easyscience/legacy/obj_base.py | 171 ------ src/easyscience/legacy/xml.py | 197 ------- src/easyscience/models/polynomial.py | 58 +- src/easyscience/utils/classTools.py | 66 --- tests/functional/test_smoke.py | 2 +- tests/integration/fitting/test_fitter.py | 46 +- .../unit/base_classes/test_collection_base.py | 550 ------------------ .../base_classes/test_deprecated_wrappers.py | 87 --- tests/unit/base_classes/test_obj_base.py | 505 ---------------- tests/unit/fitting/test_multi_fitter.py | 28 +- .../test_integration_comprehensive.py | 51 +- tests/unit/global_object/test_map.py | 26 +- tests/unit/global_object/test_undo_redo.py | 139 ++--- tests/unit/io/test_serializer_base.py | 27 +- tests/unit/io/test_serializer_dict.py | 17 +- tests/unit/legacy/__init__.py | 3 - tests/unit/legacy/test_collection_base.py | 166 ------ tests/unit/legacy/test_dict.py | 3 - tests/unit/legacy/test_json.py | 3 - tests/unit/legacy/test_obj_base.py | 119 ---- tests/unit/legacy/test_xml.py | 3 - tests/unit/models/test_polynomial.py | 34 +- tests/unit/variable/test_parameter.py | 6 +- 53 files changed, 285 insertions(+), 3875 deletions(-) delete mode 100644 src/easyscience/base_classes/based_base.py delete mode 100644 src/easyscience/base_classes/collection_base.py delete mode 100644 src/easyscience/base_classes/obj_base.py delete mode 100644 src/easyscience/global_object/hugger/property.py delete mode 100644 src/easyscience/legacy/__init__.py delete mode 100644 src/easyscience/legacy/collection_base.py delete mode 100644 src/easyscience/legacy/dict.py delete mode 100644 src/easyscience/legacy/json.py delete mode 100644 src/easyscience/legacy/legacy_core.py delete mode 100644 src/easyscience/legacy/obj_base.py delete mode 100644 src/easyscience/legacy/xml.py delete mode 100644 src/easyscience/utils/classTools.py delete mode 100644 tests/unit/base_classes/test_collection_base.py delete mode 100644 tests/unit/base_classes/test_deprecated_wrappers.py delete mode 100644 tests/unit/base_classes/test_obj_base.py delete mode 100644 tests/unit/legacy/__init__.py delete mode 100644 tests/unit/legacy/test_collection_base.py delete mode 100644 tests/unit/legacy/test_dict.py delete mode 100644 tests/unit/legacy/test_json.py delete mode 100644 tests/unit/legacy/test_obj_base.py delete mode 100644 tests/unit/legacy/test_xml.py diff --git a/docs/docs/tutorials/fitting-bayesian.ipynb b/docs/docs/tutorials/fitting-bayesian.ipynb index 4ddb4239..a2931634 100644 --- a/docs/docs/tutorials/fitting-bayesian.ipynb +++ b/docs/docs/tutorials/fitting-bayesian.ipynb @@ -143,7 +143,7 @@ "source": [ "## Defining parameters with priors\n", "\n", - "Create four `Parameter` objects, for the area $A$, $\\gamma$, $\\omega_0$ and $\\sigma$. The `min` and `max` arguments define a **uniform prior** on each parameter — the sampler will only consider values inside this range and will treat every value inside the range as equally plausible *a priori*. The parameters are then collected in an `ObjBase` container: this is the model object that both `Fitter` and `Sampler` take, so it is defined here, before the optional fit.\n", + "Create four `Parameter` objects, for the area $A$, $\\gamma$, $\\omega_0$ and $\\sigma$. The `min` and `max` arguments define a **uniform prior** on each parameter — the sampler will only consider values inside this range and will treat every value inside the range as equally plausible *a priori*. The parameters are then collected in an `EasyList` container: this is the model object that both `Fitter` and `Sampler` take, so it is defined here, before the optional fit.\n", "\n", "| Parameter | Initial Value | Min | Max |\n", "| --- | --- | --- | --- |\n", @@ -160,7 +160,7 @@ "metadata": {}, "outputs": [], "source": [ - "from easyscience import ObjBase\n", + "from easyscience import EasyList\n", "from easyscience import Parameter\n", "\n", "area = Parameter(display_name='area', value=10, fixed=False, min=1, max=100)\n", @@ -168,7 +168,7 @@ "omega_0 = Parameter(display_name='omega_0', value=1e-3, fixed=False, min=0, max=2e-3)\n", "sigma = Parameter(display_name='sigma', value=1e-3, fixed=False, min=1e-5, max=1e-1)\n", "\n", - "parameter_container = ObjBase(name='params', A=area, gamma=gamma, omega_0=omega_0, sigma=sigma)" + "parameter_container = EasyList(area, gamma, omega_0, sigma)" ] }, { diff --git a/docs/docs/tutorials/fitting-qens.ipynb b/docs/docs/tutorials/fitting-qens.ipynb index 0b820cab..3809461e 100644 --- a/docs/docs/tutorials/fitting-qens.ipynb +++ b/docs/docs/tutorials/fitting-qens.ipynb @@ -293,10 +293,10 @@ "metadata": {}, "outputs": [], "source": [ + "from easyscience import EasyList\n", "from easyscience import Fitter\n", - "from easyscience import ObjBase\n", "\n", - "params = ObjBase(name='params', A=A, gamma=gamma, omega_0=omega_0, sigma=sigma)\n", + "params = EasyList(A, gamma, omega_0, sigma)\n", "f = Fitter(params, intensity)\n", "\n", "res = f.fit(x=q, y=i, weights=1 / di)" diff --git a/docs/docs/tutorials/fitting-sans.ipynb b/docs/docs/tutorials/fitting-sans.ipynb index 02fdb97f..84c3a1cd 100644 --- a/docs/docs/tutorials/fitting-sans.ipynb +++ b/docs/docs/tutorials/fitting-sans.ipynb @@ -283,10 +283,10 @@ "metadata": {}, "outputs": [], "source": [ + "from easyscience import EasyList\n", "from easyscience import Fitter\n", - "from easyscience import ObjBase\n", "\n", - "params = ObjBase(name='params', delta_rho=delta_rho, r=r, bkg=bkg)\n", + "params = EasyList(delta_rho, r, bkg)\n", "f = Fitter(params, sphere)\n", "\n", "res = f.fit(x=q, y=i, weights=1 / di)" diff --git a/docs/docs/tutorials/progress-callback.ipynb b/docs/docs/tutorials/progress-callback.ipynb index d6c6d616..43a75680 100644 --- a/docs/docs/tutorials/progress-callback.ipynb +++ b/docs/docs/tutorials/progress-callback.ipynb @@ -48,8 +48,9 @@ "import numpy as np\n", "\n", "from easyscience import AvailableMinimizers\n", + "from easyscience import EasyList\n", "from easyscience import Fitter\n", - "from easyscience import ObjBase\n", + "from easyscience import ModelBase\n", "from easyscience import Parameter" ] }, @@ -82,32 +83,23 @@ }, "outputs": [], "source": [ - "class MultiPeak(ObjBase):\n", - " a0: Parameter\n", - " a1: Parameter\n", - " a2: Parameter\n", - " A1: Parameter\n", - " mu1: Parameter\n", - " sigma1: Parameter\n", - " A2: Parameter\n", - " mu2: Parameter\n", - " sigma2: Parameter\n", - " A3: Parameter\n", - " mu3: Parameter\n", - " sigma3: Parameter\n", - "\n", + "class MultiPeak(ModelBase):\n", " def __init__(self, **values):\n", - " params = {name: Parameter(value, display_name=name) for name, value in values.items()}\n", - " super().__init__('multipeak', **params)\n", + " super().__init__(display_name='multipeak')\n", + " self._parameters = EasyList(\n", + " *(Parameter(value, display_name=name) for name, value in values.items())\n", + " )\n", + "\n", + " @property\n", + " def parameters(self) -> EasyList:\n", + " return self._parameters\n", "\n", " def __call__(self, x):\n", - " bg = self.a0.value + self.a1.value * x + self.a2.value * x**2\n", + " p = {par.display_name: par.value for par in self.parameters}\n", + " bg = p['a0'] + p['a1'] * x + p['a2'] * x**2\n", " peaks = 0.0\n", - " for A, mu, sigma in (\n", - " (self.A1.value, self.mu1.value, self.sigma1.value),\n", - " (self.A2.value, self.mu2.value, self.sigma2.value),\n", - " (self.A3.value, self.mu3.value, self.sigma3.value),\n", - " ):\n", + " for i in (1, 2, 3):\n", + " A, mu, sigma = p[f'A{i}'], p[f'mu{i}'], p[f'sigma{i}']\n", " peaks = peaks + A * np.exp(-0.5 * ((x - mu) / sigma) ** 2)\n", " return bg + peaks" ] @@ -205,7 +197,7 @@ " mu3=3.0,\n", " sigma3=1.2,\n", " )\n", - " for p in model.get_parameters():\n", + " for p in model.get_all_parameters():\n", " p.fixed = False\n", " return model" ] diff --git a/docs/docs/user-guide/controlling-log-output.md b/docs/docs/user-guide/controlling-log-output.md index 0403590a..cf0b389f 100644 --- a/docs/docs/user-guide/controlling-log-output.md +++ b/docs/docs/user-guide/controlling-log-output.md @@ -35,7 +35,6 @@ individual subsystems: ``` easyscience # root — controls everything ├── easyscience.base_classes # EasyList runtime warnings -├── easyscience.legacy # ObjBase / CollectionBase deprecations ├── easyscience.deprecated # @deprecated decorator messages ├── easyscience.fitting # import-availability warnings │ ├── easyscience.fitting.bumps # Bumps fitting runtime messages @@ -56,9 +55,6 @@ logging.getLogger('easyscience.fitting').setLevel(logging.ERROR) # Suppress only Bumps runtime messages logging.getLogger('easyscience.fitting.bumps').setLevel(logging.ERROR) - -# Suppress only legacy deprecation notices -logging.getLogger('easyscience.legacy').setLevel(logging.ERROR) ``` ## Environment Variable diff --git a/pyproject.toml b/pyproject.toml index aa57296e..d20a9ac3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,7 +140,6 @@ default-tag = 'v999.0.0' [tool.interrogate] fail-under = 45 # Minimum docstring coverage percentage to pass verbose = 1 -exclude = ['src/easyscience/legacy'] ####################################### # Configuration for coverage/pytest-cov @@ -178,7 +177,7 @@ testpaths = ['tests'] # https://docs.astral.sh/ruff/rules/ [tool.ruff] -exclude = ['tests', 'legacy', 'src/easyscience/legacy'] +exclude = ['tests', 'legacy'] indent-width = 4 line-length = 99 # See also `max-line-length` in [tool.ruff.lint.pycodestyle] preview = true # Enable new rules that are not yet stable, like DOC @@ -285,7 +284,6 @@ max-positional-args = 6 # the parameter declarations in the code (in function's signature). [tool.pydoclint] -exclude = 'src/easyscience/legacy' style = 'numpy' check-style-mismatch = true check-arg-defaults = true @@ -299,7 +297,6 @@ allow-init-docstring = true # https://github.com/jsh9/format-docstring [tool.format_docstring] -exclude = 'src/easyscience/legacy' docstring_style = 'numpy' line_length = 72 fix_rst_backticks = true diff --git a/src/easyscience/__init__.py b/src/easyscience/__init__.py index ae709682..5d49be07 100644 --- a/src/easyscience/__init__.py +++ b/src/easyscience/__init__.py @@ -11,10 +11,10 @@ global_object.stack.enabled = False -from .base_classes import ObjBase # noqa: E402 +from .base_classes import EasyList # noqa: E402 +from .base_classes import ModelBase # noqa: E402 from .fitting import AvailableMinimizers # noqa: E402 from .fitting import Fitter # noqa: E402 -from .legacy import CollectionBase # noqa: E402 from .variable import DescriptorNumber # noqa: E402 from .variable import Parameter # noqa: E402 @@ -23,8 +23,8 @@ __all__ = [ __version__, global_object, - ObjBase, - CollectionBase, + ModelBase, + EasyList, AvailableMinimizers, Fitter, DescriptorNumber, diff --git a/src/easyscience/base_classes/__init__.py b/src/easyscience/base_classes/__init__.py index 6c465346..a9388faf 100644 --- a/src/easyscience/base_classes/__init__.py +++ b/src/easyscience/base_classes/__init__.py @@ -1,11 +1,8 @@ # SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -from ..legacy.collection_base import CollectionBase -from ..legacy.obj_base import ObjBase -from .based_base import BasedBase from .easy_list import EasyList from .model_base import ModelBase from .new_base import NewBase -__all__ = [BasedBase, CollectionBase, ObjBase, ModelBase, NewBase, EasyList] +__all__ = [ModelBase, NewBase, EasyList] diff --git a/src/easyscience/base_classes/based_base.py b/src/easyscience/base_classes/based_base.py deleted file mode 100644 index 7070f93a..00000000 --- a/src/easyscience/base_classes/based_base.py +++ /dev/null @@ -1,269 +0,0 @@ -# SPDX-FileCopyrightText: 2025 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -from inspect import signature -from typing import TYPE_CHECKING -from typing import Any -from typing import Dict -from typing import Iterable -from typing import List -from typing import Optional -from typing import Set - -from easyscience import global_object - -from ..io import SerializerComponent -from ..variable import Parameter -from ..variable.descriptor_base import DescriptorBase - -if TYPE_CHECKING: - from ..fitting.calculators import InterfaceFactoryTemplate - - -class BasedBase(SerializerComponent): - __slots__ = ['_name', '_global_object', 'user_data', '_kwargs'] - - _REDIRECT = {} - - def __init__( - self, - name: str, - interface: Optional[InterfaceFactoryTemplate] = None, - unique_name: Optional[str] = None, - ): - self._global_object = global_object - if unique_name is None: - unique_name = self._global_object.generate_unique_name(self.__class__.__name__) - self._unique_name = unique_name - self._name = name - self._global_object.map.add_vertex(self, obj_type='created') - self.interface = interface - self.user_data: dict = {} - - @property - def _arg_spec(self) -> Set[str]: - base_cls = getattr(self, '__old_class__', self.__class__) - sign = signature(base_cls.__init__) - names = [ - param.name - for param in sign.parameters.values() - if param.kind == param.POSITIONAL_OR_KEYWORD - ] - return set(names[1:]) - - def __reduce__(self) -> tuple: - """ - Make the class picklable. - - Due to the nature of the dynamic class definitions special - measures need to be taken. - - Returns - ------- - tuple - Tuple consisting of how to make the object. - """ - state = self.encode() - cls = getattr(self, '__old_class__', self.__class__) - return cls.from_dict, (state,) - - @property - def unique_name(self) -> str: - """Get the unique name of the object.""" - return self._unique_name - - @unique_name.setter - 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. - - Parameters - ---------- - new_unique_name : str - New unique name for the object. - - Raises - ------ - TypeError - If ``new_unique_name`` is not a string. - """ - 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) - - @property - def name(self) -> str: - """ - Get the common name of the object. - - Returns - ------- - str - Common name of the object. - """ - return self._name - - @name.setter - def name(self, new_name: str): - """ - Set a new common name for the object. - - Parameters - ---------- - new_name : str - New name for the object. - """ - self._name = new_name - - @property - def interface(self) -> InterfaceFactoryTemplate: - """Get the current interface of the object.""" - return self._interface - - @interface.setter - def interface(self, new_interface: InterfaceFactoryTemplate): - """ - Set the current interface to the object and generate bindings if - possible. - - iF.e. ``` def __init__(self, bar, interface=None, **kwargs): - super().__init__(self, **kwargs) self.foo = bar - self.interface = interface # As final step after initialization - to set correct bindings. ``` - """ - self._interface = new_interface - if new_interface is not None: - self.generate_bindings() - - def generate_bindings(self): - """ - Generate or re-generate bindings to an interface (if exists). - - Raises - ------ - AttributeError - If ``interface`` has not been set. - """ - if self.interface is None: - raise AttributeError( - 'Interface error for generating bindings. `interface` has to be set.' - ) - interfaceable_children = [ - key - for key in self._global_object.map.get_edges(self) - if issubclass(type(self._global_object.map.get_item_by_key(key)), BasedBase) - ] - for child_key in interfaceable_children: - child = self._global_object.map.get_item_by_key(child_key) - child.interface = self.interface - self.interface.generate_bindings(self) - - def switch_interface(self, new_interface_name: str): - """Switch or create a new interface.""" - if self.interface is None: - raise AttributeError( - 'Interface error for generating bindings. `interface` has to be set.' - ) - self.interface.switch(new_interface_name) - self.generate_bindings() - - def get_parameters(self) -> List[Parameter]: - """ - Get all parameter objects as a list. - - Returns - ------- - List[Parameter] - List of ``Parameter`` objects. - """ - par_list = [] - for key, item in self._kwargs.items(): - if hasattr(item, 'get_parameters'): - par_list = [*par_list, *item.get_parameters()] - elif isinstance(item, Parameter): - par_list.append(item) - return par_list - - def _get_linkable_attributes(self) -> List[DescriptorBase]: - """ - Get all objects which can be linked against as a list. - - Returns - ------- - List[DescriptorBase] - List of ``Descriptor``/`Parameter` objects. - """ - item_list = [] - for key, item in self._kwargs.items(): - if hasattr(item, '_get_linkable_attributes'): - item_list = [*item_list, *item._get_linkable_attributes()] - elif issubclass(type(item), (DescriptorBase)): - item_list.append(item) - return item_list - - def get_fit_parameters(self) -> List[Parameter]: - """ - Get all objects which can be fitted (and are not fixed) as a - list. - - Returns - ------- - List[Parameter] - List of ``Parameter`` objects which can be used in fitting. - """ - fit_list = [] - for key, item in self._kwargs.items(): - if hasattr(item, 'get_fit_parameters'): - fit_list = [*fit_list, *item.get_fit_parameters()] - elif isinstance(item, Parameter): - if item.independent and not item.fixed: - fit_list.append(item) - return fit_list - - def __dir__(self) -> Iterable[str]: - """ - This creates auto-completion and helps out in iPython notebooks. - - Returns - ------- - Iterable[str] - List of function and parameter names for auto- completion. - """ - new_class_objs = list(k for k in dir(self.__class__) if not k.startswith('_')) - return sorted(new_class_objs) - - def __copy__(self) -> BasedBase: - """Return a copy of the object.""" - temp = self.to_dict(skip=['unique_name']) - new_obj = self.__class__.from_dict(temp) - return new_obj - - def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: - """ - Convert an object into a full dictionary using - ``SerializerDict``. This is a shortcut for - ```obj.encode(encoder=SerializerDict)``` - - Parameters - ---------- - skip : Optional[List[str]], default=None - List of field names as strings to skip when forming - the dictionary. By default, None. - - Returns - ------- - Dict[str, Any] - Encoded object containing all information to reform an - EasyScience object. - """ - # extend skip to include unique_name by default - if skip is None: - skip = [] - if 'unique_name' not in skip: - skip.append('unique_name') - return super().to_dict(skip=skip) diff --git a/src/easyscience/base_classes/collection_base.py b/src/easyscience/base_classes/collection_base.py deleted file mode 100644 index c62e9382..00000000 --- a/src/easyscience/base_classes/collection_base.py +++ /dev/null @@ -1,18 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -""" -.. deprecated:: - This module has been moved to ``easyscience.legacy.collection_base``. - Please update your imports. -""" - -from easyscience import global_object - -from ..legacy.collection_base import CollectionBase # noqa: F401 - -global_object.log.warning( - 'easyscience.base_classes.collection_base is deprecated. ' - 'Please import from easyscience.legacy.collection_base instead.', - stacklevel=2, -) diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py index 624983a0..b81b69fd 100644 --- a/src/easyscience/base_classes/easy_list.py +++ b/src/easyscience/base_classes/easy_list.py @@ -364,23 +364,36 @@ def pop(self, index: int | str = -1) -> ProtectedType_: # Serialization support - def to_dict(self) -> dict: + def _convert_to_dict(self, d: dict, encoder: Any, skip: Any = None, **kwargs: Any) -> dict: """ - Convert the EasyList to a dictionary for serialization. + Add the list items and protected types to a serialized dict. + + The serializer calls this hook, so the contents are written both + by ``to_dict`` and when the list is nested in another object. + + Parameters + ---------- + d : dict + Dictionary produced so far by the serializer. + encoder : Any + The serializer performing the conversion. + skip : Any, default=None + Field names to skip. Unused. + **kwargs : Any + Additional keyword arguments from the serializer. Unused. Returns ------- dict - Dictionary representation of the EasyList. + The dictionary with ``protected_types`` and ``data`` added. """ - dict_repr = super().to_dict() if self._protected_types != [NewBase]: - dict_repr['protected_types'] = [ + d['protected_types'] = [ {'@module': cls_.__module__, '@class': cls_.__name__} for cls_ in self._protected_types ] # noqa: E501 - dict_repr['data'] = [item.to_dict() for item in self._data] - return dict_repr + d['data'] = [item.to_dict() for item in self._data] + return d @classmethod def from_dict(cls, obj_dict: Dict[str, Any]) -> NewBase: diff --git a/src/easyscience/base_classes/obj_base.py b/src/easyscience/base_classes/obj_base.py deleted file mode 100644 index 8d44b193..00000000 --- a/src/easyscience/base_classes/obj_base.py +++ /dev/null @@ -1,18 +0,0 @@ -# SPDX-FileCopyrightText: 2025 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -""" -.. deprecated:: - This module has been moved to ``easyscience.legacy.obj_base``. - Please update your imports. -""" - -from easyscience import global_object - -from ..legacy.obj_base import ObjBase # noqa: F401 - -global_object.log.warning( - 'easyscience.base_classes.obj_base is deprecated. ' - 'Please import from easyscience.legacy.obj_base instead.', - stacklevel=2, -) diff --git a/src/easyscience/fitting/minimizers/minimizer_base.py b/src/easyscience/fitting/minimizers/minimizer_base.py index e598a678..70447ab1 100644 --- a/src/easyscience/fitting/minimizers/minimizer_base.py +++ b/src/easyscience/fitting/minimizers/minimizer_base.py @@ -9,7 +9,7 @@ import numpy as np # causes circular import when Parameter is imported -# from easyscience.base_classes import ObjBase +# from easyscience.base_classes import ModelBase from easyscience.variable import Parameter from ..available_minimizers import AvailableMinimizers @@ -26,10 +26,10 @@ class MinimizerBase(EngineBase): def __init__( self, - obj, #: ObjBase, + obj, #: ModelBase, fit_function: Callable, minimizer_enum: AvailableMinimizers, - ): # todo after constraint changes, add type hint: obj: ObjBase # noqa: E501 + ): # todo after constraint changes, add type hint: obj: ModelBase # noqa: E501 if minimizer_enum.method not in self.supported_methods(): raise FitError(f'Method {minimizer_enum.method} not available in {self.__class__}') super().__init__(obj=obj, fit_function=fit_function) diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index 34e4587b..3b1c13b2 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -16,7 +16,7 @@ from scipy.optimize import OptimizeResult # causes circular import when Parameter is imported -# from easyscience.base_classes import ObjBase +# from easyscience.base_classes import ModelBase from easyscience.variable import Parameter from ..available_minimizers import AvailableMinimizers @@ -42,7 +42,7 @@ class Bumps(MinimizerBase): """ This is a wrapper to Bumps: https://bumps.readthedocs.io/ It allows for the Bumps fitting engine to use parameters declared in an - ``EasyScience.base_classes.ObjBase``. + ``easyscience.base_classes.ModelBase``. """ package = 'bumps' diff --git a/src/easyscience/fitting/minimizers/minimizer_dfo.py b/src/easyscience/fitting/minimizers/minimizer_dfo.py index 21cc2a1b..130ed6fc 100644 --- a/src/easyscience/fitting/minimizers/minimizer_dfo.py +++ b/src/easyscience/fitting/minimizers/minimizer_dfo.py @@ -11,7 +11,7 @@ import numpy as np # causes circular import when Parameter is imported -# from easyscience.base_classes import ObjBase +# from easyscience.base_classes import ModelBase from easyscience.variable import Parameter from ..available_minimizers import AvailableMinimizers @@ -46,10 +46,10 @@ class DFO(MinimizerBase): def __init__( self, - obj: object, #: ObjBase, + obj: object, #: ModelBase, fit_function: Callable, minimizer_enum: AvailableMinimizers | None = None, - ): # todo after constraint changes, add type hint: obj: ObjBase # noqa: E501 + ): # todo after constraint changes, add type hint: obj: ModelBase # noqa: E501 """ Initialize the fitting engine. diff --git a/src/easyscience/fitting/minimizers/minimizer_lmfit.py b/src/easyscience/fitting/minimizers/minimizer_lmfit.py index ea226b5e..f0c26eb6 100644 --- a/src/easyscience/fitting/minimizers/minimizer_lmfit.py +++ b/src/easyscience/fitting/minimizers/minimizer_lmfit.py @@ -12,7 +12,7 @@ from lmfit.model import ModelResult # causes circular import when Parameter is imported -# from easyscience.base_classes import ObjBase +# from easyscience.base_classes import ModelBase from easyscience.variable import Parameter from ..available_minimizers import AvailableMinimizers @@ -27,17 +27,17 @@ class LMFit(MinimizerBase): # noqa: S101 This is a wrapper to the extended Levenberg-Marquardt Fit: https://lmfit.github.io/lmfit-py/ It allows for the lmfit fitting engine to use parameters declared in an - ``EasyScience.base_classes.ObjBase``. + ``easyscience.base_classes.ModelBase``. """ package = 'lmfit' def __init__( self, - obj: object, #: ObjBase, + obj: object, #: ModelBase, fit_function: Callable, minimizer_enum: AvailableMinimizers | None = None, - ): # todo after constraint changes, add type hint: obj: ObjBase # noqa: E501 + ): # todo after constraint changes, add type hint: obj: ModelBase # noqa: E501 """ Initialize the minimizer. diff --git a/src/easyscience/fitting/multi_fitter.py b/src/easyscience/fitting/multi_fitter.py index 1e9ccbfd..b90bd607 100644 --- a/src/easyscience/fitting/multi_fitter.py +++ b/src/easyscience/fitting/multi_fitter.py @@ -40,8 +40,7 @@ def __init__( if fit_functions is None: fit_functions = [] # Aggregate the fit objects so a single object can be sent to Fitter. - # *-unpacking keeps any sequence (list, tuple, etc) working, as the - # old CollectionBase container did. + # *-unpacking keeps any sequence (list, tuple, etc) working. self._fit_objects = EasyList(*fit_objects, protected_types=ModelBase) self._fit_functions = list(fit_functions) # Initialize with the first of the fit_functions, without this it is diff --git a/src/easyscience/fitting/samplers/sampler_bumps.py b/src/easyscience/fitting/samplers/sampler_bumps.py index b9722066..f7033e48 100644 --- a/src/easyscience/fitting/samplers/sampler_bumps.py +++ b/src/easyscience/fitting/samplers/sampler_bumps.py @@ -45,9 +45,9 @@ class DreamSampler(EngineBase): def __init__( self, - obj: object, #: ObjBase, + obj: object, #: ModelBase, fit_function: Callable, - ): # todo after constraint changes, add type hint: obj: ObjBase # noqa: E501 + ): # todo after constraint changes, add type hint: obj: ModelBase # noqa: E501 """ Initialize the sampling engine. diff --git a/src/easyscience/global_object/hugger/hugger.py b/src/easyscience/global_object/hugger/hugger.py index f4f405a0..a151d394 100644 --- a/src/easyscience/global_object/hugger/hugger.py +++ b/src/easyscience/global_object/hugger/hugger.py @@ -1,12 +1,7 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -import inspect -import sys -from abc import ABCMeta -from abc import abstractmethod from typing import List -from typing import Tuple from ...utils.classUtils import singleton @@ -60,162 +55,3 @@ def reset_history(self): def append_log(self, log_entry: str): self._store.log.append(log_entry) - - -class Hugger(metaclass=ABCMeta): - def __init__(self): - self._store = Store() - - @property - def log(self) -> list: - return self._store.log - - @abstractmethod - def patch(self): - pass - - @abstractmethod - def restore(self): - pass - - -class PatcherFactory(Hugger, metaclass=ABCMeta): - def __init__(self): - super().__init__() - - @staticmethod - def is_mutable(arg) -> bool: - ret = True - if isinstance(arg, (int, float, complex, str, tuple, frozenset, bytes, property)): - ret = False - return ret - - @staticmethod - def _caller_name(skip: int = 2): - """ - Get a name of a caller in the format module.class.method - ``skip`` specifies how many levels of stack to skip while - getting caller name. - - skip=1 means "who calls me", skip=2 "who calls my caller" etc. - An empty string is returned if skipped levels exceed stack - height - https://gist.github.com/techtonik/2151727#gistcomment-2333747 - """ - - def stack_(frame): - framelist = [] - while frame: - framelist.append(frame) - frame = frame.f_back - return framelist - - stack = stack_(sys._getframe(1)) - start = 0 + skip - if len(stack) < start + 1: - return '' - parentframe = stack[start] - - name = [] - module = inspect.getmodule(parentframe) - # `modname` can be None when frame is executed directly in console - # TODO(techtonik): consider using __main__ - if module: - name.append(module.__name__) - # detect classname - if 'self' in parentframe.f_locals: - # I don't know any way to detect call from the object method - # XXX: there seems to be no way to detect static method call - it will - # be just a function call - name.append(parentframe.f_locals['self'].__class__.__name__) - codename = parentframe.f_code.co_name - if codename != '': # top level usually - name.append(codename) # function or a method - del parentframe - return '.'.join(name) - - def _append_args(self, *args, **kwargs): - - def check(res): - return ( - id(res) not in self._store.unique_rets - and id(res) not in self._store.create_list - and id(res) not in self._store.unique_args - ) - - for arg in args: - if self.is_mutable(arg) and check(arg): - self._store.unique_args.append(id(arg)) - for item in kwargs.values(): - if self.is_mutable(item) and check(item): - self._store.unique_args.append(id(item)) - - def _append_create(self, obj): - this_id = id(obj) - if this_id not in self._store.create_list: - self._store.create_list.append(this_id) - - def _append_result(self, result) -> int: - ret = 0 - - def check(res): - return ( - id(res) not in self._store.unique_rets - and id(res) not in self._store.create_list - and id(res) not in self._store.unique_args - ) - - if isinstance(result, type(None)): - return ret - elif isinstance(result, tuple): - for res in result: - # if self.is_mutable(res) and check(res): - if check(res): - self._store.unique_rets.append(id(res)) - ret = len(result) - else: - # if self.is_mutable(result) and check(result): - if check(result): - self._store.unique_rets.append(id(result)) - ret = 1 - return ret - - def _append_log(self, log_entry: str): - self._store.log.append(log_entry) - - def __options(self, item) -> Tuple[int, dict]: - this_id = id(item) - option = { - 'create_list': self._store.create_list, - 'return_list': self._store.unique_rets, - 'input_list': self._store.unique_args, - } - return this_id, option - - def _get_position(self, query: str, item) -> int: - this_id, option = self.__options(item) - in_list = self._in_list(query, item) - index = None - if in_list: - index = option.get(query).index(this_id) - return index - - def _in_list(self, query: str, item) -> bool: - this_id, option = self.__options(item) - return this_id in option.get(query, []) - - @staticmethod - def _get_class_that_defined_method(method_in) -> classmethod: - if inspect.ismethod(method_in): - for cls in inspect.getmro(method_in.__self__.__class__): - if cls.__dict__.get(method_in.__name__) is method_in: - return cls - method_in = method_in.__func__ # fallback to __qualname__ parsing - if inspect.isfunction(method_in): - class_name = method_in.__qualname__.split('.', 1)[0].rsplit('.', 1)[0] - try: - cls = getattr(inspect.getmodule(method_in), class_name) - except AttributeError: - cls = method_in.__globals__.get(class_name) - if isinstance(cls, type): - return cls diff --git a/src/easyscience/global_object/hugger/property.py b/src/easyscience/global_object/hugger/property.py deleted file mode 100644 index e36c52e5..00000000 --- a/src/easyscience/global_object/hugger/property.py +++ /dev/null @@ -1,281 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -import logging -import sys -from functools import wraps -from typing import Any -from typing import Callable -from typing import List - -from easyscience import global_object - -from .hugger import PatcherFactory -from .hugger import Store - - -class LoggedProperty(property): - """ - Pump up python properties. - - In this case we can see who has called this property and then do - something if a criteria is met. In this case if the caller is not a - member of the ``ObjBase`` class. Note that all high level - ``EasyScience`` objects should be built from ``ObjBase``. - """ - - _global_object = global_object - - def __init__(self, *args, get_id=None, my_self=None, test_class=None, **kwargs): - super(LoggedProperty, self).__init__(*args, **kwargs) - self._get_id = get_id - self._my_self = my_self - self.test_class = test_class - - @staticmethod - def _caller_class(test_class, skip: int = 1): - - def stack_(frame): - frame_list = [] - while frame: - frame_list.append(frame) - frame = frame.f_back - return frame_list - - stack: List[Any] = stack_(sys._getframe(1)) - start = 0 + skip - if len(stack) < start + 1: - return '' - parent_frame = stack[start] - test = False - if 'self' in parent_frame.f_locals: - test = issubclass(parent_frame.f_locals['self'].__class__, test_class) - return test - - def __get__(self, instance, owner=None): - if not global_object.script.enabled: - return super(LoggedProperty, self).__get__(instance, owner) - test = self._caller_class(self.test_class) - res = super(LoggedProperty, self).__get__(instance, owner) - - def result_item(item_to_be_resulted): - if item_to_be_resulted is None: - return None - if global_object.map.is_known(item_to_be_resulted): - global_object.map.change_type(item_to_be_resulted, 'returned') - else: - global_object.map.add_vertex(item_to_be_resulted, obj_type='returned') - - if not test and self._get_id is not None and self._my_self is not None: - if not isinstance(res, list): - result_item(res) - else: - for item in res: - result_item(item) - Store().append_log(self.makeEntry('get', res)) - if global_object.debug: # noqa: S1006 - logging.getLogger('easyscience.global_object.hugger').debug( - "I'm %s and %s has been called from the outside!", self._my_self, self._get_id - ) - return res - - def __set__(self, instance, value): - if not global_object.script.enabled: - return super().__set__(instance, value) - test = self._caller_class(self.test_class) - if not test and self._get_id is not None and self._my_self is not None: - Store().append_log(self.makeEntry('set', value)) - if global_object.debug: # noqa: S1006 - logging.getLogger('easyscience.global_object.hugger').debug( - "I'm %s and %s has been set to %s from the outside!", - self._my_self, - self._get_id, - value, - ) - return super().__set__(instance, value) - - def makeEntry(self, log_type, returns, *args, **kwargs) -> str: - temp = '' - if returns is None: - returns = [] - if not isinstance(returns, list): - returns = [returns] - if log_type == 'get': - for var in returns: - if var.unique_name in global_object.map.returned_objs: - index = global_object.map.returned_objs.index(var.unique_name) - temp += f'{Store().var_ident}{index}, ' - if len(returns) > 0: - temp = temp[:-2] - temp += ' = ' - if self._my_self.unique_name in global_object.map.created_objs: - # for edge in route[::-1]: - index = global_object.map.created_objs.index(self._my_self.unique_name) - temp += f'{self._my_self.__class__.__name__.lower()}_{index}.{self._get_id}' - if self._my_self.unique_name in global_object.map.created_internal: - # We now have to trace.... - route = global_object.map.reverse_route(self._my_self) # noqa: F841 - index = global_object.map.created_internal.index(self._my_self.unique_name) - temp += f'{self._my_self.__class__.__name__.lower()}_{index}.{self._get_id}' - elif log_type == 'set': - if self._my_self.unique_name in global_object.map.created_objs: - index = global_object.map.created_objs.index(self._my_self.unique_name) - temp += f'{self._my_self.__class__.__name__.lower()}_{index}.{self._get_id} = ' - args = args[1:] - for var in args: - if var.unique_name in global_object.map.argument_objs: - index = global_object.map.argument_objs.index(var.unique_name) - temp += f'{Store().var_ident}{index}' - elif var.unique_name in global_object.map.returned_objs: - index = global_object.map.returned_objs.index(var.unique_name) - temp += f'{Store().var_ident}{index}' - elif var.unique_name in global_object.map.created_objs: - index = global_object.map.created_objs.index(var.unique_name) - temp += f'{self._my_self.__class__.__name__.lower()}_{index}' - else: - if isinstance(var, str): - var = '"' + var + '"' - temp += f'{var}' - else: - logging.getLogger('easyscience.global_object.hugger').debug( - '%s is not implemented yet. Sorry', log_type - ) - temp += '\n' - return temp - - -class PropertyHugger(PatcherFactory): - # Properties are immutable, so need to be set at the parent level. However unlike `FunctionHugger` we can't traverse - # the stack to get the parent. So, it and it's name has to be set at initialization. Boo! - - _global_object = global_object - - def __init__(self, klass, prop_name): - super().__init__() - self.klass = klass - if isinstance(prop_name, tuple): - self.prop_name = prop_name[0] - self.property = prop_name[1] - else: - self.prop_name = prop_name - self.property = klass.__dict__.get(prop_name) - self.__patch_ref = { - 'fget': {'old': self.property.fget, 'patcher': self.patch_get}, - 'fset': {'old': self.property.fset, 'patcher': self.patch_set}, - 'fdel': {'old': self.property.fdel, 'patcher': self.patch_del}, - } - - def patch(self): - option = {} - for key, item in self.__patch_ref.items(): - func = getattr(self.property, key) - if func is not None: - if global_object.debug: - logging.getLogger('easyscience.global_object.hugger').debug( - 'Patching property %s.%s', self.klass.__name__, self.prop_name - ) - patch_function: Callable = item.get('patcher') - new_func = patch_function(func) - option[key] = new_func - setattr(self.klass, self.prop_name, property(**option)) - - def restore(self): - if global_object.debug: - logging.getLogger('easyscience.global_object.hugger').debug( - 'Restoring property %s.%s', self.klass.__name__, self.prop_name - ) - setattr(self.klass, self.prop_name, self.property) - - def patch_get(self, func: Callable) -> Callable: - - @wraps(func) - def inner(*args, **kwargs): - if global_object.debug: - logging.getLogger('easyscience.global_object.hugger').debug( - '%s.%s has been called with %s, %s', - self.klass.__name__, - self.prop_name, - args[1:], - kwargs, - ) - res = func(*args, **kwargs) - self._append_args(*args, **kwargs) - self._append_result(res) - self._append_log(self.makeEntry('get', res, *args, **kwargs)) - return res - - return inner - - def patch_set(self, func: Callable) -> Callable: - - @wraps(func) - def inner(*args, **kwargs): - if global_object.debug: - logging.getLogger('easyscience.global_object.hugger').debug( - '%s.%s has been set with %s, %s', - self.klass.__name__, - self.prop_name, - args[1:], - kwargs, - ) - self._append_args(*args, **kwargs) - self._append_log(self.makeEntry('set', None, *args, **kwargs)) - return func(*args, **kwargs) - - return inner - - def patch_del(self, func: Callable) -> Callable: - - @wraps(func) - def inner(*args, **kwargs): - if global_object.debug: - logging.getLogger('easyscience.global_object.hugger').debug( - '%s.%s has been deleted.', self.klass.__name__, self.prop_name - ) - self._append_log(self.makeEntry('del', None, *args, **kwargs)) - return func(*args, **kwargs) - - return inner - - def makeEntry(self, log_type, returns, *args, **kwargs) -> str: - temp = '' - if returns is None: - returns = [] - if not isinstance(returns, list): - returns = [returns] - if log_type == 'get': - for var in returns: - if self._in_list('return_list', var): - index = self._get_position('return_list', var) - temp += f'{self._store.var_ident}{index}, ' - if len(returns) > 0: - temp = temp[:-2] - temp += ' = ' - if self._in_list('create_list', args[0]): - index = self._get_position('create_list', args[0]) - temp += f'{self.klass.__name__.lower()}_{index}.{self.prop_name}' - elif log_type == 'set': - if self._in_list('create_list', args[0]): - index = self._get_position('create_list', args[0]) - temp += f'{self.klass.__name__.lower()}_{index}.{self.prop_name} = ' - args = args[1:] - for var in args: - if self._in_list('input_list', var): - index = self._get_position('input_list', var) - temp += f'{self._store.var_ident}{index}' - elif self._in_list('return_list', var): - index = self._get_position('return_list', var) - temp += f'{self._store.var_ident}{index}' - elif self._in_list('create_list', var): - index = self._get_position('create_list', var) - temp += f'{self.klass.__name__.lower()}_{index}' - else: - if isinstance(var, str): - var = '"' + var + '"' - temp += f'{var}' - else: - logging.getLogger('easyscience.global_object.hugger').debug( - '%s is not implemented yet. Sorry', log_type - ) - temp += '\n' - return temp diff --git a/src/easyscience/io/serializer_base.py b/src/easyscience/io/serializer_base.py index 26b1ad72..f0b66612 100644 --- a/src/easyscience/io/serializer_base.py +++ b/src/easyscience/io/serializer_base.py @@ -205,14 +205,6 @@ def runner(o): delattr(option, c) d.update(runner(v)) # pylint: disable=E1101 err = False - if hasattr(obj, '_kwargs'): - # type: ignore - option = getattr(obj, '_kwargs') - if hasattr(option, c): - v = getattr(option, c) - delattr(option, c) - d.update(runner(v)) # pylint: disable=E1101 - err = False if err: raise NotImplementedError( 'Unable to automatically determine to_dict ' @@ -228,27 +220,6 @@ def runner(o): ) if spec.varargs is not None and getattr(obj, spec.varargs, None) is not None: d.update({spec.varargs: getattr(obj, spec.varargs)}) - if hasattr(obj, '_kwargs'): - if not issubclass(type(obj), MutableSequence): - d_k = list(d.keys()) - for k, v in getattr(obj, '_kwargs').items(): - # We should have already obtained `key` and `_key` - if k not in skip and k not in d_k: - if k[0] == '_' and k[1:] in d_k: - continue - vv = v - if k in redirect.keys(): - if redirect[k] is None: - continue - vv = redirect[k](obj) - v_ = runner(vv) - d[k] = self._recursive_encoder( - v_, - skip=skip, - encoder=self, - full_encode=full_encode, - **kwargs, - ) if isinstance(obj, Enum): d.update({'value': runner(obj.value)}) # pylint: disable=E1101 if hasattr(obj, '_convert_to_dict'): diff --git a/src/easyscience/job/analysis.py b/src/easyscience/job/analysis.py index 03686714..c53be0a8 100644 --- a/src/easyscience/job/analysis.py +++ b/src/easyscience/job/analysis.py @@ -3,22 +3,27 @@ from abc import ABCMeta from abc import abstractmethod +from typing import Optional import numpy as np +from ..base_classes.model_base import ModelBase from ..fitting.minimizers import MinimizerBase -from ..legacy.obj_base import ObjBase -class AnalysisBase(ObjBase, metaclass=ABCMeta): +class AnalysisBase(ModelBase, metaclass=ABCMeta): """ This virtual class allows for the creation of technique-specific Analysis objects. """ - def __init__(self, name: str, interface=None, *args, **kwargs): - super(AnalysisBase, self).__init__(name, *args, **kwargs) - self.name = name + def __init__( + self, + display_name: Optional[str] = None, + interface=None, + unique_name: Optional[str] = None, + ): + super(AnalysisBase, self).__init__(unique_name=unique_name, display_name=display_name) self._calculator = None self._minimizer = None self._fitter = None @@ -34,7 +39,7 @@ def fit(self, x: np.ndarray, y: np.ndarray, e: np.ndarray, **kwargs) -> None: @property def calculator(self) -> str: - if self._calculator is None: + if self._calculator is None and self.interface is not None: self._calculator = self.interface.current_interface_name return self._calculator @@ -53,4 +58,4 @@ def minimizer(self, minimizer: MinimizerBase) -> None: # required dunder methods def __str__(self): - return f'Analysis: {self.name}' + return f'Analysis: {self.display_name}' diff --git a/src/easyscience/job/experiment.py b/src/easyscience/job/experiment.py index b3e4daa9..7fc85dd6 100644 --- a/src/easyscience/job/experiment.py +++ b/src/easyscience/job/experiment.py @@ -1,19 +1,20 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -from ..legacy.obj_base import ObjBase +from typing import Optional +from ..base_classes.model_base import ModelBase -class ExperimentBase(ObjBase): + +class ExperimentBase(ModelBase): """ This virtual class allows for the creation of technique-specific Experiment objects. """ - def __init__(self, name: str, *args, **kwargs): - super(ExperimentBase, self).__init__(name, *args, **kwargs) - self._name = name + def __init__(self, display_name: Optional[str] = None, unique_name: Optional[str] = None): + super(ExperimentBase, self).__init__(unique_name=unique_name, display_name=display_name) # required dunder methods def __str__(self): - return f'Experiment: {self._name}' + return f'Experiment: {self.display_name}' diff --git a/src/easyscience/job/job.py b/src/easyscience/job/job.py index bfed8bec..dee825fd 100644 --- a/src/easyscience/job/job.py +++ b/src/easyscience/job/job.py @@ -3,22 +3,22 @@ from abc import ABCMeta from abc import abstractmethod +from typing import Optional -from ..legacy.obj_base import ObjBase +from ..base_classes.model_base import ModelBase from .analysis import AnalysisBase from .experiment import ExperimentBase from .theoreticalmodel import TheoreticalModelBase -class JobBase(ObjBase, metaclass=ABCMeta): +class JobBase(ModelBase, metaclass=ABCMeta): """ This virtual class allows for the creation of technique-specific Job objects. """ - def __init__(self, name: str, *args, **kwargs): - super(JobBase, self).__init__(name, *args, **kwargs) - self.name = name + def __init__(self, display_name: Optional[str] = None, unique_name: Optional[str] = None): + super(JobBase, self).__init__(unique_name=unique_name, display_name=display_name) self._theory = None self._experiment = None self._analysis = None diff --git a/src/easyscience/job/theoreticalmodel.py b/src/easyscience/job/theoreticalmodel.py index 3cddec7e..c255e5cd 100644 --- a/src/easyscience/job/theoreticalmodel.py +++ b/src/easyscience/job/theoreticalmodel.py @@ -1,23 +1,20 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -from ..legacy.obj_base import ObjBase +from typing import Optional +from ..base_classes.model_base import ModelBase -class TheoreticalModelBase(ObjBase): + +class TheoreticalModelBase(ModelBase): """ This virtual class allows for the creation of technique-specific Theory objects. """ - def __init__(self, name: str, *args, **kwargs): - self._name = name - super().__init__(name, *args, **kwargs) + def __init__(self, display_name: Optional[str] = None, unique_name: Optional[str] = None): + super().__init__(unique_name=unique_name, display_name=display_name) # required dunder methods def __str__(self): raise NotImplementedError('Copy not implemented') - - def to_dict(self, skip: list = []) -> dict: - this_dict = super().to_dict(skip=skip) - return this_dict diff --git a/src/easyscience/legacy/__init__.py b/src/easyscience/legacy/__init__.py deleted file mode 100644 index 482abc26..00000000 --- a/src/easyscience/legacy/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from .collection_base import CollectionBase # noqa: F401 -from .obj_base import ObjBase # noqa: F401 - -__all__ = ['CollectionBase', 'ObjBase'] diff --git a/src/easyscience/legacy/collection_base.py b/src/easyscience/legacy/collection_base.py deleted file mode 100644 index 5be3386c..00000000 --- a/src/easyscience/legacy/collection_base.py +++ /dev/null @@ -1,338 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -from collections.abc import MutableSequence -from numbers import Number -from typing import TYPE_CHECKING -from typing import Any -from typing import Callable -from typing import List -from typing import Optional -from typing import Tuple -from typing import Union - -from easyscience import global_object -from easyscience.base_classes.new_base import NewBase -from easyscience.global_object.undo_redo import NotarizedDict - -from ..base_classes.based_base import BasedBase -from ..variable.descriptor_base import DescriptorBase - -if TYPE_CHECKING: - from ..fitting.calculators import InterfaceFactoryTemplate - - -class CollectionBase(BasedBase, MutableSequence): - """ - This is the base class for which all higher level classes are built - off of. - - .. deprecated:: - ``CollectionBase`` is deprecated and will be removed in a future version. - Please migrate to ``ModelBase`` or ``EasyList`` instead. - - NOTE: This object is serializable only if parameters are supplied - as: ``ObjBase(a=value, b=value)``. For ``Parameter`` or - ``Descriptor`` objects we can cheat with - ``ObjBase(*[Descriptor(...), Parameter(...), ...])``. - """ - - def __init__( - self, - name: str, - *args: Union[BasedBase, DescriptorBase, NewBase], - interface: Optional[InterfaceFactoryTemplate] = None, - unique_name: Optional[str] = None, - **kwargs: Any, - ): - """ - Set up the base collection class. - - Parameters - ---------- - name : str - Name of this object. - *args : Union[BasedBase, DescriptorBase, NewBase] - Initial EasyScience objects to include in the collection. - interface : Optional[InterfaceFactoryTemplate], default=None - Interface attached to contained objects. By default, None. - unique_name : Optional[str], default=None - Unique identifier for this collection. By default, None. - **kwargs : Any - Fields which this class should contain. - - Raises - ------ - AttributeError - If a provided item is not an EasyScience object or if a - keyword collides with an internal attribute. - """ - global_object.log.getLogger('legacy').warning( - 'CollectionBase is deprecated and will be removed in a future version. ' - 'Please migrate to ModelBase or EasyList.', - stacklevel=2, - ) - BasedBase.__init__(self, name, unique_name=unique_name) - kwargs = {key: kwargs[key] for key in kwargs.keys() if kwargs[key] is not None} - _args = [] - for item in args: - if not isinstance(item, list): - _args.append(item) - else: - _args += item - _kwargs = {} - for key, item in kwargs.items(): - if isinstance(item, list) and len(item) > 0: - _args += item - else: - _kwargs[key] = item - kwargs = _kwargs - for item in list(kwargs.values()) + _args: - if not issubclass(type(item), (DescriptorBase, BasedBase, NewBase)): - raise AttributeError('A collection can only be formed from easyscience objects.') - args = _args - _kwargs = {} - for key, item in kwargs.items(): - _kwargs[key] = item - for arg in args: - kwargs[arg.unique_name] = arg - _kwargs[arg.unique_name] = arg - - # Set kwargs, also useful for serialization - self._kwargs = NotarizedDict(**_kwargs) - - for key in kwargs.keys(): - if key in self.__dict__.keys() or key in self.__slots__: - raise AttributeError( - f'Given kwarg: `{key}`, is an internal attribute. Please rename.' - ) - if kwargs[key]: # Might be None (empty tuple or list) - self._global_object.map.add_edge(self, kwargs[key]) - self._global_object.map.reset_type(kwargs[key], 'created_internal') - if interface is not None: - kwargs[key].interface = interface - # TODO wrap getter and setter in Logger - if interface is not None: - self.interface = interface - self._kwargs._stack_enabled = True - - def insert(self, index: int, value: Union[DescriptorBase, BasedBase, NewBase]) -> None: - """ - Insert an object into the collection at an index. - - Parameters - ---------- - index : int - Index for EasyScience object to be inserted. - value : Union[DescriptorBase, BasedBase, NewBase] - Object to be inserted. - - Returns - ------- - None - None. - - Raises - ------ - AttributeError - If ``value`` is not an EasyScience object. - """ - t_ = type(value) - if issubclass(t_, (BasedBase, DescriptorBase, NewBase)): - update_key = list(self._kwargs.keys()) - values = list(self._kwargs.values()) - # Update the internal dict - new_key = value.unique_name - update_key.insert(index, new_key) - values.insert(index, value) - self._kwargs.reorder(**{k: v for k, v in zip(update_key, values)}) - # ADD EDGE - self._global_object.map.add_edge(self, value) - self._global_object.map.reset_type(value, 'created_internal') - value.interface = self.interface - else: - raise AttributeError('Only EasyScience objects can be put into an EasyScience group') - - def __getitem__(self, idx: Union[int, slice]) -> Union[DescriptorBase, BasedBase, NewBase]: - """ - Get an item in the collection based on its index. - - Parameters - ---------- - idx : Union[int, slice] - Index or slice of the collection. - - Returns - ------- - Union[DescriptorBase, BasedBase, NewBase] - Object at index ``idx``. - - Raises - ------ - IndexError - If ``idx`` does not resolve to an element. - TypeError - If boolean indexing is attempted. - """ - if isinstance(idx, slice): - start, stop, step = idx.indices(len(self)) - return self.__class__( - getattr(self, 'name'), *[self[i] for i in range(start, stop, step)] - ) - if str(idx) in self._kwargs.keys(): - return self._kwargs[str(idx)] - if isinstance(idx, str): - idx = [index for index, item in enumerate(self) if item.name == idx] - noi = len(idx) - if noi == 0: - raise IndexError('Given index does not exist') - elif noi == 1: - idx = idx[0] - else: - return self.__class__(getattr(self, 'name'), *[self[i] for i in idx]) - elif not isinstance(idx, int) or isinstance(idx, bool): - if isinstance(idx, bool): - raise TypeError('Boolean indexing is not supported at the moment') - try: - if idx > len(self): - raise IndexError(f'Given index {idx} is out of bounds') - except TypeError: - raise IndexError('Index must be of type `int`/`slice` or an item name (`str`)') - keys = list(self._kwargs.keys()) - return self._kwargs[keys[idx]] - - def __setitem__(self, key: int, value: Union[BasedBase, DescriptorBase, NewBase]) -> None: - """ - Set an item via it's index. - - Parameters - ---------- - key : int - Index in self. - value : Union[BasedBase, DescriptorBase, NewBase] - Value which index key should be set to. - - Raises - ------ - NotImplementedError - If ``value`` is neither numeric nor an EasyScience object. - """ - if isinstance(value, Number): # noqa: S3827 - item = self.__getitem__(key) - item.value = value - elif issubclass(type(value), (BasedBase, DescriptorBase, NewBase)): - update_key = list(self._kwargs.keys()) - values = list(self._kwargs.values()) - old_item = values[key] - # Update the internal dict - update_dict = {update_key[key]: value} - self._kwargs.update(update_dict) - # ADD EDGE - self._global_object.map.add_edge(self, value) - self._global_object.map.reset_type(value, 'created_internal') - value.interface = self.interface - # REMOVE EDGE - self._global_object.map.prune_vertex_from_edge(self, old_item) - else: - raise NotImplementedError( - 'At the moment only numerical values or EasyScience objects can be set.' - ) - - def __delitem__(self, key: int) -> None: - """ - Try to delete an idem by key. - - Parameters - ---------- - key : int - - Returns - ------- - None - """ - keys = list(self._kwargs.keys()) - item = self._kwargs[keys[key]] - self._global_object.map.prune_vertex_from_edge(self, item) - del self._kwargs[keys[key]] - - def __len__(self) -> int: - """ - Get the number of items in this collection. - - Returns - ------- - int - Number of items in this collection. - """ - return len(self._kwargs.keys()) - - def _convert_to_dict( - self, in_dict: dict, encoder: Any, skip: List[str] = [], **kwargs: Any - ) -> dict: - """ - Convert ones self into a serialized form. - - Parameters - ---------- - in_dict : dict - Dictionary being populated during serialization. - encoder : Any - Serializer used to encode child objects. - skip : List[str], default=[] - Field names to skip during serialization. - **kwargs : Any - Additional keyword arguments passed to the serializer. - - Returns - ------- - dict - Dictionary of ones self. - """ - d = {} - if hasattr(self, '_modify_dict'): - # any extra keys defined on the inheriting class - d = self._modify_dict(skip=skip, **kwargs) - in_dict['data'] = [encoder._convert_to_dict(item, skip=skip, **kwargs) for item in self] - out_dict = {**in_dict, **d} - return out_dict - - @property - def data(self) -> Tuple: - """ - The data function returns a tuple of the keyword arguments - passed to the constructor. This is useful for when you need to - pass in a dictionary of data to other functions, such as with - matplotlib's plot function. - - Returns - ------- - Tuple - The values of the attributes in a tuple :doc-author: - Trelent. - """ - return tuple(self._kwargs.values()) - - def __repr__(self) -> str: - return f'{self.__class__.__name__} `{getattr(self, "name")}` of length {len(self)}' - - def sort( - self, - mapping: Callable[[Union[BasedBase, DescriptorBase, NewBase]], Any], - reverse: bool = False, - ) -> None: - """ - Sort the collection according to the given mapping. - - Parameters - ---------- - mapping : Callable[[Union[BasedBase, DescriptorBase, NewBase]], Any] - Mapping function to sort the collection. i.e. lambda - parameter: parameter.value. - reverse : bool, default=False - Reverse the sorting. By default, False. - """ - i = list(self._kwargs.items()) - i.sort(key=lambda x: mapping(x[1]), reverse=reverse) - self._kwargs.reorder(**{k[0]: k[1] for k in i}) diff --git a/src/easyscience/legacy/dict.py b/src/easyscience/legacy/dict.py deleted file mode 100644 index 9b36c9f8..00000000 --- a/src/easyscience/legacy/dict.py +++ /dev/null @@ -1,179 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -__author__ = 'https://github.com/materialsvirtuallab/monty/blob/master/monty/json.py' -__version__ = '3.0.0' - - -from typing import TYPE_CHECKING -from typing import Any -from typing import Dict -from typing import List -from typing import Optional - -from .template import BaseEncoderDecoder - -if TYPE_CHECKING: - from .component_serializer import ComponentSerializer - -_KNOWN_CORE_TYPES = ('Descriptor', 'Parameter') - - -class DictSerializer(BaseEncoderDecoder): - """ - This is a serializer that can encode and decode EasyScience objects - to a JSON encoded dictionary. - """ - - def encode( - self, - obj: ComponentSerializer, - skip: Optional[List[str]] = None, - full_encode: bool = False, - **kwargs: Any, - ) -> Dict[str, Any]: - """ - Convert an EasyScience object to a JSON encoded dictionary. - - Parameters - ---------- - obj : ComponentSerializer - Object to be encoded. - skip : Optional[List[str]], default=None - List of field names as strings to skip when forming the - encoded object. By default, None. - full_encode : bool, default=False - Should the data also be JSON encoded (default False). By - default, False. - **kwargs : Any - Any additional key word arguments to be passed to the - encoder. - - Returns - ------- - Dict[str, Any] - Object encoded to dictionary containing all information to - reform an EasyScience object. - """ - - return self._convert_to_dict(obj, skip=skip, full_encode=full_encode, **kwargs) - - @classmethod - def decode(cls, d: Dict[str, Any]) -> ComponentSerializer: - """ - Decode function. - - Parameters - ---------- - d : Dict[str, Any] - Dict representation. - - Returns - ------- - ComponentSerializer - ComponentSerializer class. - """ - - return BaseEncoderDecoder._convert_from_dict(d) - - @classmethod - def from_dict(cls, d: Dict[str, Any]) -> ComponentSerializer: - """ - From dict. - - Parameters - ---------- - d : Dict[str, Any] - Dict representation. - - Returns - ------- - ComponentSerializer - ComponentSerializer class. - """ - return BaseEncoderDecoder._convert_from_dict(d) - - -class DataDictSerializer(DictSerializer): - """ - This is a serializer that can encode the data in an EasyScience - object to a JSON encoded dictionary. - """ - - def encode( - self, - obj: ComponentSerializer, - skip: Optional[List[str]] = None, - full_encode: bool = False, - **kwargs: Any, - ) -> Dict[str, Any]: - """ - Convert an EasyScience object to a JSON encoded data dictionary. - - Parameters - ---------- - obj : ComponentSerializer - Object to be encoded. - skip : Optional[List[str]], default=None - List of field names as strings to skip when forming the - encoded object. By default, None. - full_encode : bool, default=False - Should the data also be JSON encoded (default False). By - default, False. - **kwargs : Any - Any additional key word arguments to be passed to the - encoder. - - Returns - ------- - Dict[str, Any] - Object encoded to data dictionary. - - Raises - ------ - ValueError - If ``skip`` is not a list of strings. - """ - - if skip is None: - skip = [] - elif isinstance(skip, str): - skip = [skip] - if not isinstance(skip, list): - raise ValueError('Skip must be a list of strings.') - encoded = super().encode(obj, skip=skip, full_encode=full_encode, **kwargs) - return self._parse_dict(encoded) - - @classmethod - def decode(cls, d: Dict[str, Any]) -> ComponentSerializer: - """ - This function is not implemented as a data dictionary does not - contain the necessary information to re-form an EasyScience - object. - """ - - raise NotImplementedError( - 'It is not possible to reconstitute objects from data only dictionary.' - ) - - @staticmethod - def _parse_dict(in_dict: Dict[str, Any]) -> Dict[str, Any]: - """Strip out any non-data from a dictionary.""" - - out_dict = dict() - for key in in_dict.keys(): - if key[0] == '@': - if key == '@class' and in_dict[key] not in _KNOWN_CORE_TYPES: - out_dict['name'] = in_dict[key] - continue - out_dict[key] = in_dict[key] - if isinstance(in_dict[key], dict): - out_dict[key] = DataDictSerializer._parse_dict(in_dict[key]) - elif isinstance(in_dict[key], list): - out_dict[key] = [ - DataDictSerializer._parse_dict(x) if isinstance(x, dict) else x - for x in in_dict[key] - ] - return out_dict diff --git a/src/easyscience/legacy/json.py b/src/easyscience/legacy/json.py deleted file mode 100644 index 25833fbc..00000000 --- a/src/easyscience/legacy/json.py +++ /dev/null @@ -1,139 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -__author__ = 'https://github.com/materialsvirtuallab/monty/blob/master/monty/json.py' -__version__ = '3.0.0' - - -import json -from typing import TYPE_CHECKING -from typing import Any -from typing import Dict -from typing import List - -from .template import BaseEncoderDecoder - -if TYPE_CHECKING: - from .component_serializer import ComponentSerializer - - -class JsonSerializer(BaseEncoderDecoder): - def encode(self, obj: ComponentSerializer, skip: List[str] = []) -> str: - """ - Returns a json string representation of the ComponentSerializer - object. - """ - ENCODER = type( - JsonEncoderTemplate.__name__, - (JsonEncoderTemplate, BaseEncoderDecoder), - {'skip': skip}, - ) - return json.dumps(obj, cls=ENCODER) - - @classmethod - def decode(cls, data: str) -> ComponentSerializer: - return json.loads(data, cls=JsonDecoderTemplate) - - -class JsonDataSerializer(BaseEncoderDecoder): - def encode(self, obj: ComponentSerializer, skip: List[str] = []) -> str: - """ - Returns a json string representation of the ComponentSerializer - object. - """ - from .dict import DataDictSerializer - - ENCODER = type( - JsonEncoderTemplate.__name__, - (JsonEncoderTemplate, BaseEncoderDecoder), - { - 'skip': skip, - '_converter': lambda *args, **kwargs: DataDictSerializer._parse_dict( - DataDictSerializer._convert_to_dict(*args, **kwargs) - ), - }, - ) - - return json.dumps(obj, cls=ENCODER) - - @classmethod - def decode(cls, data: str) -> ComponentSerializer: - raise NotImplementedError( - 'It is not possible to reconstitute objects from data only objects.' - ) - - -class JsonEncoderTemplate(json.JSONEncoder): - """ - A Json Encoder which supports the ComponentSerializer API, plus adds - support for numpy arrays, datetime objects, bson ObjectIds (requires - bson). - - Usage:: - - # Add it as a *cls* keyword when using json.dump - json.dumps(object, cls=MontyEncoder) - """ - - skip = [] - _converter = BaseEncoderDecoder._convert_to_dict - - def default(self, o: Any) -> Dict[str, Any]: # pylint: disable=E0202 - """ - Overriding default method for JSON encoding. - - This method does two things: (a) If an object has a to_dict - property, return the to_dict output. (b) If the @module and - @class keys are not in the to_dict, add them to the output - automatically. If the object has no to_dict property, the - default Python json encoder default method is called. - - Parameters - ---------- - o : Any - Python object. - - Returns - ------- - Dict[str, Any] - JSON-serializable dictionary representation of ``o``. - """ - return self._converter(o, self.skip, full_encode=True) - - -class JsonDecoderTemplate(json.JSONDecoder): - """ - A Json Decoder which supports the ComponentSerializer API. - - By default, the decoder attempts to find a module and name - associated with a dict. If found, the decoder will generate a - Pymatgen as a priority. If that fails, the original decoded - dictionary from the string is returned. Note that nested lists and - dicts containing pymatgen object will be decoded correctly as well. - - Usage: - - # Add it as a *cls* keyword when using json.load - json.loads(json_string, cls=MontyDecoder) - """ - - _converter = BaseEncoderDecoder._convert_from_dict - - def decode(self, s: str) -> Any: - """ - Overrides decode from JSONDecoder. - - Parameters - ---------- - s : str - JSON string representation. - - Returns - ------- - Any - Decoded object. - """ - d = json.JSONDecoder.decode(self, s) - return self.__class__._converter(d) diff --git a/src/easyscience/legacy/legacy_core.py b/src/easyscience/legacy/legacy_core.py deleted file mode 100644 index 40a5beac..00000000 --- a/src/easyscience/legacy/legacy_core.py +++ /dev/null @@ -1,220 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -import json -from collections import OrderedDict -from hashlib import sha1 -from typing import TYPE_CHECKING -from typing import Any -from typing import Dict -from typing import List -from typing import Optional - -from ..io.dict import DataDictSerializer -from ..io.dict import DictSerializer -from ..io.json import jsanitize - -if TYPE_CHECKING: - from ..io.template import BaseEncoderDecoder - - -class ComponentSerializer: - """ - This is the base class for all EasyScience objects and deals with - the data conversion to other formats via the ``encode`` and - ``decode`` functions. - - Shortcuts for dictionary and data dictionary encoding is also - present. - """ - - _CORE = True - - def __deepcopy__(self, memo): - return self.from_dict(self.as_dict()) - - def encode( - self, - skip: Optional[List[str]] = None, - encoder: Optional[BaseEncoderDecoder] = None, - **kwargs, - ) -> Any: - """ - Use an encoder to covert an EasyScience object into another - format. Default is to a dictionary using ``DictSerializer``. - - Parameters - ---------- - skip : Optional[List[str]], default=None - List of field names as strings to skip when forming the - encoded object. By default, None. - encoder : Optional[BaseEncoderDecoder], default=None - The encoder to be used for encoding the data. By default, - None. - **kwargs : - Any additional key word arguments to be passed to the - encoder. - - Returns - ------- - Any - Encoded object containing all information to reform an - EasyScience object. - """ - if encoder is None: - encoder = DictSerializer - encoder_obj = encoder() - return encoder_obj.encode(self, skip=skip, **kwargs) - - @classmethod - def decode(cls, obj: Any, decoder: Optional[BaseEncoderDecoder] = None) -> Any: - """ - Re-create an EasyScience object from the output of an encoder. - The default decoder is ``DictSerializer``. - - Parameters - ---------- - obj : Any - Encoded EasyScience object. - decoder : Optional[BaseEncoderDecoder], default=None - Decoder to be used to reform the EasyScience object. By - default, None. - - Returns - ------- - Any - Reformed EasyScience object. - """ - - if decoder is None: - decoder = DictSerializer - return decoder.decode(obj) - - def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: - """ - Convert an EasyScience object into a full dictionary using - ``DictSerializer``. This is a shortcut for - ```obj.encode(encoder=DictSerializer)``` - - Parameters - ---------- - skip : Optional[List[str]], default=None - List of field names as strings to skip when forming - the dictionary. By default, None. - - Returns - ------- - Dict[str, Any] - Encoded object containing all information to reform an - EasyScience object. - """ - - return self.encode(skip=skip, encoder=DictSerializer) - - @classmethod - def from_dict(cls, obj_dict: Dict[str, Any]) -> Any: - """ - Re-create an EasyScience object from a full encoded dictionary. - - Parameters - ---------- - obj_dict : Dict[str, Any] - Dictionary containing the serialized contents (from - ``DictSerializer``) of an EasyScience object. - - Returns - ------- - Any - Reformed EasyScience object. - """ - - return cls.decode(obj_dict, decoder=DictSerializer) - - def encode_data( - self, - skip: Optional[List[str]] = None, - encoder: Optional[BaseEncoderDecoder] = None, - **kwargs, - ) -> Any: - """ - Returns just the data in an EasyScience object win the format - specified by an encoder. - - Parameters - ---------- - skip : Optional[List[str]], default=None - List of field names as strings to skip when forming the - dictionary. By default, None. - encoder : Optional[BaseEncoderDecoder], default=None - The encoder to be used for encoding the data. By default, - None. - **kwargs : - Any additional keywords to pass to the encoder when - encoding. - - Returns - ------- - Any - Encoded object containing just the data of an EasyScience - object. - """ - - if encoder is None: - encoder = DataDictSerializer - return self.encode(skip=skip, encoder=encoder, **kwargs) - - def as_data_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: - """ - Returns a dictionary containing just the data of an EasyScience - object. - - Parameters - ---------- - skip : Optional[List[str]], default=None - List of field names as strings to skip when forming the - dictionary. By default, None. - - Returns - ------- - Dict[str, Any] - Dictionary containing just the data of an EasyScience - object. - """ - - return self.encode(skip=skip, encoder=DataDictSerializer) - - def unsafe_hash(self) -> sha1: - """ - Returns an hash of the current object. - - This uses a generic but low performance method of converting the - object to a dictionary, flattening any nested keys, and then - performing a hash on the resulting object - """ - - def flatten(obj, seperator='.'): - # Flattens a dictionary - - flat_dict = {} - for key, value in obj.items(): - if isinstance(value, dict): - flat_dict.update({ - seperator.join([key, _key]): _value - for _key, _value in flatten(value).items() - }) - elif isinstance(value, list): - list_dict = { - '{}{}{}'.format(key, seperator, num): item - for num, item in enumerate(value) - } - flat_dict.update(flatten(list_dict)) - else: - flat_dict[key] = value - - return flat_dict - - ordered_keys = sorted(flatten(jsanitize(self.as_dict())).items(), key=lambda x: x[0]) - ordered_keys = [item for item in ordered_keys if '@' not in item[0]] - return sha1(json.dumps(OrderedDict(ordered_keys)).encode('utf-8')) # noqa: S324 diff --git a/src/easyscience/legacy/obj_base.py b/src/easyscience/legacy/obj_base.py deleted file mode 100644 index 4a70de25..00000000 --- a/src/easyscience/legacy/obj_base.py +++ /dev/null @@ -1,171 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -from typing import TYPE_CHECKING -from typing import Callable -from typing import Optional - -from easyscience import global_object - -from ..base_classes.based_base import BasedBase -from ..utils.classTools import addLoggedProp -from ..variable.descriptor_base import DescriptorBase - -if TYPE_CHECKING: - from ..io import SerializerComponent - - -class ObjBase(BasedBase): - """ - This is the base class for which all higher level classes are built - off of. - - .. deprecated:: - ``ObjBase`` is deprecated and will be removed in a future version. - Please migrate to ``ModelBase`` instead. - - NOTE: This object is serializable only if parameters are supplied - as: ``ObjBase(a=value, b=value)``. For ``Parameter`` or - ``Descriptor`` objects we can cheat with - ``ObjBase(*[Descriptor(...), Parameter(...), ...])``. - """ - - def __init__( - self, - name: str, - unique_name: Optional[str] = None, - *args: Optional[SerializerComponent], - **kwargs: Optional[SerializerComponent], - ): - """ - Set up the base class. - - Parameters - ---------- - name : str - Name of this object. - unique_name : Optional[str], default=None - Unique identifier for this object. By default, None. - *args : Optional[SerializerComponent] - Positional serializer components to attach to this object. - **kwargs : Optional[SerializerComponent] - Fields which this class should contain. - - Raises - ------ - AttributeError - If a keyword component name would overwrite an existing - class attribute. - """ - global_object.log.getLogger('legacy').warning( - 'ObjBase is deprecated and will be removed in a future version. ' - 'Please migrate to ModelBase.', - stacklevel=2, - ) - super(ObjBase, self).__init__(name=name, unique_name=unique_name) - # If Parameter or Descriptor is given as arguments... - for arg in args: - if issubclass(type(arg), (ObjBase, DescriptorBase)): - kwargs[getattr(arg, 'name')] = arg - # Set kwargs, also useful for serialization - known_keys = self.__dict__.keys() - self._kwargs = kwargs - for key in kwargs.keys(): - if key in known_keys: - raise AttributeError('Kwargs cannot overwrite class attributes in ObjBase.') - if issubclass(type(kwargs[key]), (BasedBase, DescriptorBase)) or 'CollectionBase' in [ - c.__name__ for c in type(kwargs[key]).__bases__ - ]: - self._global_object.map.add_edge(self, kwargs[key]) - self._global_object.map.reset_type(kwargs[key], 'created_internal') - addLoggedProp( - self, - key, - self.__getter(key), - self.__setter(key), - get_id=key, - my_self=self, - test_class=ObjBase, - ) - - def _add_component(self, key: str, component: SerializerComponent) -> None: - """Dynamically add a component to the class. This is an internal - method, though can be called remotely. The recommended - alternative is to use typing, i.e. - - .. code-block:: python - - class Foo(Bar): - def __init__(self, foo: Parameter, bar: Parameter): - '''Init function.''' - super(Foo, self).__init__(bar=bar) - self._add_component('foo', foo) - - :param key: Name of component to be added - :param component: Component to be added - :return: None - """ - self._kwargs[key] = component - self._global_object.map.add_edge(self, component) - self._global_object.map.reset_type(component, 'created_internal') - addLoggedProp( - self, - key, - self.__getter(key), - self.__setter(key), - get_id=key, - my_self=self, - test_class=ObjBase, - ) - - def __setattr__(self, key: str, value: SerializerComponent) -> None: - # Assume that the annotation is a ClassVar - old_obj = None - if ( - hasattr(self.__class__, '__annotations__') - and key in self.__class__.__annotations__ - and hasattr(self.__class__.__annotations__[key], '__args__') - and issubclass( - getattr(value, '__old_class__', value.__class__), - self.__class__.__annotations__[key].__args__, - ) - ): - if issubclass(type(getattr(self, key, None)), (BasedBase, DescriptorBase)): - old_obj = self.__getattribute__(key) - self._global_object.map.prune_vertex_from_edge(self, old_obj) - self._add_component(key, value) - else: - if hasattr(self, key) and issubclass(type(value), (BasedBase, DescriptorBase)): - old_obj = self.__getattribute__(key) - self._global_object.map.prune_vertex_from_edge(self, old_obj) - self._global_object.map.add_edge(self, value) - super(ObjBase, self).__setattr__(key, value) - # Update the interface bindings if something changed (BasedBase and Descriptor) - if old_obj is not None: - old_interface = getattr(self, 'interface', None) - if old_interface is not None: - self.generate_bindings() - - def __repr__(self) -> str: - return f'{self.__class__.__name__} `{getattr(self, "name")}`' - - @staticmethod - def __getter(key: str) -> Callable[[SerializerComponent], SerializerComponent]: - def getter(obj: SerializerComponent) -> SerializerComponent: - return obj._kwargs[key] - - return getter - - @staticmethod - def __setter(key: str) -> Callable[[SerializerComponent], None]: - def setter(obj: SerializerComponent, value: float) -> None: - if issubclass(obj._kwargs[key].__class__, (DescriptorBase)) and not issubclass( - value.__class__, (DescriptorBase) - ): - obj._kwargs[key].value = value - else: - obj._kwargs[key] = value - - return setter diff --git a/src/easyscience/legacy/xml.py b/src/easyscience/legacy/xml.py deleted file mode 100644 index da52ef65..00000000 --- a/src/easyscience/legacy/xml.py +++ /dev/null @@ -1,197 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -import sys -import xml.etree.ElementTree as ET -from numbers import Number -from typing import TYPE_CHECKING -from typing import Any -from typing import List -from typing import Optional - -import numpy as np - -from easyscience import global_object - -from ..io.dict import DataDictSerializer -from ..io.dict import DictSerializer -from ..io.template import BaseEncoderDecoder - -if TYPE_CHECKING: - from ..io.component_serializer import ComponentSerializer - - -can_intent = (sys.version_info.major > 2) & (sys.version_info.minor > 8) - - -class XMLSerializer(BaseEncoderDecoder): - """ - This is a serializer that can encode and decode EasyScience objects - to a basic xml format. - """ - - def encode( - self, - obj: ComponentSerializer, - skip: Optional[List[str]] = None, - data_only: bool = False, - fast: bool = False, - use_header: bool = False, - **kwargs, - ) -> str: - """ - Convert an EasyScience object to an XML encoded string. - - Note that for speed the ``fast`` setting can be changed to - ``True``. An XML document with initial block *data* is returned. - - Parameters - ---------- - obj : ComponentSerializer - Object to be encoded. - skip : Optional[List[str]], default=None - List of field names as strings to skip when forming the - encoded object. By default, None. - data_only : bool, default=False - Should only the object's data be encoded. By default, False. - fast : bool, default=False - Should the returned string be pretty? This can be turned off - for speed. By default, False. - use_header : bool, default=False - Should a header of ``'?xml version="1.0" - encoding="UTF-8"?'`` be included? By default, False. - **kwargs : - Any additional key-words to pass to the Dictionary - Serializer. - - Returns - ------- - str - String containing the XML encoded object. - """ - - if skip is None: - skip = [] - encoder = DictSerializer - if data_only: - encoder = DataDictSerializer - if isinstance(obj, dict): - obj_dict = obj - else: - obj_dict = encoder().encode(obj, skip=skip, full_encode=True, **kwargs) - block = ET.Element('data') - self._check_class(block, None, obj_dict, skip=skip) - header = '' - if use_header: - header = '?xml version="1.0" encoding="UTF-8"?' - if not fast and can_intent: - ET.indent(block) - if use_header: - header += '\n' - return header + ET.tostring(block, encoding='unicode') - - @classmethod - def decode(cls, data: str) -> ComponentSerializer: - """ - Decode an EasyScience object which has been encoded in XML - format. - - Parameters - ---------- - data : str - String containing XML encoded data. - - Returns - ------- - ComponentSerializer - Reformed EasyScience object. - """ - - data_xml = ET.XML(data) - out_dict = {} - for element in data_xml: - XMLSerializer._element_to_dict(element, out_dict) - return DictSerializer.decode(out_dict) - - @staticmethod - def _element_to_dict(element, out_dict): - """Convert an XML element to a dictionary recursively.""" - - label = element.tag - if label[0] == '_': - label = '@' + label[2:] - if len(element) == 0: - out_dict[label] = XMLSerializer.string_to_variable(element.text) - else: - this_dict = {} - for el in element: - XMLSerializer._element_to_dict(el, this_dict) - if label in out_dict.keys(): - # This object is a list. Create a list. - old_value = out_dict[label] - if not isinstance(old_value, list): - old_value = [old_value] - old_value.append(this_dict) - this_dict = old_value - out_dict[label] = this_dict - - @staticmethod - def string_to_variable(in_string: str): - """Convert an XML encoded string to JSON form.""" - if in_string is None: - return in_string - in_string = in_string.strip() - if "'" in in_string: - in_string = in_string.replace("'", '') - if '"' in in_string: - in_string = in_string.replace('"', '') - try: - value = float(in_string) - except ValueError: - if in_string == 'True': - value = True - elif in_string == 'False': - value = False - elif in_string == 'None': - value = None - else: - value = in_string - return value - - def _check_class(self, element, key: str, value: Any, skip: Optional[List[str]] = None): - """ - Add a value to an element or create a new element based on input - type. - """ - T_ = type(value) - if isinstance(value, dict): - for k, v in value.items(): - if k in skip: - continue - kk = k - if k[0] == '@': - kk = '__' + kk[1:] - if not isinstance(v, list): - s = ET.SubElement(element, kk) - self._check_class(s, kk, v, skip=skip) - else: - self._check_class(element, kk, v, skip=skip) - elif isinstance(value, bool): - element.text = str(value) - elif isinstance(value, str): - element.text = value - elif isinstance(value, list): - for i in value: - s = ET.SubElement(element, key) - self._check_class(s, None, i, skip=skip) - elif value is None: - element.text = 'None' - elif issubclass(T_, Number): - element.text = str(value) - elif issubclass(T_, np.ndarray): - element.text = str(value.tolist()) - else: - global_object.log.getLogger('legacy.xml').error('Cannot encode %s to XML', T_) - raise NotImplementedError diff --git a/src/easyscience/models/polynomial.py b/src/easyscience/models/polynomial.py index 18066e9f..1ff32812 100644 --- a/src/easyscience/models/polynomial.py +++ b/src/easyscience/models/polynomial.py @@ -1,64 +1,72 @@ # SPDX-FileCopyrightText: 2024 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -from typing import ClassVar from typing import Iterable from typing import Optional from typing import Union import numpy as np -from ..base_classes import CollectionBase -from ..base_classes import ObjBase +from ..base_classes import EasyList +from ..base_classes import ModelBase from ..variable import Parameter -class Polynomial(ObjBase): +class Polynomial(ModelBase): """ A polynomial model. - Attributes - ---------- - coefficients : ClassVar[CollectionBase] - Coefficients of the polynomial. - Parameters ---------- - name : str, default='polynomial' - The name of the model. - coefficients : Optional[Union[Iterable[Union[float, Parameter]], CollectionBase]], default=None + display_name : Optional[str], default='polynomial' + The display name of the model. + coefficients : Optional[Union[Iterable[Union[float, Parameter]], EasyList]], default=None Coefficients used to populate ``self.coefficients``. + unique_name : Optional[str], default=None + Unique name of the model. By default, None. Raises ------ TypeError - If ``coefficients`` is neither a ``CollectionBase`` nor an - iterable of floats or ``Parameter`` instances. + If ``coefficients`` is neither an ``EasyList`` nor an iterable + of floats or ``Parameter`` instances. """ - coefficients: ClassVar[CollectionBase] - def __init__( self, - name: str = 'polynomial', - coefficients: Optional[Union[Iterable[Union[float, Parameter]], CollectionBase]] = None, + display_name: Optional[str] = 'polynomial', + coefficients: Optional[Union[Iterable[Union[float, Parameter]], EasyList]] = None, + unique_name: Optional[str] = None, ): - super(Polynomial, self).__init__(name, coefficients=CollectionBase('coefficients')) + super(Polynomial, self).__init__(unique_name=unique_name, display_name=display_name) + self._coefficients = EasyList(protected_types=Parameter) if coefficients is not None: - if issubclass(type(coefficients), CollectionBase): - self.coefficients = coefficients + if isinstance(coefficients, EasyList): + self._coefficients = coefficients elif isinstance(coefficients, Iterable): for index, item in enumerate(coefficients): if issubclass(type(item), Parameter): - self.coefficients.append(item) + self._coefficients.append(item) elif isinstance(item, float): - self.coefficients.append( + self._coefficients.append( Parameter(value=item, display_name='c{}'.format(index)) ) else: raise TypeError('Coefficients must be floats or Parameters') else: - raise TypeError('coefficients must be a list or a CollectionBase') + raise TypeError('coefficients must be a list or an EasyList') + + @property + def coefficients(self) -> EasyList: + """ + Get the coefficients of the polynomial. + + Returns + ------- + EasyList + Coefficients of the polynomial. + """ + return self._coefficients def __call__(self, x: np.ndarray, *args, **kwargs) -> np.ndarray: return np.polyval([c.value for c in self.coefficients], x) @@ -77,4 +85,4 @@ def __repr__(self): ] s.reverse() s = ' + '.join(s) - return 'Polynomial({}, {})'.format(self.name, s) + return 'Polynomial({}, {})'.format(self.display_name, s) diff --git a/src/easyscience/utils/classTools.py b/src/easyscience/utils/classTools.py deleted file mode 100644 index 151f36c1..00000000 --- a/src/easyscience/utils/classTools.py +++ /dev/null @@ -1,66 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -from typing import TYPE_CHECKING -from typing import List -from typing import Tuple - -from easyscience import global_object -from easyscience.global_object.hugger.property import LoggedProperty - -if TYPE_CHECKING: - from ..base_classes import BasedBase - from ..io import SerializerComponent - - -def addLoggedProp(inst: SerializerComponent, name: str, *args, **kwargs) -> None: - cls = type(inst) - annotations = getattr(cls, '__annotations__', False) - if not hasattr(cls, '__perinstance'): - cls = type(cls.__name__, (cls,), {'__module__': inst.__module__}) - cls.__perinstance = True - if annotations: - cls.__annotations__ = annotations - inst.__old_class__ = inst.__class__ - inst.__class__ = cls - setattr(cls, name, LoggedProperty(*args, **kwargs)) - - -def addProp(inst: SerializerComponent, name: str, *args, **kwargs) -> None: - cls = type(inst) - annotations = getattr(cls, '__annotations__', False) - if not hasattr(cls, '__perinstance'): - cls = type(cls.__name__, (cls,), {'__module__': __name__}) - cls.__perinstance = True - if annotations: - cls.__annotations__ = annotations - inst.__old_class__ = inst.__class__ - inst.__class__ = cls - - setattr(cls, name, property(*args, **kwargs)) - - -def removeProp(inst: SerializerComponent, name: str) -> None: - cls = type(inst) - if not hasattr(cls, '__perinstance'): - cls = type(cls.__name__, (cls,), {'__module__': __name__}) - cls.__perinstance = True - inst.__old_class__ = inst.__class__ - inst.__class__ = cls - delattr(cls, name) - - -def generatePath(model_obj: BasedBase, skip_first: bool = False) -> Tuple[List[int], List[str]]: - pars = model_obj.get_parameters() - start_idx = 0 + int(skip_first) - unique_names = [] - names = [] - for par in pars: - route = global_object.map.reverse_route(par.unique_name, model_obj.unique_name) - objs = [getattr(global_object.map.get_item_by_key(r), 'name') for r in route] - objs.reverse() - names.append('.'.join(objs[start_idx:])) - unique_names.append(par.unique_name) - return unique_names, names diff --git a/tests/functional/test_smoke.py b/tests/functional/test_smoke.py index a355e548..af5396fb 100644 --- a/tests/functional/test_smoke.py +++ b/tests/functional/test_smoke.py @@ -13,7 +13,7 @@ def test_polynomial_smoke() -> None: """Exercise a minimal user-facing model workflow.""" global_object.map._clear() - model = Polynomial(name='smoke', coefficients=[1.0, 2.0, 3.0]) + model = Polynomial(display_name='smoke', coefficients=[1.0, 2.0, 3.0]) x = np.array([0.0, 1.0, 2.0]) assert [coefficient.display_name for coefficient in model.coefficients] == ['c0', 'c1', 'c2'] diff --git a/tests/integration/fitting/test_fitter.py b/tests/integration/fitting/test_fitter.py index c3b1c924..ce466993 100644 --- a/tests/integration/fitting/test_fitter.py +++ b/tests/integration/fitting/test_fitter.py @@ -6,35 +6,41 @@ from easyscience import AvailableMinimizers from easyscience import Fitter -from easyscience import ObjBase from easyscience import Parameter from easyscience.base_classes import ModelBase from easyscience.fitting.minimizers import FitError # Model and container of parameters for tests -class AbsSin(ObjBase): - phase: Parameter - offset: Parameter - +class _PhaseOffset(ModelBase): def __init__(self, offset_val: float, phase_val: float): - offset = Parameter(offset_val, display_name='offset') - phase = Parameter(phase_val, display_name='phase') - super().__init__('sin', offset=offset, phase=phase) + super().__init__() + self._offset = Parameter(offset_val, display_name='offset') + self._phase = Parameter(phase_val, display_name='phase') - def __call__(self, x): - return np.abs(np.sin(self.phase.value * x + self.offset.value)) + @property + def offset(self) -> Parameter: + return self._offset + + @offset.setter + def offset(self, value: float) -> None: + self._offset.value = value + @property + def phase(self) -> Parameter: + return self._phase -class AbsSin2D(ObjBase): - phase: Parameter - offset: Parameter + @phase.setter + def phase(self, value: float) -> None: + self._phase.value = value + + +class AbsSin(_PhaseOffset): + def __call__(self, x): + return np.abs(np.sin(self.phase.value * x + self.offset.value)) - def __init__(self, offset_val: float, phase_val: float): - offset = Parameter(offset_val, display_name='offset') - phase = Parameter(phase_val, display_name='phase') - super().__init__('sin2D', offset=offset, phase=phase) +class AbsSin2D(_PhaseOffset): def __call__(self, x): X = x[:, :, 0] # x is a 2D array Y = x[:, :, 1] @@ -90,7 +96,7 @@ def check_fit_results(result, sp_sin, ref_sin, x, expect_error=True, **kwargs): assert key in result.p0.keys() assert result.p0[key] == pytest.approx(value) # Bumps does something strange here assert np.all(result.x == x) - for item1, item2 in zip(sp_sin._kwargs.values(), ref_sin._kwargs.values()): + for item1, item2 in zip(sp_sin.get_all_parameters(), ref_sin.get_all_parameters()): # Gradient-free methods (e.g. lmfit's powell/cobyla) have no covariance # matrix, so they report error as None rather than a fake 0.0. Every # other method must still produce a real uncertainty. @@ -165,11 +171,11 @@ def test_fit_result(fit_engine): sp_ref1 = { f'p{item1.unique_name}': item1.value - for item1, item2 in zip(sp_sin._kwargs.values(), ref_sin._kwargs.values()) + for item1, item2 in zip(sp_sin.get_all_parameters(), ref_sin.get_all_parameters()) } sp_ref2 = { f'p{item1.unique_name}': item2.value - for item1, item2 in zip(sp_sin._kwargs.values(), ref_sin._kwargs.values()) + for item1, item2 in zip(sp_sin.get_all_parameters(), ref_sin.get_all_parameters()) } f = Fitter(sp_sin, sp_sin) diff --git a/tests/unit/base_classes/test_collection_base.py b/tests/unit/base_classes/test_collection_base.py deleted file mode 100644 index 8db6c220..00000000 --- a/tests/unit/base_classes/test_collection_base.py +++ /dev/null @@ -1,550 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -import pytest - -import easyscience -from easyscience import DescriptorNumber -from easyscience import ObjBase -from easyscience import Parameter -from easyscience import global_object -from easyscience.base_classes import CollectionBase - -test_dict = { - '@module': 'easyscience.base_classes', - '@class': 'CollectionBase', - '@version': easyscience.__version__, - 'name': 'testing', - 'data': [ - { - '@module': DescriptorNumber.__module__, - '@class': DescriptorNumber.__name__, - '@version': easyscience.__version__, - 'value': 1.0, - 'unit': 'dimensionless', - 'variance': None, - 'unique_name': 'CollectionBase_0', - 'description': '', - 'url': '', - 'display_name': 'par1', - } - ], -} - - -class Alpha(CollectionBase): - pass - - -class_constructors = [CollectionBase, Alpha] - - -@pytest.fixture -def setup_pars(): - d = { - 'name': 'test', - 'par1': Parameter(0.1, fixed=True, display_name='p1'), - 'des1': DescriptorNumber(0.1, display_name='d1'), - 'par2': Parameter(0.1, display_name='p2'), - 'des2': DescriptorNumber(0.1, display_name='d2'), - 'par3': Parameter(0.1, display_name='p3'), - } - return d - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_from_base(cls, setup_pars): - name = setup_pars['name'] - del setup_pars['name'] - coll = cls(name, **setup_pars) - - assert coll.name == name - assert len(coll) == 5 - assert coll.user_data == {} - - for item, key in zip(coll, setup_pars.keys()): - assert item.display_name == setup_pars[key].display_name - assert item.value == setup_pars[key].value - - -@pytest.mark.parametrize('cls', class_constructors) -@pytest.mark.parametrize('value', range(1, 11)) -def test_CollectionBase_from_ObjBase(cls, setup_pars: dict, value: int): - name = setup_pars['name'] - del setup_pars['name'] - objs = {} - - prefix = 'obj' - for idx in range(value): - objs[prefix + str(idx)] = ObjBase(prefix + str(idx), **setup_pars) - - coll = cls(name, **objs) - - assert coll.name == name - assert len(coll) == value - assert coll.user_data == {} - - idx = 0 - for item, key in zip(coll, objs.keys()): - # Members here are legacy ObjBase instances, which still carry `name`. - assert item.name == prefix + str(idx) - assert isinstance(item, objs[key].__class__) - idx += 1 - - -@pytest.mark.parametrize('cls', class_constructors) -@pytest.mark.parametrize('value', ('abc', False, (), [])) -def test_CollectionBase_create_fail(cls, setup_pars, value): - name = setup_pars['name'] - del setup_pars['name'] - setup_pars['to_fail'] = value - - with pytest.raises(AttributeError): - coll = cls(name, **setup_pars) - - -@pytest.mark.parametrize('cls', class_constructors) -@pytest.mark.parametrize('key', ('user_data', '_kwargs', 'interface')) -def test_CollectionBase_create_fail2(cls, setup_pars, key): - name = setup_pars['name'] - del setup_pars['name'] - setup_pars[key] = DescriptorNumber(0, display_name='fail_name') - - with pytest.raises(AttributeError): - coll = cls(name, **setup_pars) - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_append_base(cls, setup_pars): - name = setup_pars['name'] - del setup_pars['name'] - - new_item_name = 'boo' - new_item_value = 100 - new_item = Parameter(new_item_value, display_name=new_item_name) - - coll = cls(name, **setup_pars) - n_before = len(coll) - - coll.append(new_item) - assert len(coll) == n_before + 1 - assert coll[-1].display_name == new_item_name - assert coll[-1].value == new_item_value - - -@pytest.mark.parametrize('cls', class_constructors) -@pytest.mark.parametrize('value', ('abc', False, (), [])) -def test_CollectionBase_append_fail(cls, setup_pars, value): - name = setup_pars['name'] - del setup_pars['name'] - - coll = cls(name, **setup_pars) - with pytest.raises(AttributeError): - coll.append(value) - - -@pytest.mark.parametrize('cls', class_constructors) -@pytest.mark.parametrize('value', (0, 1, 3, 'par1', 'des1')) -def test_CollectionBase_getItem(cls, setup_pars, value): - name = setup_pars['name'] - del setup_pars['name'] - - coll = cls(name, **setup_pars) - - get_item = coll[value] - if isinstance(value, str): - key = value - else: - key = list(setup_pars.keys())[value] - assert get_item.display_name == setup_pars[key].display_name - - -@pytest.mark.parametrize('cls', class_constructors) -@pytest.mark.parametrize('value', (False, [], (), 100, 100.4)) -def test_CollectionBase_getItem_type_fail(cls, setup_pars, value): - name = setup_pars['name'] - del setup_pars['name'] - - coll = cls(name, **setup_pars) - - with pytest.raises((IndexError, TypeError)): - get_item = coll[value] - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_getItem_slice(cls, setup_pars): - name = setup_pars['name'] - del setup_pars['name'] - - coll = cls(name, **setup_pars) - - get_item = coll[0:2] - assert len(get_item) == 2 - - -@pytest.mark.parametrize('cls', class_constructors) -@pytest.mark.parametrize('value', (0, 1, 3)) -def test_CollectionBase_setItem(cls, setup_pars, value): - name = setup_pars['name'] - del setup_pars['name'] - - coll = cls(name, **setup_pars) - n_coll = len(coll) - name_coll_idx = coll[value].display_name - - new_item_value = 100 - - coll[value] = new_item_value - - assert len(coll) == n_coll - assert coll[value].display_name == name_coll_idx - assert coll[value].value == new_item_value - - -@pytest.mark.parametrize('cls', class_constructors) -@pytest.mark.parametrize('value', ('abc', (), [])) -def test_CollectionBase_setItem_fail(cls, setup_pars, value): - name = setup_pars['name'] - del setup_pars['name'] - - coll = cls(name, **setup_pars) - - with pytest.raises(NotImplementedError): - for idx in range(len(coll)): - coll[idx] = value - - -@pytest.mark.parametrize('cls', class_constructors) -@pytest.mark.parametrize('value', (0, 1, 3)) -def test_CollectionBase_delItem(cls, setup_pars, value): - name = setup_pars['name'] - del setup_pars['name'] - - coll = cls(name, **setup_pars) - n_coll = len(coll) - # On del we should shift left - name_coll_idx = coll[value].display_name - name_coll_idxp = coll[value + 1].display_name - - del coll[value] - - assert len(coll) == n_coll - 1 - assert coll[value].display_name == name_coll_idxp - assert name_coll_idx not in [col.display_name for col in coll] - - -@pytest.mark.parametrize('cls', class_constructors) -@pytest.mark.parametrize('value', (0, 1, 3)) -def test_CollectionBase_len(cls, setup_pars, value): - name = setup_pars['name'] - del setup_pars['name'] - - keys = list(setup_pars.keys()) - keys = keys[0 : (value + 1)] - - coll = cls(name, **{key: setup_pars[key] for key in keys}) - assert len(coll) == (value + 1) - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_get_parameters(cls, setup_pars): - name = setup_pars['name'] - del setup_pars['name'] - obj = cls(name, **setup_pars) - pars = obj.get_parameters() - assert len(pars) == 3 - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_get_parameters_nested(cls, setup_pars): - name = setup_pars['name'] - del setup_pars['name'] - obj = ObjBase(name, **setup_pars) - - name2 = name + '_2' - obj2 = cls(name2, obj=obj, **setup_pars) - - pars = obj2.get_parameters() - assert len(pars) == 6 - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_get_fit_parameters(cls, setup_pars): - name = setup_pars['name'] - del setup_pars['name'] - obj = cls(name, **setup_pars) - pars = obj.get_fit_parameters() - assert len(pars) == 2 - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_get_fit_parameters_nested(cls, setup_pars): - name = setup_pars['name'] - del setup_pars['name'] - obj = ObjBase(name, **setup_pars) - - name2 = name + '_2' - obj2 = cls(name2, obj=obj, **setup_pars) - - pars = obj2.get_fit_parameters() - assert len(pars) == 4 - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_dir(cls): - name = 'testing' - kwargs = {'p1': DescriptorNumber(1, display_name='par1')} - obj = cls(name, **kwargs) - d = set(dir(obj)) - - expected = { - 'user_data', - 'reverse', - 'constraints', - 'get_fit_parameters', - 'append', - 'unique_name', - 'index', - 'to_dict', - 'clear', - 'extend', - 'encode', - 'remove', - 'interface', - 'from_dict', - 'name', - 'switch_interface', - 'get_parameters', - 'insert', - 'data', - 'pop', - 'count', - 'generate_bindings', - 'decode', - 'sort', - } - assert not d.difference(expected) - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_to_dict(cls): - name = 'testing' - kwargs = {'p1': DescriptorNumber(1, display_name='par1')} - obj = cls(name, **kwargs) - d = obj.to_dict() - - def check_dict(dict_1: dict, dict_2: dict): - keys_1 = list(dict_1.keys()) - keys_2 = list(dict_2.keys()) - if 'unique_name' in keys_1: - del keys_1[keys_1.index('unique_name')] - if 'unique_name' in keys_2: - del keys_2[keys_2.index('unique_name')] - - assert not set(keys_1).difference(set(keys_2)) - - def testit(item1, item2): - if isinstance(item1, dict) and isinstance(item2, dict): - check_dict(item1, item2) - elif isinstance(item1, list) and isinstance(item2, list): - for v1, v2 in zip(item1, item2): - testit(v1, v2) - else: - if isinstance(item1, str) and isinstance(item2, str): - assert item1 == item2 - elif isinstance(item1, float) and isinstance(item2, float): - assert item1 == item2 - else: - assert item1 is item2 - - keys_1 = list(keys_1) - keys_1.sort() - keys_2 = list(keys_2) - keys_2.sort() - - for k1, k2 in zip(keys_1, keys_2): - if k1[0] == '@': - continue - testit(dict_1[k1], dict_2[k2]) - - check_dict(d, test_dict) - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_from_dict(cls): - global_object.map._clear() # TODO: figure out why this test fails without this line - name = 'testing' - kwargs = {'p1': DescriptorNumber(1, display_name='par1')} - expected = cls.from_dict(test_dict) - ref = cls(name, **kwargs) - - assert ref.name == expected.name - assert len(ref) == len(expected) - for item1, item2 in zip(ref, expected): - assert item1.display_name == item2.display_name - assert item1.value == item2.value - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_repr(cls): - name = 'test' - p1 = Parameter(1, display_name='p1') - obj = cls(name, p1) - test_str = str(obj) - ref_str = f'{cls.__name__} `{name}` of length 1' - assert test_str == ref_str - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_iterator(cls): - name = 'test' - p1 = Parameter(1, display_name='p1') - p2 = Parameter(2, display_name='p2') - p3 = Parameter(3, display_name='p3') - p4 = Parameter(4, display_name='p4') - - l_object = [p1, p2, p3, p4] - - obj = cls(name, *l_object) - - for index, item in enumerate(obj): - assert item == l_object[index] - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_iterator_dict(cls): - global_object.map._clear() # TODO: figure out why this test fails without this line - name = 'test' - p1 = Parameter(1, display_name='p1') - p2 = Parameter(2, display_name='p2') - p3 = Parameter(3, display_name='p3') - p4 = Parameter(4, display_name='p4') - - l_object = [p1, p2, p3, p4] - - obj = cls(name, *l_object) - d = obj.to_dict() - global_object.map._clear() - obj2 = cls.from_dict(d) - - for index, item in enumerate(obj2): - assert item.value == l_object[index].value - - -@pytest.mark.xfail( - strict=True, - raises=AttributeError, - reason='legacy CollectionBase string indexing uses the removed descriptor `name` (#308/#292)', -) -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_sameName(cls): - global_object.map._clear() # TODO: figure out why this test fails without this line - name = 'test' - p1 = Parameter(1, display_name='p1') - p2 = Parameter(2, display_name='p1') - p3 = Parameter(3, display_name='p3') - p4 = Parameter(4, display_name='p4') - - l_object = [p1, p2, p3, p4] - obj = cls(name, *l_object) - assert len(l_object) == len(obj) - for index, item in enumerate(obj): - assert item == l_object[index] - - obj12 = obj['p1'] - assert len(obj12) == 2 - for index, item in enumerate(obj12): - assert item == l_object[index] - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_set_index(cls): - name = 'test' - p1 = Parameter(1, display_name='p1') - p2 = Parameter(2, display_name='p1') - p3 = Parameter(3, display_name='p3') - p4 = Parameter(4, display_name='p4') - - l_object = [p1, p2, p3] - obj = cls(name, *l_object) - - idx = 1 - assert obj[idx] == p2 - obj[idx] = p4 - assert obj[idx] == p4 - edges = obj._global_object.map.get_edges(obj) - assert len(edges) == len(obj) - for item in obj: - assert item.unique_name in edges - assert p2.unique_name not in edges - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_set_index_based(cls): - name = 'test' - p1 = Parameter(1, display_name='p1') - p2 = Parameter(2, display_name='p2') - p3 = Parameter(3, display_name='p3') - p4 = Parameter(4, display_name='p4') - p5 = Parameter(5, display_name='p5') - d = cls('testing', p1, p2) - - l_object = [p3, p4, p5] - obj = cls(name, *l_object) - - idx = 1 - assert obj[idx] == p4 - obj[idx] = d - assert obj[idx] == d - edges = obj._global_object.map.get_edges(obj) - assert len(edges) == len(obj) - for item in obj: - assert item.unique_name in edges - assert p4.unique_name not in edges - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_sort(cls): - name = 'test' - v = [1, 4, 3, 2, 5] - expected = [1, 2, 3, 4, 5] - d = cls(name, *[Parameter(v[i], display_name=f'p{i}') for i in range(len(v))]) - d.sort(lambda x: x.value) - for i, item in enumerate(d): - assert item.value == expected[i] - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_sort_reverse(cls): - name = 'test' - v = [1, 4, 3, 2, 5] - expected = [1, 2, 3, 4, 5] - expected.reverse() - d = cls(name, *[Parameter(v[i], display_name=f'p{i}') for i in range(len(v))]) - d.sort(lambda x: x.value, reverse=True) - for i, item in enumerate(d): - assert item.value == expected[i] - - -class Beta(ObjBase): - pass - - -@pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBaseGraph(cls): - from easyscience import global_object - - G = global_object.map - name = 'test' - v = [1, 2] - p = [Parameter(v[i], display_name=f'p{i}') for i in range(len(v))] - p_id = [_p.unique_name for _p in p] - bb = cls(name, *p) - bb_id = bb.unique_name - b = Beta('b', bb=bb) - b_id = b.unique_name - for _id in p_id: - assert _id in G.get_edges(bb) - assert len(p) == len(G.get_edges(bb)) - assert bb_id in G.get_edges(b) - assert 1 == len(G.get_edges(b)) diff --git a/tests/unit/base_classes/test_deprecated_wrappers.py b/tests/unit/base_classes/test_deprecated_wrappers.py deleted file mode 100644 index ea7cb6e3..00000000 --- a/tests/unit/base_classes/test_deprecated_wrappers.py +++ /dev/null @@ -1,87 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -"""Tests for the deprecated wrapper modules in easyscience.base_classes. - -These modules now only emit log warnings and re-export from -easyscience.legacy. We test that the log messages are raised and that -the classes are still usable. -""" - -import importlib -import logging - -import pytest - -from easyscience import global_object - - -@pytest.fixture(autouse=True) -def _clear_map(): - """Clear the global object map before and after each test.""" - global_object.map._clear() - yield - global_object.map._clear() - - -# --------------------------------------------------------------------------- -# Deprecated wrapper: easyscience.base_classes.collection_base -# --------------------------------------------------------------------------- - - -def test_import_collection_base_warns(caplog: 'pytest.LogCaptureFixture'): - """Importing easyscience.base_classes.collection_base logs a WARNING.""" - import easyscience.base_classes.collection_base - - with caplog.at_level(logging.WARNING, logger='easyscience'): - importlib.reload(easyscience.base_classes.collection_base) - - assert len(caplog.records) >= 1, 'Expected at least one log record' - messages = [r.message for r in caplog.records] - combined = ' '.join(messages) - assert 'deprecated' in combined.lower() - assert 'legacy.collection_base' in combined - - -def test_collection_base_still_works_from_deprecated(caplog: 'pytest.LogCaptureFixture'): - """The class imported from the deprecated wrapper still works.""" - with caplog.at_level(logging.ERROR, logger='easyscience'): - from easyscience.base_classes.collection_base import CollectionBase # noqa: F811 - - from easyscience import Parameter - - p = Parameter(1.0, display_name='p1') - coll = CollectionBase('test', p) - assert len(coll) == 1 - assert coll[0].display_name == 'p1' - - -# --------------------------------------------------------------------------- -# Deprecated wrapper: easyscience.base_classes.obj_base -# --------------------------------------------------------------------------- - - -def test_import_obj_base_warns(caplog: 'pytest.LogCaptureFixture'): - """Importing easyscience.base_classes.obj_base logs a WARNING.""" - import easyscience.base_classes.obj_base - - with caplog.at_level(logging.WARNING, logger='easyscience'): - importlib.reload(easyscience.base_classes.obj_base) - - assert len(caplog.records) >= 1, 'Expected at least one log record' - messages = [r.message for r in caplog.records] - combined = ' '.join(messages) - assert 'deprecated' in combined.lower() - assert 'legacy.obj_base' in combined - - -def test_obj_base_still_works_from_deprecated(caplog: 'pytest.LogCaptureFixture'): - """The class imported from the deprecated wrapper still works.""" - with caplog.at_level(logging.ERROR, logger='easyscience'): - from easyscience.base_classes.obj_base import ObjBase # noqa: F811 - - from easyscience import Parameter - - p = Parameter(1.0, display_name='p1') - obj = ObjBase('test', p1=p) - assert obj.p1.value == 1.0 diff --git a/tests/unit/base_classes/test_obj_base.py b/tests/unit/base_classes/test_obj_base.py deleted file mode 100644 index 8b95c96d..00000000 --- a/tests/unit/base_classes/test_obj_base.py +++ /dev/null @@ -1,505 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from contextlib import contextmanager -from copy import copy -from typing import ClassVar -from typing import List -from typing import Optional -from typing import Type -from typing import Union - -import numpy as np -import pytest - -import easyscience -from easyscience import DescriptorNumber -from easyscience import ObjBase -from easyscience import Parameter -from easyscience import global_object -from easyscience.io import SerializerDict - - -@pytest.fixture -def clear(): - global_object.map._clear() - - -@pytest.fixture -def setup_pars(): - d = { - 'name': 'test', - 'par1': Parameter(0.1, fixed=True, display_name='p1'), - 'des1': DescriptorNumber(0.1, display_name='d1'), - 'par2': Parameter(0.1, display_name='p2'), - 'des2': DescriptorNumber(0.1, display_name='d2'), - 'par3': Parameter(0.1, display_name='p3'), - } - return d - - -@contextmanager -def not_raises(expected_exception: Union[Type[BaseException], List[Type[BaseException]]]): - try: - yield - except expected_exception: - raise pytest.fail( - 'Did raise exception {0} when it should not.'.format(repr(expected_exception)) - ) - except Exception as err: - raise pytest.fail('An unexpected exception {0} raised.'.format(repr(err))) - - -#: ``ObjBase`` keys positional components on ``arg.name`` (obj_base.py:71), which -#: descriptors no longer have. Pass descriptors as keyword arguments instead. -_positional_descriptors_unsupported = pytest.mark.xfail( - strict=True, - raises=AttributeError, - reason='legacy ObjBase keys positional components on the removed descriptor `name` (#308/#292)', -) - - -@pytest.mark.parametrize( - 'a, kw', - [ - ([], ['par1']), - pytest.param(['par1'], [], marks=_positional_descriptors_unsupported), - pytest.param(['par1'], ['par2'], marks=_positional_descriptors_unsupported), - pytest.param( - ['par1', 'des1'], ['par2', 'des2'], marks=_positional_descriptors_unsupported - ), - ], -) -def test_ObjBase_create(setup_pars: dict, a: List[str], kw: List[str]): - name = setup_pars['name'] - args = [] - for key in a: - args.append(setup_pars[key]) - kwargs = {} - for key in kw: - kwargs[key] = setup_pars[key] - base = ObjBase(name, None, *args, **kwargs) - assert base.name == name - for key in a: - item = getattr(base, setup_pars[key].display_name) - assert isinstance(item, setup_pars[key].__class__) - - -def test_ObjBase_copy(setup_pars: dict): - # When - name = setup_pars['name'] - kwargs = {key: setup_pars[key] for key in ['par1', 'des1', 'par2', 'des2']} - base = ObjBase(name, None, **kwargs) - - # Then - base_copy = copy(base) - - # Expect - assert base_copy.name == name - assert base_copy.unique_name != base.unique_name - - for key in kwargs: - item = getattr(base_copy, key) - assert isinstance(item, setup_pars[key].__class__) - assert item.unique_name != setup_pars[key].unique_name - assert item.value == setup_pars[key].value - assert item.display_name == setup_pars[key].display_name - - -def test_ObjBase_get(setup_pars: dict): - name = setup_pars['name'] - explicit_name1 = 'par1' - explicit_name2 = 'par2' - kwargs = { - setup_pars[explicit_name1].display_name: setup_pars[explicit_name1], - setup_pars[explicit_name2].display_name: setup_pars[explicit_name2], - } - obj = ObjBase(name, **kwargs) - with not_raises(AttributeError): - p1: Parameter = obj.p1 - with not_raises(AttributeError): - p2: Parameter = obj.p2 - - -def test_ObjBase_set(setup_pars: dict): - name = setup_pars['name'] - explicit_name1 = 'par1' - kwargs = { - setup_pars[explicit_name1].display_name: setup_pars[explicit_name1], - } - obj = ObjBase(name, **kwargs) - new_value = 5.0 - with not_raises([AttributeError, ValueError]): - obj.p1 = new_value - assert obj.p1.value == new_value - - -def test_ObjBase_get_parameters(setup_pars: dict): - name = setup_pars['name'] - del setup_pars['name'] - obj = ObjBase(name, **setup_pars) - pars = obj.get_fit_parameters() - assert isinstance(pars, list) - assert len(pars) == 2 - par_names = [par.display_name for par in pars] - assert 'p2' in par_names - assert 'p3' in par_names - - -def test_ObjBase_fit_objects(setup_pars: dict): - pass - - -def test_ObjBase_to_dict(clear, setup_pars: dict): - name = setup_pars['name'] - del setup_pars['name'] - obj = ObjBase(name, **setup_pars) - obtained = obj.to_dict() - assert isinstance(obtained, dict) - expected = { - '@module': 'easyscience.legacy.obj_base', - '@class': 'ObjBase', - '@version': easyscience.__version__, - 'name': 'test', - 'par1': { - '@module': Parameter.__module__, - '@class': Parameter.__name__, - '@version': easyscience.__version__, - 'display_name': 'p1', - 'value': 0.1, - 'variance': 0.0, - 'min': -np.inf, - 'max': np.inf, - 'fixed': True, - 'unit': 'dimensionless', - }, - 'des1': { - '@module': DescriptorNumber.__module__, - '@class': DescriptorNumber.__name__, - '@module': DescriptorNumber.__module__, - '@class': DescriptorNumber.__name__, - '@version': easyscience.__version__, - 'value': 0.1, - 'unit': 'dimensionless', - 'description': '', - 'url': '', - 'display_name': 'd1', - }, - 'par2': { - '@module': Parameter.__module__, - '@class': Parameter.__name__, - '@version': easyscience.__version__, - 'display_name': 'p2', - 'value': 0.1, - 'variance': 0.0, - 'min': -np.inf, - 'max': np.inf, - 'fixed': False, - 'unit': 'dimensionless', - }, - 'des2': { - '@module': DescriptorNumber.__module__, - '@class': DescriptorNumber.__name__, - '@module': DescriptorNumber.__module__, - '@class': DescriptorNumber.__name__, - '@version': easyscience.__version__, - 'value': 0.1, - 'unit': 'dimensionless', - 'description': '', - 'url': '', - 'display_name': 'd2', - }, - 'par3': { - '@module': Parameter.__module__, - '@class': Parameter.__name__, - '@version': easyscience.__version__, - 'display_name': 'p3', - 'value': 0.1, - 'variance': 0.0, - 'min': -np.inf, - 'max': np.inf, - 'fixed': False, - 'unit': 'dimensionless', - }, - } - - def check_dict(check, item): - if isinstance(check, dict) and isinstance(item, dict): - if '@module' in item.keys(): - with not_raises([ValueError, AttributeError]): - global_object.map._clear() - this_obj = SerializerDict().decode(item) - - for key in check.keys(): - assert key in item.keys() - check_dict(check[key], item[key]) - else: - assert isinstance(item, type(check)) - assert item == check - - check_dict(expected, obtained) - - -def test_ObjBase_dict_roundtrip(clear, setup_pars: dict): - # When - name = setup_pars['name'] - del setup_pars['name'] - obj = ObjBase(name, **setup_pars, unique_name='special_name') - obj_dict = obj.to_dict() - - global_object.map._clear() - - # Then - new_obj = ObjBase.from_dict(obj_dict) - - # Expect - new_obj_dict = new_obj.to_dict() - assert obj_dict == new_obj_dict - - -def test_ObjBase_dir(setup_pars): - name = setup_pars['name'] - del setup_pars['name'] - obj = ObjBase(name, **setup_pars) - expected = [ - 'encode', - 'decode', - 'to_dict', - 'des1', - 'des2', - 'from_dict', - 'generate_bindings', - 'get_fit_parameters', - 'get_parameters', - 'interface', - 'unique_name', - 'name', - 'par1', - 'par2', - 'par3', - 'switch_interface', - 'user_data', - ] - obtained = dir(obj) - print(obtained) - print(expected) - assert len(obtained) == len(expected) - assert obtained == sorted(obtained) - assert len(set(expected).difference(set(obtained))) == 0 - - -def test_ObjBase_get_parameters(setup_pars): - name = setup_pars['name'] - del setup_pars['name'] - obj = ObjBase(name, **setup_pars) - pars = obj.get_parameters() - assert len(pars) == 3 - - -def test_ObjBase_get_parameters_nested(setup_pars): - name = setup_pars['name'] - del setup_pars['name'] - obj = ObjBase(name, **setup_pars) - - name2 = name + '_2' - obj2 = ObjBase(name2, obj=obj, **setup_pars) - - pars = obj2.get_parameters() - assert len(pars) == 6 - - pars = obj.get_parameters() - assert len(pars) == 3 - - -def test_ObjBase_get_fit_parameters(setup_pars): - name = setup_pars['name'] - del setup_pars['name'] - obj = ObjBase(name, **setup_pars) - pars = obj.get_fit_parameters() - assert len(pars) == 2 - - -def test_ObjBase_get_fit_parameters_nested(setup_pars): - name = setup_pars['name'] - del setup_pars['name'] - obj = ObjBase(name, **setup_pars) - - name2 = name + '_2' - obj2 = ObjBase(name2, obj=obj, **setup_pars) - - pars = obj2.get_fit_parameters() - assert len(pars) == 4 - - pars = obj.get_fit_parameters() - assert len(pars) == 2 - - -def test_ObjBase__add_component(setup_pars): - name = setup_pars['name'] - del setup_pars['name'] - obj = ObjBase(name, **setup_pars) - - p = Parameter(1, display_name='added_par') - new_item_name = 'Added' - obj._add_component(new_item_name, p) - - assert hasattr(obj, new_item_name) - a = getattr(obj, new_item_name) - assert isinstance(a, Parameter) - - -def test_ObjBase_name(setup_pars): - name = setup_pars['name'] - del setup_pars['name'] - obj = ObjBase(name, **setup_pars) - assert obj.name == name - - -def test_Base_GETSET(): - class A(ObjBase): - def __init__(self, a: Parameter): - super(A, self).__init__('a', a=a) - - @classmethod - def from_pars(cls, a: float): - return cls(a=Parameter(a, display_name='a')) - - a_start = 5 - a_end = 10 - a = A.from_pars(a_start) - graph = a._global_object.map - - assert a.a.value == a_start - assert len(graph.get_edges(a)) == 1 - - setattr(a, 'a', a_end) - assert a.a.value == a_end - assert len(graph.get_edges(a)) == 1 - - -def test_Base_GETSET(): - class A(ObjBase): - def __init__(self, a: Parameter): - super(A, self).__init__('a', a=a) - b = 0 - - @classmethod - def from_pars(cls, a: float): - return cls(a=Parameter(a, display_name='a')) - - a = A.from_pars(5) - b_new = 10 - a.b = b_new - assert a.b == b_new - - -def test_Base_GETSET_v2(): - class A(ObjBase): - a: ClassVar[Parameter] - - def __init__(self, a: Parameter): - super(A, self).__init__('a', a=a) - - @classmethod - def from_pars(cls, a: float): - return cls(a=Parameter(a, display_name='a')) - - a_start = 5 - a_end = 10 - a = A.from_pars(a_start) - graph = a._global_object.map - - assert a.a.value == a_start - assert len(graph.get_edges(a)) == 1 - - setattr(a, 'a', a_end) - assert a.a.value == a_end - assert len(graph.get_edges(a)) == 1 - - -def test_Base_GETSET_v3(): - class A(ObjBase): - a: ClassVar[Parameter] - - def __init__(self, a: Parameter): - super(A, self).__init__('a', a=a) - - @classmethod - def from_pars(cls, a: float): - return cls(a=Parameter(a, display_name='a')) - - a_start = 5 - a_end = 10 - a = A.from_pars(a_start) - graph = a._global_object.map - - assert a.a.value == a_start - assert len(graph.get_edges(a)) == 1 - a_ = Parameter(a_end, display_name='a') - assert a.a.unique_name in graph.get_edges(a) - a__ = a.a - - setattr(a, 'a', a_) - assert a.a.value == a_end - assert len(graph.get_edges(a)) == 1 - assert a_.unique_name in graph.get_edges(a) - assert a__.unique_name not in graph.get_edges(a) - - -def test_BaseCreation(): - class A(ObjBase): - def __init__(self, a: Optional[Union[Parameter, float]] = None): - super(A, self).__init__('A', a=Parameter(1.0, display_name='a')) - if a is not None: - self.a = a - - a = A() - assert a.a.value == 1.0 - a = A(2.0) - assert a.a.value == 2.0 - a = A(Parameter(3.0, display_name='a')) - assert a.a.value == 3.0 - a.a = 4.0 - assert a.a.value == 4.0 - - class B(ObjBase): - def __init__(self, b: Optional[Union[A, Parameter, float]] = None): - super(B, self).__init__('B', b=A()) - if b is not None: - if isinstance(b, (float, Parameter)): - b = A(b) - self.b = b - - b = B() - assert b.b.a.value == 1.0 - b = B(2.0) - assert b.b.a.value == 2.0 - b = B(A(3.0)) - assert b.b.a.value == 3.0 - b.b.a = 4.0 - assert b.b.a.value == 4.0 - - -def test_unique_name_generator(clear): - # When Then - test_obj = ObjBase(name='test') - # Expect - assert test_obj.unique_name == 'ObjBase_0' - - -def test_unique_name_change(clear): - # When - test_obj = ObjBase(name='test') - # Then - test_obj.unique_name = 'test' - # Expect - assert test_obj.unique_name == 'test' - - -@pytest.mark.parametrize('input', [2, 2.0, [2], {2}, None]) -def test_unique_name_change_exception(input): - # When - test_obj = ObjBase(name='test') - # Then Expect - with pytest.raises(TypeError): - test_obj.unique_name = input diff --git a/tests/unit/fitting/test_multi_fitter.py b/tests/unit/fitting/test_multi_fitter.py index fb2e02d2..48f9ef15 100644 --- a/tests/unit/fitting/test_multi_fitter.py +++ b/tests/unit/fitting/test_multi_fitter.py @@ -7,7 +7,6 @@ import numpy as np import pytest -from easyscience import ObjBase from easyscience import Parameter from easyscience import global_object from easyscience.base_classes import EasyList @@ -42,21 +41,6 @@ def __call__(self, x): return self.m.value * x + self.c.value -class LegacyLine(ObjBase): - """Deprecated-hierarchy model; no longer accepted by MultiFitter.""" - - m: Parameter - c: Parameter - - def __init__(self, m_val: float, c_val: float): - super().__init__( - 'line', m=Parameter(m_val, display_name='m'), c=Parameter(c_val, display_name='c') - ) - - def __call__(self, x): - return self.m.value * x + self.c.value - - class TestMultiFitter: @pytest.fixture def multi_fitter(self, monkeypatch): @@ -226,7 +210,7 @@ def test_weights_all_none_returns_none(self): assert len(dims) == 2 # =================================================================== - # The EasyList container replacing the deprecated CollectionBase + # The EasyList container # =================================================================== def test_container_is_an_easy_list(self): @@ -261,15 +245,15 @@ def test_rejects_foreign_object(self): MultiFitter([Line(1.0, 0.5), 'not a model'], [None, None]) def test_rejects_bare_parameter(self): - """CollectionBase accepted bare parameters; the ModelBase-gated - EasyList does not (a Parameter is a NewBase, but EasyList only - harvests parameters from ModelBase members).""" + """The ModelBase-gated EasyList rejects bare parameters (a + Parameter is a NewBase, but EasyList only harvests parameters + from ModelBase members).""" model = Line(1.0, 0.5) with pytest.raises(TypeError, match='Items must be one of'): MultiFitter([model, Parameter(1.0, display_name='p')], [model, None]) def test_flattens_nested_list(self): - """Nested lists are flattened, as CollectionBase did.""" + """Nested lists are flattened.""" models = [Line(1.0, 0.5), Line(2.0, 1.5)] container = MultiFitter([models[0], [models[1]]], models).fit_object @@ -309,7 +293,7 @@ def test_none_arguments_are_treated_as_empty(self): assert mf.fit_function is None def test_accepts_tuple_arguments(self): - """Any sequence works thanks to *-unpacking, as with CollectionBase.""" + """Any sequence works thanks to *-unpacking.""" models = (Line(1.0, 0.5), Line(2.0, 1.5)) mf = MultiFitter(models, models) diff --git a/tests/unit/global_object/test_integration_comprehensive.py b/tests/unit/global_object/test_integration_comprehensive.py index 13424351..4781f557 100644 --- a/tests/unit/global_object/test_integration_comprehensive.py +++ b/tests/unit/global_object/test_integration_comprehensive.py @@ -6,13 +6,30 @@ import pytest -from easyscience import ObjBase +from easyscience import ModelBase from easyscience import Parameter from easyscience import global_object from easyscience.global_object.global_object import GlobalObject +from easyscience.models.polynomial import Polynomial from easyscience.variable import DescriptorBool +class Container(ModelBase): + """ModelBase holding arbitrary named children, linked in the global map.""" + + 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', {}) + if key in children: + return children[key] + raise AttributeError(key) + + class TestGlobalObjectIntegration: """Integration tests for GlobalObject ecosystem""" @@ -73,16 +90,16 @@ def test_parameter_lifecycle_integration(self, clear_all): global_obj.map.prune(param_name) assert param_name not in global_obj.map.vertices() - def test_objbase_parameter_relationship(self, clear_all): - """Test ObjBase containing parameters integration""" + def test_model_parameter_relationship(self, clear_all): + """Test a model containing parameters integration""" # Given global_obj = GlobalObject() param1 = Parameter(display_name='length', value=10.0, unit='m') param2 = Parameter(display_name='width', value=5.0, unit='m') - # When - Create ObjBase with parameters - obj = ObjBase(name='rectangle', length=param1, width=param2) + # 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) @@ -94,7 +111,7 @@ def test_objbase_parameter_relationship(self, clear_all): assert param1.unique_name in obj_edges assert param2.unique_name in obj_edges - # When - Modify through ObjBase + # When - Modify through the container if not global_obj.stack: global_obj.instantiate_stack() global_obj.stack.enabled = True @@ -126,11 +143,11 @@ def test_unique_name_generation_integration(self, clear_all): assert name == f'Parameter_{i}' # When - Create mixed object types - obj = ObjBase(name='test_obj') + obj = Container(display_name='test_obj') desc = DescriptorBool(display_name='test_desc', value=True) # Then - Should not interfere with each other's naming - assert obj.unique_name == 'ObjBase_0' + assert obj.unique_name == 'Container_0' assert desc.unique_name == 'DescriptorBool_0' def test_map_vertex_type_management(self, clear_all): @@ -170,7 +187,7 @@ def test_weak_reference_cleanup_integration(self, clear_all): # When - Create objects param1 = Parameter(display_name='temp1', value=1.0) param2 = Parameter(display_name='temp2', value=2.0) - obj = ObjBase(name='temp_obj', param1=param1, param2=param2) + obj = Container(display_name='temp_obj', param1=param1, param2=param2) param1_name = param1.unique_name param2_name = param2.unique_name @@ -210,7 +227,7 @@ def test_complex_undo_redo_scenario(self, clear_all): width = Parameter(display_name='width', value=5.0, unit='m') height = Parameter(display_name='height', value=3.0, unit='m') - box = ObjBase(name='box', length=length, width=width, height=height) + box = Container(display_name='box', length=length, width=width, height=height) # When - Perform multiple operations in a macro global_obj.stack.beginMacro('Resize box') @@ -253,8 +270,8 @@ def test_map_path_finding_integration(self, clear_all): # Create a hierarchy: container -> sub_container -> parameter param = Parameter(display_name='value', value=42.0) - sub_container = ObjBase(name='sub', value=param) - main_container = ObjBase(name='main', sub=sub_container) + 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) @@ -279,7 +296,7 @@ def test_map_connectivity_with_isolated_objects(self, clear_all): # When - Create connected objects param1 = Parameter(display_name='connected1', value=1.0) param2 = Parameter(display_name='connected2', value=2.0) - container = ObjBase(name='container', p1=param1, p2=param2) + container = Container(display_name='container', p1=param1, p2=param2) # Then - Map should be connected # TODO: Depending on implementation, connectivity might vary @@ -322,7 +339,7 @@ def test_memory_pressure_simulation(self, clear_all): objects = [] for i in range(100): param = Parameter(display_name=f'param_{i}', value=float(i)) - obj = ObjBase(name=f'obj_{i}', param=param) + obj = Container(display_name=f'obj_{i}', param=param) objects.append((param, obj)) initial_vertex_count = len(global_obj.map.vertices()) @@ -356,7 +373,7 @@ def test_serialization_integration_with_global_state(self, clear_all): # Create objects param = Parameter(display_name='test_param', value=123.45, unit='kg') - obj = ObjBase(name='test_obj', param=param) + obj = Polynomial(display_name='test_obj', coefficients=[param]) original_vertex_count = len(global_obj.map.vertices()) @@ -370,7 +387,7 @@ def test_serialization_integration_with_global_state(self, clear_all): # When - Deserialize objects new_param = Parameter.from_dict(param_dict) - new_obj = ObjBase.from_dict(obj_dict) + new_obj = Polynomial.from_dict(obj_dict) # Then - Should be registered in global map again assert len(global_obj.map.vertices()) >= 2 @@ -381,6 +398,8 @@ def test_serialization_integration_with_global_state(self, clear_all): assert new_param.display_name == param.display_name assert new_param.value == param.value assert new_param.unit == param.unit + assert new_obj.display_name == 'test_obj' + assert new_obj.coefficients[0].value == param.value def test_debug_mode_integration(self, clear_all): """Test debug mode behavior across the system""" diff --git a/tests/unit/global_object/test_map.py b/tests/unit/global_object/test_map.py index 259fa0dc..45ba82d3 100644 --- a/tests/unit/global_object/test_map.py +++ b/tests/unit/global_object/test_map.py @@ -6,7 +6,7 @@ import pytest -from easyscience import ObjBase +from easyscience import ModelBase from easyscience import Parameter from easyscience import global_object from easyscience.global_object.map import Map @@ -137,7 +137,7 @@ def clear(self): @pytest.fixture def base_object(self): - return ObjBase(name='test') + return ModelBase(display_name='test') @pytest.fixture def parameter_object(self): @@ -160,7 +160,7 @@ def test_clear(self, clear, base_object): def test_weakref(self, clear): # When - test_obj = ObjBase(name='test') + test_obj = ModelBase(display_name='test') assert len(global_object.map._store) == 1 assert len(global_object.map._Map__type_dict) == 1 # Then @@ -183,7 +183,7 @@ def test_get_item_by_key(self, clear, base_object, parameter_object): assert global_object.map.get_item_by_key(parameter_object.unique_name) == parameter_object @pytest.mark.parametrize( - 'cls, kwargs', [(ObjBase, {'name': 'test'}), (Parameter, {'value': 2.0})] + 'cls, kwargs', [(ModelBase, {'display_name': 'test'}), (Parameter, {'value': 2.0})] ) def test_identical_unique_names_exception(self, clear, cls, kwargs): # When @@ -194,13 +194,13 @@ def test_identical_unique_names_exception(self, clear, cls, kwargs): def test_unique_name_change_still_in_map(self, clear, base_object, parameter_object): # When - assert global_object.map.get_item_by_key('ObjBase_0') == base_object + assert global_object.map.get_item_by_key('ModelBase_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('ObjBase_0') == base_object + assert global_object.map.get_item_by_key('ModelBase_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 @@ -210,12 +210,14 @@ def test_add_vertex_duplicate_name_error(self, clear, base_object): # When/Then with pytest.raises(ValueError, match='already exists'): # Try to add another object with same unique_name - duplicate_obj = ObjBase(name='duplicate', unique_name=base_object.unique_name) + duplicate_obj = ModelBase( + 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 = ObjBase(name='test') + obj = ModelBase(display_name='test') # When - Object is automatically added during construction # Then @@ -251,7 +253,7 @@ def test_find_type_unknown_object(self, clear): def test_returned_objs_access_safe_under_modification(self, clear): """Ensure accessing returned_objs doesn't raise when entries change size during iteration.""" - objs = [ObjBase(name=f'race_{i}') for i in range(8)] + objs = [ModelBase(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') @@ -382,7 +384,7 @@ def test_edges_generation(self, clear, base_object, parameter_object): def test_type_filtering_properties(self, clear): """Test type filtering properties""" # Given - obj1 = ObjBase(name='obj1') # 'created' type + obj1 = ModelBase(display_name='obj1') # 'created' type obj2 = Parameter(display_name='obj2', value=1) # 'created' type global_object.map.change_type(obj1, 'argument') @@ -495,7 +497,7 @@ def test_get_item_by_key_not_found(self, clear): def test_clear_with_finalizers(self, clear): """Test clearing map properly calls finalizers""" # Given - obj = ObjBase(name='test') + obj = ModelBase(display_name='test') original_count = len(global_object.map.vertices()) # When @@ -693,7 +695,7 @@ def test_prune_vertex_from_edge_parent_not_in_map(self, clear, parameter_object) def test_created_internal_property(self, clear): """Test created_internal property.""" # Given - obj = ObjBase(name='internal_obj') + obj = ModelBase(display_name='internal_obj') global_object.map.change_type(obj, 'created_internal') # When diff --git a/tests/unit/global_object/test_undo_redo.py b/tests/unit/global_object/test_undo_redo.py index 23abd276..c2d32f22 100644 --- a/tests/unit/global_object/test_undo_redo.py +++ b/tests/unit/global_object/test_undo_redo.py @@ -8,13 +8,38 @@ from easyscience import DescriptorNumber from easyscience import Fitter -from easyscience import ObjBase +from easyscience import ModelBase from easyscience import Parameter -from easyscience.base_classes import CollectionBase from easyscience.variable import DescriptorBool from easyscience.variable import DescriptorStr +class Line(ModelBase): + def __init__(self, m: Parameter, c: Parameter): + super().__init__() + self._m = m + self._c = c + + @property + def m(self) -> Parameter: + return self._m + + @m.setter + def m(self, value: float) -> None: + self._m.value = value + + @property + def c(self) -> Parameter: + return self._c + + @c.setter + def c(self, value: float) -> None: + self._c.value = value + + def __call__(self, x: np.ndarray) -> np.ndarray: + return self.m.value * x + self.c.value + + def createSingleObjs(idx): alphabet = 'abcdefghijklmnopqrstuvwxyz' reps = math.floor(idx / len(alphabet)) + 1 @@ -150,92 +175,15 @@ def test_Parameter_Bounds_UndoRedo(): assert parameter.max == np.inf -def test_ObjBaseUndoRedo(): - objs = {obj.display_name: obj for obj in [createSingleObjs(idx) for idx in range(5)]} - name = 'test' - obj = ObjBase(name, **objs) - name2 = 'best' - - # Test name - # assert not doUndoRedo(obj, 'name', name2) +def test_ModelBaseUndoRedo(): + line = Line(m=Parameter(1.0, display_name='m'), c=Parameter(2.0, display_name='c')) - # Test setting value - for b_obj in objs.values(): - e = doUndoRedo(obj, b_obj.display_name, b_obj.value + 1, 'value') + # Test setting value through the model's property setters + for attr in ('m', 'c'): + e = doUndoRedo(line, attr, getattr(line, attr).value + 1, 'value') assert not e -def test_CollectionBaseUndoRedo(): - objs = [createSingleObjs(idx) for idx in range(5)] - name = 'test' - obj = CollectionBase(name, *objs) - name2 = 'best' - - # assert not doUndoRedo(obj, 'name', name2) - - from easyscience import global_object - - global_object.stack.enabled = True - - original_length = len(obj) - p = Parameter(50, display_name='slip_in') - idx = 2 - obj.insert(idx, p) - assert len(obj) == original_length + 1 - objs.insert(idx, p) - for item, obj_r in zip(obj, objs): - assert item == obj_r - - # Test inserting items - global_object.stack.undo() - assert len(obj) == original_length - _ = objs.pop(idx) - for item, obj_r in zip(obj, objs): - assert item == obj_r - global_object.stack.redo() - assert len(obj) == original_length + 1 - objs.insert(idx, p) - for item, obj_r in zip(obj, objs): - assert item == obj_r - - # Test Del Items - del obj[idx] - del objs[idx] - assert len(obj) == original_length - for item, obj_r in zip(obj, objs): - assert item == obj_r - global_object.stack.undo() - assert len(obj) == original_length + 1 - objs.insert(idx, p) - for item, obj_r in zip(obj, objs): - assert item == obj_r - del objs[idx] - global_object.stack.redo() - assert len(obj) == original_length - for item, obj_r in zip(obj, objs): - assert item == obj_r - - # Test Place Item - old_item = objs[idx] - objs[idx] = p - obj[idx] = p - assert len(obj) == original_length - for item, obj_r in zip(obj, objs): - assert item == obj_r - global_object.stack.undo() - for i in range(len(obj)): - if i == idx: - item = old_item - else: - item = objs[i] - assert obj[i] == item - global_object.stack.redo() - for item, obj_r in zip(obj, objs): - assert item == obj_r - - global_object.stack.enabled = False - - def test_UndoRedoMacros(): items = [createSingleObjs(idx) for idx in range(5)] offset = 5 @@ -273,30 +221,11 @@ def test_fittingUndoRedo(fit_engine): weights = np.ones_like(x) dy = np.random.rand(*x.shape) - class Line(ObjBase): - def __init__(self, m: Parameter, c: Parameter): - super(Line, self).__init__('basic_line', m=m, c=c) - - @classmethod - def default(cls): - m = Parameter(m_value, display_name='m') - c = Parameter(c_value, display_name='c') - return cls(m=m, c=c) - - @classmethod - def from_pars(cls, m_value: float, c_value: float): - m = Parameter(m_value, display_name='m') - c = Parameter(c_value, display_name='c') - return cls(m=m, c=c) - - def __call__(self, x: np.ndarray) -> np.ndarray: - return self.m.value * x + self.c.value - - l1 = Line.default() + l1 = Line(m=Parameter(m_value, display_name='m'), c=Parameter(c_value, display_name='c')) m_sp = 4 c_sp = -3 - l2 = Line.from_pars(m_sp, c_sp) + l2 = Line(m=Parameter(m_sp, display_name='m'), c=Parameter(c_sp, display_name='c')) l2.m.fixed = False l2.c.fixed = False diff --git a/tests/unit/io/test_serializer_base.py b/tests/unit/io/test_serializer_base.py index 0f479e2e..43c9fe67 100644 --- a/tests/unit/io/test_serializer_base.py +++ b/tests/unit/io/test_serializer_base.py @@ -35,7 +35,6 @@ def __init__( self.name = name self.value = value self.optional_param = optional_param - self._kwargs = kwargs self.unique_name = f'mock_{name}' self._global_object = True @@ -424,17 +423,17 @@ def test_recursive_encoder_builtin_encode_method(self, serializer): def test_recursive_encoder_with_mutable_sequence(self, serializer, clear): """Test _recursive_encoder with MutableSequence objects""" - from easyscience.base_classes import CollectionBase + from easyscience.base_classes import EasyList d0 = DescriptorNumber(0, display_name='a') # type: ignore d1 = DescriptorNumber(1, display_name='b') # type: ignore - collection = CollectionBase('test_collection', d0, d1) + collection = EasyList(d0, d1) result = serializer._recursive_encoder(collection) assert isinstance(result, dict) - assert result['@class'] == 'CollectionBase' - assert 'data' in result + assert result['@class'] == 'EasyList' + assert len(result['data']) == 2 @patch('easyscience.io.serializer_base.import_module') def test_convert_to_dict_no_version(self, mock_import, serializer, clear): @@ -473,24 +472,6 @@ def __init__(self, name: str, missing_param: str = 'default'): with pytest.raises(NotImplementedError, match='Unable to automatically determine to_dict'): serializer._convert_to_dict(obj) - def test_convert_to_dict_with_kwargs_attribute(self, serializer, clear): - """Test _convert_to_dict with _kwargs attribute handling""" - - class MockObjWithKwargs(SerializerComponent): - def __init__(self, name: str, value: int): - self.name = name - self.value = value - self.unique_name = f'kwargs_{name}' - self._global_object = True - # Set up _kwargs to test the kwargs handling path - self._kwargs = {'extra_param': 'extra_value'} - - obj = MockObjWithKwargs('test', 42) - result = serializer._convert_to_dict(obj) - - # The extra_param from _kwargs should be included - assert result['extra_param'] == 'extra_value' - def test_convert_to_dict_varargs_handling(self, serializer, clear): """Test _convert_to_dict with varargs (*args) handling""" diff --git a/tests/unit/io/test_serializer_dict.py b/tests/unit/io/test_serializer_dict.py index f725c261..aa299175 100644 --- a/tests/unit/io/test_serializer_dict.py +++ b/tests/unit/io/test_serializer_dict.py @@ -7,7 +7,7 @@ import pytest from easyscience import DescriptorNumber -from easyscience import ObjBase +from easyscience import Parameter from easyscience import global_object from easyscience.io.serializer_dict import SerializerDict @@ -103,19 +103,20 @@ def test_group_encode(): d0 = DescriptorNumber(0, display_name='a') d1 = DescriptorNumber(1, display_name='b') - from easyscience.base_classes import CollectionBase + from easyscience.base_classes import EasyList - b = CollectionBase('test', d0, d1) + b = EasyList(d0, d1) d = b.to_dict() assert isinstance(d['data'], list) def test_group_encode2(): - d0 = DescriptorNumber(0, display_name='a') - d1 = DescriptorNumber(1, display_name='b') + p0 = Parameter(0, display_name='a') + p1 = Parameter(1, display_name='b') - from easyscience.base_classes import CollectionBase + from easyscience.models.polynomial import Polynomial - b = ObjBase('outer', b=CollectionBase('test', d0, d1)) + b = Polynomial(display_name='outer', coefficients=[p0, p1]) d = b.to_dict() - assert isinstance(d['b'], dict) + assert isinstance(d['coefficients'], dict) + assert len(d['coefficients']['data']) == 2 diff --git a/tests/unit/legacy/__init__.py b/tests/unit/legacy/__init__.py deleted file mode 100644 index b1990ec8..00000000 --- a/tests/unit/legacy/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - diff --git a/tests/unit/legacy/test_collection_base.py b/tests/unit/legacy/test_collection_base.py deleted file mode 100644 index 9583d06b..00000000 --- a/tests/unit/legacy/test_collection_base.py +++ /dev/null @@ -1,166 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -"""Tests for easyscience.legacy.collection_base.CollectionBase. - -These tests cover methods not exercised by the existing -tests/unit/base_classes/test_collection_base.py suite. -""" - -import logging - -import pytest - -from easyscience import DescriptorNumber -from easyscience import Parameter -from easyscience import global_object -from easyscience.legacy.collection_base import CollectionBase - -#: ``CollectionBase.__getitem__`` resolves a string index against ``item.name`` -#: (collection_base.py:187), which descriptors no longer have. -#: Index by position or ``unique_name`` instead. -_string_indexing_unsupported = pytest.mark.xfail( - strict=True, - raises=AttributeError, - reason='legacy CollectionBase string indexing uses the removed descriptor `name` (#308/#292)', -) - - -@pytest.fixture(autouse=True) -def _clear_map(): - global_object.map._clear() - yield - global_object.map._clear() - - -@pytest.fixture -def setup_pars(): - return { - 'name': 'test', - 'par1': Parameter(0.1, fixed=True, display_name='p1'), - 'des1': DescriptorNumber(0.1, display_name='d1'), - 'par2': Parameter(0.2, display_name='p2'), - 'des2': DescriptorNumber(0.2, display_name='d2'), - 'par3': Parameter(0.3, display_name='p3'), - } - - -# --------------------------------------------------------------------------- -# Deprecation warning on instantiation -# --------------------------------------------------------------------------- - -def test_instantiation_emits_deprecation_warning(caplog: "pytest.LogCaptureFixture"): - """Instantiating CollectionBase should log a deprecation warning.""" - with caplog.at_level(logging.WARNING, logger='easyscience.legacy'): - CollectionBase('test') - assert len(caplog.records) >= 1 - messages = [r.message for r in caplog.records] - combined = ' '.join(messages) - assert 'deprecated' in combined.lower() - - -# --------------------------------------------------------------------------- -# sort -# --------------------------------------------------------------------------- - -def test_sort_ascending(setup_pars): - """Sort by parameter value in ascending order.""" - name = setup_pars.pop('name') - coll = CollectionBase(name, **setup_pars) - coll.sort(mapping=lambda item: item.value, reverse=False) - values = [item.value for item in coll] - assert values == sorted(values) - - -def test_sort_descending(setup_pars): - """Sort by parameter value in descending order.""" - name = setup_pars.pop('name') - coll = CollectionBase(name, **setup_pars) - coll.sort(mapping=lambda item: item.value, reverse=True) - values = [item.value for item in coll] - assert values == sorted(values, reverse=True) - - -# --------------------------------------------------------------------------- -# data property -# --------------------------------------------------------------------------- - -def test_data_property(setup_pars): - """The data property returns a tuple of stored items.""" - name = setup_pars.pop('name') - coll = CollectionBase(name, **setup_pars) - d = coll.data - assert isinstance(d, tuple) - assert len(d) == 5 - # Items should be the same objects - for item_from_data, item_from_iter in zip(d, coll): - assert item_from_data is item_from_iter - - -# --------------------------------------------------------------------------- -# __setitem__ with an EasyScience object (not a Number) -# --------------------------------------------------------------------------- - -def test_setitem_with_easyscience_object(setup_pars): - """Replace an item at an index with another EasyScience object.""" - name = setup_pars.pop('name') - coll = CollectionBase(name, **setup_pars) - - n_before = len(coll) - old_item = coll[0] - new_item = Parameter(99.0, display_name='replacement') - - coll[0] = new_item - - assert len(coll) == n_before - assert coll[0].display_name == 'replacement' - assert coll[0].value == 99.0 - # Old item should be removed from the graph - assert old_item.unique_name not in global_object.map.get_edges(coll) - - -# --------------------------------------------------------------------------- -# __getitem__ with duplicate names returns a new CollectionBase -# --------------------------------------------------------------------------- - -@_string_indexing_unsupported -def test_getitem_duplicate_names_returns_collection(setup_pars): - """When multiple items share the same name, __getitem__ returns a sub-collection.""" - name = setup_pars.pop('name') - # Add two items with the same display name - p1 = Parameter(1.0, display_name='same_name') - p2 = Parameter(2.0, display_name='same_name') - coll = CollectionBase(name, p1, p2) - - result = coll['same_name'] - assert isinstance(result, CollectionBase) - assert len(result) == 2 - - -@_string_indexing_unsupported -def test_getitem_nonexistent_name_raises(setup_pars): - """Looking up a nonexistent name raises IndexError.""" - name = setup_pars.pop('name') - coll = CollectionBase(name, **setup_pars) - - with pytest.raises(IndexError, match='Given index does not exist'): - _ = coll['nonexistent'] - - -# --------------------------------------------------------------------------- -# insert method (called by MutableSequence.append, but test edge case) -# --------------------------------------------------------------------------- - -def test_insert_at_specific_index(setup_pars): - """Insert an item at a specific index (not just via append).""" - name = setup_pars.pop('name') - coll = CollectionBase(name, **setup_pars) - - n_before = len(coll) - new_item = Parameter(42.0, display_name='inserted') - - coll.insert(2, new_item) - - assert len(coll) == n_before + 1 - assert coll[2].display_name == 'inserted' - assert coll[2].value == 42.0 diff --git a/tests/unit/legacy/test_dict.py b/tests/unit/legacy/test_dict.py deleted file mode 100644 index b1990ec8..00000000 --- a/tests/unit/legacy/test_dict.py +++ /dev/null @@ -1,3 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - diff --git a/tests/unit/legacy/test_json.py b/tests/unit/legacy/test_json.py deleted file mode 100644 index b1990ec8..00000000 --- a/tests/unit/legacy/test_json.py +++ /dev/null @@ -1,3 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - diff --git a/tests/unit/legacy/test_obj_base.py b/tests/unit/legacy/test_obj_base.py deleted file mode 100644 index 8735a440..00000000 --- a/tests/unit/legacy/test_obj_base.py +++ /dev/null @@ -1,119 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -"""Tests for easyscience.legacy.obj_base.ObjBase. - -These tests cover methods not exercised by the existing -tests/unit/base_classes/test_obj_base.py suite. -""" - -import logging -from typing import ClassVar - -import pytest - -from easyscience import Parameter -from easyscience import global_object -from easyscience.legacy.obj_base import ObjBase - - -@pytest.fixture(autouse=True) -def _clear_map(): - global_object.map._clear() - yield - global_object.map._clear() - - -# --------------------------------------------------------------------------- -# Deprecation warning on instantiation -# --------------------------------------------------------------------------- - -def test_instantiation_emits_deprecation_warning(caplog: "pytest.LogCaptureFixture"): - """Instantiating ObjBase should log a deprecation warning.""" - with caplog.at_level(logging.WARNING, logger='easyscience.legacy'): - ObjBase('test') - assert len(caplog.records) >= 1 - messages = [r.message for r in caplog.records] - combined = ' '.join(messages) - assert 'deprecated' in combined.lower() - - -# --------------------------------------------------------------------------- -# __repr__ -# --------------------------------------------------------------------------- - -def test_repr(): - """Verify __repr__ returns the expected format.""" - obj = ObjBase('my_name') - r = repr(obj) - assert "ObjBase" in r - assert "my_name" in r - - -# --------------------------------------------------------------------------- -# __setattr__ with interface bindings regeneration -# --------------------------------------------------------------------------- - -def test_setattr_with_interface_calls_generate_bindings(): - """When replacing an attribute that has an interface, generate_bindings is called.""" - from unittest.mock import Mock - - class A(ObjBase): - a: ClassVar[Parameter] - - def __init__(self, a: Parameter): - super().__init__('A', a=a) - - p1 = Parameter(1.0, display_name='a') - a = A(p1) - - # Attach a mock interface - mock_iface = Mock() - a.interface = mock_iface - - # Replace the parameter — should trigger generate_bindings via __setattr__ - p2 = Parameter(2.0, display_name='a') - a.a = p2 - - mock_iface.generate_bindings.assert_called() - - -# --------------------------------------------------------------------------- -# __setattr__ without annotation (the else branch) -# --------------------------------------------------------------------------- - -def test_setattr_without_annotation(): - """Setting a non-annotated BasedBase/DescriptorBase attribute updates graph.""" - - class A(ObjBase): - def __init__(self, p: Parameter): - super().__init__('A', p=p) - - p1 = Parameter(1.0, display_name='p') - a = A(p1) - - graph = global_object.map - edges_before = set(graph.get_edges(a)) - - # Replace the parameter with a new one - p2 = Parameter(2.0, display_name='p') - a.p = p2 - - edges_after = set(graph.get_edges(a)) - assert edges_before != edges_after - assert p2.unique_name in edges_after - assert p1.unique_name not in edges_after - - -# --------------------------------------------------------------------------- -# __setter with DescriptorBase path -# --------------------------------------------------------------------------- - -def test_setter_sets_descriptor_value(): - """When setting a Descriptor via the logged property, the descriptor's value is updated.""" - from easyscience import DescriptorNumber - - d = DescriptorNumber(0.5, display_name='d1') - obj = ObjBase('test', d1=d) - obj.d1 = 3.14 - assert obj.d1.value == 3.14 diff --git a/tests/unit/legacy/test_xml.py b/tests/unit/legacy/test_xml.py deleted file mode 100644 index b1990ec8..00000000 --- a/tests/unit/legacy/test_xml.py +++ /dev/null @@ -1,3 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - diff --git a/tests/unit/models/test_polynomial.py b/tests/unit/models/test_polynomial.py index 67b18ddb..2699babb 100644 --- a/tests/unit/models/test_polynomial.py +++ b/tests/unit/models/test_polynomial.py @@ -5,7 +5,7 @@ import pytest from easyscience import global_object -from easyscience.base_classes import CollectionBase +from easyscience.base_classes import EasyList from easyscience.models.polynomial import Polynomial from easyscience.variable import Parameter @@ -42,9 +42,9 @@ def test_Polynomial_pars(clear, coo): def test_Polynomial_default_initialization(clear): """Test Polynomial with no coefficients.""" - poly = Polynomial(name='test_poly') + poly = Polynomial(display_name='test_poly') - assert poly.name == 'test_poly' + assert poly.display_name == 'test_poly' assert len(poly.coefficients) == 0 # Test that calling the polynomial with empty coefficients works @@ -93,9 +93,9 @@ def test_Polynomial_with_mixed_coefficients(clear): assert np.allclose(poly(x), expected) -def test_Polynomial_with_CollectionBase(clear): - """Test Polynomial initialized with a CollectionBase.""" - collection = CollectionBase('coeffs') +def test_Polynomial_with_EasyList(clear): + """Test Polynomial initialized with an EasyList.""" + collection = EasyList(protected_types=Parameter) collection.append(Parameter(value=1.0, display_name='c0')) collection.append(Parameter(value=2.0, display_name='c1')) collection.append(Parameter(value=3.0, display_name='c2')) @@ -127,13 +127,13 @@ def test_Polynomial_invalid_coefficients_type(clear): Polynomial(coefficients='invalid') # Integer is not iterable, so it will fail with second error - with pytest.raises(TypeError, match='coefficients must be a list or a CollectionBase'): + with pytest.raises(TypeError, match='coefficients must be a list or an EasyList'): Polynomial(coefficients=42) def test_Polynomial_repr_no_coefficients(clear): """Test __repr__ with no coefficients.""" - poly = Polynomial(name='empty') + poly = Polynomial(display_name='empty') repr_str = repr(poly) assert 'Polynomial(empty, )' == repr_str @@ -190,3 +190,21 @@ def test_Polynomial_call_with_args_kwargs(clear): # polyval([1.0, 2.0, 3.0], x) = 1.0*x^2 + 2.0*x + 3.0 expected = np.polyval([1.0, 2.0, 3.0], x) assert np.allclose(result, expected) + + +def test_Polynomial_get_fit_parameters(clear): + """Coefficients held in the EasyList are exposed for fitting.""" + poly = Polynomial(coefficients=[1.0, 2.0]) + + assert poly.get_fit_parameters() == list(poly.coefficients) + + +def test_Polynomial_dict_round_trip(clear): + """Test that coefficients survive serialization.""" + poly = Polynomial(display_name='round_trip', coefficients=[1.0, 2.0, 3.0]) + + new_poly = Polynomial.from_dict(poly.to_dict()) + + assert new_poly.display_name == 'round_trip' + assert [c.value for c in new_poly.coefficients] == [1.0, 2.0, 3.0] + assert [c.display_name for c in new_poly.coefficients] == ['c0', 'c1', 'c2'] diff --git a/tests/unit/variable/test_parameter.py b/tests/unit/variable/test_parameter.py index 794a644a..c9a6f283 100644 --- a/tests/unit/variable/test_parameter.py +++ b/tests/unit/variable/test_parameter.py @@ -9,7 +9,7 @@ from scipp import UnitError from easyscience import DescriptorNumber -from easyscience import ObjBase +from easyscience import ModelBase from easyscience import Parameter from easyscience import global_object @@ -469,7 +469,7 @@ def test_process_dependency_unique_names_exception_not_a_descriptorNumber( ): # When normal_parameter._dependency_map = {} - base_obj = ObjBase(name='ObjBase', unique_name='base_obj') + base_obj = ModelBase(unique_name='base_obj') # Then Expect with pytest.raises( @@ -484,7 +484,7 @@ def test_process_dependency_unique_names_exception_not_a_descriptorNumber( (2, {'a': Parameter(display_name='a', value=1)}), ('2*a', ['a', Parameter(display_name='a', value=1)]), ('2*a', {4: Parameter(display_name='a', value=1)}), - ('2*a', {'a': ObjBase(name='a')}), + ('2*a', {'a': ModelBase(display_name='a')}), ], ids=[ 'dependency_expression_not_a_string', From 8c3aa5153d115d8fd1693d6491c0e78a9f293b55 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 28 Sep 2026 11:41:35 +0200 Subject: [PATCH 02/11] minor fixes for calculator binding, new coefficient container and forwarding serialization inclusions --- src/easyscience/base_classes/easy_list.py | 7 ++- .../fitting/calculators/interface_factory.py | 9 +++- src/easyscience/io/serializer_dict.py | 13 +++++- .../calculators/test_interface_factory.py | 43 +++++++++++++++++++ tests/unit/models/test_polynomial.py | 31 +++++++++++++ 5 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py index b81b69fd..2605bb36 100644 --- a/src/easyscience/base_classes/easy_list.py +++ b/src/easyscience/base_classes/easy_list.py @@ -378,7 +378,7 @@ def _convert_to_dict(self, d: dict, encoder: Any, skip: Any = None, **kwargs: An encoder : Any The serializer performing the conversion. skip : Any, default=None - Field names to skip. Unused. + Field names to skip, forwarded to each list item. **kwargs : Any Additional keyword arguments from the serializer. Unused. @@ -387,12 +387,15 @@ def _convert_to_dict(self, d: dict, encoder: Any, skip: Any = None, **kwargs: An dict The dictionary with ``protected_types`` and ``data`` added. """ + if skip is None: + skip = [] if self._protected_types != [NewBase]: d['protected_types'] = [ {'@module': cls_.__module__, '@class': cls_.__name__} for cls_ in self._protected_types ] # noqa: E501 - d['data'] = [item.to_dict() for item in self._data] + # Each item gets its own copy, as ``to_dict`` appends to ``skip`` + d['data'] = [item.to_dict(skip=list(skip)) for item in self._data] return d @classmethod diff --git a/src/easyscience/fitting/calculators/interface_factory.py b/src/easyscience/fitting/calculators/interface_factory.py index 9dd2a71a..ab7d105d 100644 --- a/src/easyscience/fitting/calculators/interface_factory.py +++ b/src/easyscience/fitting/calculators/interface_factory.py @@ -175,7 +175,9 @@ def generate_bindings(self, model: Any, *args: Any, ifun: Any = None, **kwargs: Parameters ---------- model : Any - Model whose linkable attributes should be bound. + Model whose variables should be bound. Variables are found + with ``_get_linkable_attributes()`` when the model provides + it, otherwise with ``ModelBase.get_all_variables()``. *args : Any Positional arguments reserved for interface-specific binding hooks. @@ -187,7 +189,10 @@ def generate_bindings(self, model: Any, *args: Any, ifun: Any = None, **kwargs: """ class_links = self.__interface_obj.create(model) - props = model._get_linkable_attributes() + if hasattr(model, '_get_linkable_attributes'): + props = model._get_linkable_attributes() + else: + props = model.get_all_variables() props_names = [prop.display_name for prop in props] for item in class_links: for item_key in item.name_conversion.keys(): diff --git a/src/easyscience/io/serializer_dict.py b/src/easyscience/io/serializer_dict.py index af6c1e9d..f36ee302 100644 --- a/src/easyscience/io/serializer_dict.py +++ b/src/easyscience/io/serializer_dict.py @@ -74,5 +74,16 @@ def decode(cls, d: Dict[str, Any]) -> SerializerComponent: SerializerComponent EasyScience object. """ - + if SerializerBase._is_serialized_easyscience_object(d): + # Local import to avoid a circular dependency + from ..base_classes.new_base import NewBase + + try: + cls_ = SerializerBase._import_class(d['@module'], d['@class']) + except (ImportError, ValueError): + cls_ = None + # NewBase subclasses know how to rebuild nested members + # (e.g. EasyList protected types), so defer to their from_dict. + if isinstance(cls_, type) and issubclass(cls_, NewBase): + return cls_.from_dict(d) return SerializerBase._convert_from_dict(d) diff --git a/tests/unit/fitting/calculators/test_interface_factory.py b/tests/unit/fitting/calculators/test_interface_factory.py index 23910309..40fb46a4 100644 --- a/tests/unit/fitting/calculators/test_interface_factory.py +++ b/tests/unit/fitting/calculators/test_interface_factory.py @@ -561,3 +561,46 @@ def mock_setter(link_name, **kwargs): assert len(setter_calls) == 1 assert setter_calls[0] == ('test_link', {'internal_param1': 'test_value'}) + + +def test_generate_bindings_with_model_base(): + """Bindings are generated for a real ModelBase via get_all_variables.""" + from easyscience.models.polynomial import Polynomial + + global_object.map._clear() + storage = {} + + def getter(link_name, key): + return storage[link_name][key] + + def setter(link_name, **kwargs): + storage.setdefault(link_name, {}).update(kwargs) + + class Calculator: + name = 'Calculator' + + def create(self, model): + return [ + ItemContainer( + link_name='poly', + name_conversion={'c0': 'a', 'c1': 'b'}, + getter_fn=getter, + setter_fn=setter, + ) + ] + + def fit_func(self, *args, **kwargs): + return None + + poly = Polynomial(coefficients=[1.0, 2.0]) + factory = InterfaceFactoryTemplate([Calculator]) + + factory.generate_bindings(poly) + + # Current values are pushed to the calculator on binding + assert storage['poly'] == {'a': 1.0, 'b': 2.0} + + # And later changes are forwarded + poly.coefficients[1].value = 5.0 + assert storage['poly']['b'] == 5.0 + global_object.map._clear() diff --git a/tests/unit/models/test_polynomial.py b/tests/unit/models/test_polynomial.py index 2699babb..16eaea0f 100644 --- a/tests/unit/models/test_polynomial.py +++ b/tests/unit/models/test_polynomial.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +import copy + import numpy as np import pytest @@ -208,3 +210,32 @@ def test_Polynomial_dict_round_trip(clear): assert new_poly.display_name == 'round_trip' assert [c.value for c in new_poly.coefficients] == [1.0, 2.0, 3.0] assert [c.display_name for c in new_poly.coefficients] == ['c0', 'c1', 'c2'] + + +def test_Polynomial_serializer_dict_round_trip(clear): + """The public dictionary decoder handles the typed coefficient list.""" + from easyscience.io.serializer_dict import SerializerDict + + poly = Polynomial(display_name='round_trip', coefficients=[1.0, 2.0]) + + new_poly = SerializerDict.decode(poly.to_dict()) + + assert isinstance(new_poly, Polynomial) + assert new_poly.display_name == 'round_trip' + assert [c.value for c in new_poly.coefficients] == [1.0, 2.0] + assert new_poly.coefficients._protected_types == [Parameter] + + +@pytest.mark.parametrize('copier', [copy.copy, copy.deepcopy], ids=['copy', 'deepcopy']) +def test_Polynomial_copy_with_named_coefficients(clear, copier): + """Copies get fresh coefficient identities, even for explicit names.""" + poly = Polynomial(coefficients=[Parameter(1.0, unique_name='coefficient'), Parameter(2.0)]) + + data = poly.to_dict(skip=['unique_name']) + assert all('unique_name' not in c for c in data['coefficients']['data']) + + new_poly = copier(poly) + + assert [c.value for c in new_poly.coefficients] == [1.0, 2.0] + assert new_poly.coefficients[0].unique_name != 'coefficient' + assert all(new is not old for new, old in zip(new_poly.coefficients, poly.coefficients)) From 49f143bbe8a6b77034cb5959f8fb9887601b5157 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 28 Sep 2026 13:39:40 +0200 Subject: [PATCH 03/11] more changes, PR review, removal of Hugger --- src/easyscience/base_classes/easy_list.py | 12 ++-- .../fitting/calculators/interface_factory.py | 4 +- .../fitting/minimizers/minimizer_base.py | 2 - .../fitting/minimizers/minimizer_bumps.py | 2 - .../fitting/minimizers/minimizer_dfo.py | 2 - .../fitting/minimizers/minimizer_lmfit.py | 2 - src/easyscience/global_object/__init__.py | 1 - .../global_object/global_object.py | 3 - .../global_object/hugger/__init__.py | 3 - .../global_object/hugger/hugger.py | 57 ------------------- .../unit/global_object/test_global_object.py | 11 ---- 11 files changed, 8 insertions(+), 91 deletions(-) delete mode 100644 src/easyscience/global_object/hugger/__init__.py delete mode 100644 src/easyscience/global_object/hugger/hugger.py diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py index 2605bb36..33358690 100644 --- a/src/easyscience/base_classes/easy_list.py +++ b/src/easyscience/base_classes/easy_list.py @@ -364,7 +364,9 @@ def pop(self, index: int | str = -1) -> ProtectedType_: # Serialization support - def _convert_to_dict(self, d: dict, encoder: Any, skip: Any = None, **kwargs: Any) -> dict: + def _convert_to_dict( + self, dict_repr: dict, encoder: Any, skip: Any = None, **kwargs: Any + ) -> dict: """ Add the list items and protected types to a serialized dict. @@ -373,7 +375,7 @@ def _convert_to_dict(self, d: dict, encoder: Any, skip: Any = None, **kwargs: An Parameters ---------- - d : dict + dict_repr : dict Dictionary produced so far by the serializer. encoder : Any The serializer performing the conversion. @@ -390,13 +392,13 @@ def _convert_to_dict(self, d: dict, encoder: Any, skip: Any = None, **kwargs: An if skip is None: skip = [] if self._protected_types != [NewBase]: - d['protected_types'] = [ + dict_repr['protected_types'] = [ {'@module': cls_.__module__, '@class': cls_.__name__} for cls_ in self._protected_types ] # noqa: E501 # Each item gets its own copy, as ``to_dict`` appends to ``skip`` - d['data'] = [item.to_dict(skip=list(skip)) for item in self._data] - return d + dict_repr['data'] = [item.to_dict(skip=list(skip)) for item in self._data] + return dict_repr @classmethod def from_dict(cls, obj_dict: Dict[str, Any]) -> NewBase: diff --git a/src/easyscience/fitting/calculators/interface_factory.py b/src/easyscience/fitting/calculators/interface_factory.py index ab7d105d..47878353 100644 --- a/src/easyscience/fitting/calculators/interface_factory.py +++ b/src/easyscience/fitting/calculators/interface_factory.py @@ -175,9 +175,7 @@ def generate_bindings(self, model: Any, *args: Any, ifun: Any = None, **kwargs: Parameters ---------- model : Any - Model whose variables should be bound. Variables are found - with ``_get_linkable_attributes()`` when the model provides - it, otherwise with ``ModelBase.get_all_variables()``. + Model whose variables should be bound. *args : Any Positional arguments reserved for interface-specific binding hooks. diff --git a/src/easyscience/fitting/minimizers/minimizer_base.py b/src/easyscience/fitting/minimizers/minimizer_base.py index 70447ab1..36d8efee 100644 --- a/src/easyscience/fitting/minimizers/minimizer_base.py +++ b/src/easyscience/fitting/minimizers/minimizer_base.py @@ -8,8 +8,6 @@ import numpy as np -# causes circular import when Parameter is imported -# from easyscience.base_classes import ModelBase from easyscience.variable import Parameter from ..available_minimizers import AvailableMinimizers diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index 3b1c13b2..0271a7ef 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -15,8 +15,6 @@ from bumps.parameter import Parameter as BumpsParameter from scipy.optimize import OptimizeResult -# causes circular import when Parameter is imported -# from easyscience.base_classes import ModelBase from easyscience.variable import Parameter from ..available_minimizers import AvailableMinimizers diff --git a/src/easyscience/fitting/minimizers/minimizer_dfo.py b/src/easyscience/fitting/minimizers/minimizer_dfo.py index 130ed6fc..c9e54bef 100644 --- a/src/easyscience/fitting/minimizers/minimizer_dfo.py +++ b/src/easyscience/fitting/minimizers/minimizer_dfo.py @@ -10,8 +10,6 @@ import dfols import numpy as np -# causes circular import when Parameter is imported -# from easyscience.base_classes import ModelBase from easyscience.variable import Parameter from ..available_minimizers import AvailableMinimizers diff --git a/src/easyscience/fitting/minimizers/minimizer_lmfit.py b/src/easyscience/fitting/minimizers/minimizer_lmfit.py index f0c26eb6..a62d02a7 100644 --- a/src/easyscience/fitting/minimizers/minimizer_lmfit.py +++ b/src/easyscience/fitting/minimizers/minimizer_lmfit.py @@ -11,8 +11,6 @@ from lmfit import Parameters as LMParameters from lmfit.model import ModelResult -# causes circular import when Parameter is imported -# from easyscience.base_classes import ModelBase from easyscience.variable import Parameter from ..available_minimizers import AvailableMinimizers diff --git a/src/easyscience/global_object/__init__.py b/src/easyscience/global_object/__init__.py index c0934dde..0cf8a585 100644 --- a/src/easyscience/global_object/__init__.py +++ b/src/easyscience/global_object/__init__.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: BSD-3-Clause from .global_object import GlobalObject # noqa: F401 -from .hugger.hugger import ScriptManager # noqa: F401 from .logger import Logger # noqa: F401 from .map import Map # 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 d128fe38..aeaec90e 100644 --- a/src/easyscience/global_object/global_object.py +++ b/src/easyscience/global_object/global_object.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: BSD-3-Clause from ..utils.classUtils import singleton -from .hugger.hugger import ScriptManager from .logger import Logger from .map import Map @@ -28,8 +27,6 @@ def __init__(self): self.debug: bool = self.__debug # Stack. This is where the undo/redo operations are stored. self.stack = self.__stack - # - self.script: ScriptManager = ScriptManager() # Map. This is the conduit database between all global object species self.map: Map = self.__map diff --git a/src/easyscience/global_object/hugger/__init__.py b/src/easyscience/global_object/hugger/__init__.py deleted file mode 100644 index b1990ec8..00000000 --- a/src/easyscience/global_object/hugger/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - diff --git a/src/easyscience/global_object/hugger/hugger.py b/src/easyscience/global_object/hugger/hugger.py deleted file mode 100644 index a151d394..00000000 --- a/src/easyscience/global_object/hugger/hugger.py +++ /dev/null @@ -1,57 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from typing import List - -from ...utils.classUtils import singleton - - -@singleton -class Store: - __log = [] - __var_ident = 'var_' - __ret_ident = 'ret_' - - def __init__(self): - self.log = self.__log # TODO Async problem? - self.var_ident = self.__var_ident - self.ret_ident = self.__ret_ident - - @staticmethod - def get_defaults() -> dict: - return { - 'log': Store.__log, - 'create_list': Store.__create_list, - 'unique_args': Store.__unique_args, - 'unique_rets': Store.__unique_rets, - 'var_ident': Store.__var_ident, - 'ret_ident': Store.__ret_ident, - } - - def append_log(self, log_entry: str): - self.log.append(log_entry) - - -class ScriptManager: - def __init__(self, enabled=True): - self._store = Store() - self._enabled = enabled - - @property - def enabled(self) -> bool: - return self._enabled - - @enabled.setter - def enabled(self, value: bool): - self._enabled = value - - def history(self) -> List[str]: - return self._store.log - - def reset_history(self): - defaults = Store.get_defaults() - for key, item in defaults.items(): - setattr(self._store, key, item) - - def append_log(self, log_entry: str): - self._store.log.append(log_entry) diff --git a/tests/unit/global_object/test_global_object.py b/tests/unit/global_object/test_global_object.py index 6199c542..4345ee33 100644 --- a/tests/unit/global_object/test_global_object.py +++ b/tests/unit/global_object/test_global_object.py @@ -164,14 +164,3 @@ def test_integration_with_parameter_creation(self, clear_global_map): # Test retrieval retrieved1 = global_obj.map.get_item_by_key(param1.unique_name) assert retrieved1 is param1 - - def test_script_manager_access(self): - """Test that script manager is accessible""" - # Given - global_obj = GlobalObject() - - # Then - assert hasattr(global_obj, 'script') - from easyscience.global_object.hugger.hugger import ScriptManager - - assert isinstance(global_obj.script, ScriptManager) From 2ffde0c268919956f0c884642942ad23842a5ebb Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 28 Sep 2026 14:23:50 +0200 Subject: [PATCH 04/11] removed Polynomial --- .github/workflows/pypi-test.yml | 6 +- .github/workflows/test.yml | 28 +- docs/docs/api-reference/index.md | 2 - docs/docs/api-reference/models.md | 1 - docs/mkdocs.yml | 1 - pixi.toml | 2 +- src/easyscience/io/serializer_dict.py | 7 + src/easyscience/models/__init__.py | 4 - src/easyscience/models/polynomial.py | 88 ------- tests/conftest.py | 9 +- tests/functional/test_smoke.py | 20 -- tests/helpers/easy_test_models.py | 69 +++++ .../base_classes/test_model_with_easy_list.py | 109 ++++++++ .../calculators/test_interface_factory.py | 4 +- .../test_integration_comprehensive.py | 6 +- tests/unit/io/test_serializer_dict.py | 4 +- tests/unit/models/__init__.py | 3 - tests/unit/models/test_polynomial.py | 241 ------------------ 18 files changed, 216 insertions(+), 388 deletions(-) delete mode 100644 docs/docs/api-reference/models.md delete mode 100644 src/easyscience/models/__init__.py delete mode 100644 src/easyscience/models/polynomial.py delete mode 100644 tests/functional/test_smoke.py create mode 100644 tests/helpers/easy_test_models.py create mode 100644 tests/unit/base_classes/test_model_with_easy_list.py delete mode 100644 tests/unit/models/__init__.py delete mode 100644 tests/unit/models/test_polynomial.py diff --git a/.github/workflows/pypi-test.yml b/.github/workflows/pypi-test.yml index b8edabe4..ba9d1b32 100644 --- a/.github/workflows/pypi-test.yml +++ b/.github/workflows/pypi-test.yml @@ -69,9 +69,9 @@ jobs: working-directory: easyscience run: pixi run python -m pytest ../tests/unit/ --color=yes -v - - name: Run functional tests to verify the installation - working-directory: easyscience - run: pixi run python -m pytest ../tests/functional/ --color=yes -v + # - name: Run functional tests to verify the installation + # working-directory: easyscience + # run: pixi run python -m pytest ../tests/functional/ --color=yes -v - name: Run integration tests to verify the installation working-directory: easyscience diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 776ed94f..b247f0da 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -258,24 +258,24 @@ jobs: cd .. done - - name: Run functional tests - shell: bash - run: | - set -euo pipefail + # - name: Run functional tests + # shell: bash + # run: | + # set -euo pipefail - for py_ver in $PY_VERSIONS; do - echo - echo "🔹🔸🔹🔸🔹 Python: $py_ver 🔹🔸🔹🔸🔹" + # for py_ver in $PY_VERSIONS; do + # echo + # echo "🔹🔸🔹🔸🔹 Python: $py_ver 🔹🔸🔹🔸🔹" - echo "Entering pixi project directory easyscience_py$py_ver" - cd easyscience_py$py_ver + # echo "Entering pixi project directory easyscience_py$py_ver" + # cd easyscience_py$py_ver - echo "Running tests" - pixi run python -m pytest ../tests/functional/ --color=yes -v + # echo "Running tests" + # pixi run python -m pytest ../tests/functional/ --color=yes -v - echo "Exiting pixi project directory" - cd .. - done + # echo "Exiting pixi project directory" + # cd .. + # done - name: Run integration tests ${{ needs.env-prepare.outputs.pytest-marks }} shell: bash diff --git a/docs/docs/api-reference/index.md b/docs/docs/api-reference/index.md index 1a035c29..918aa5bd 100644 --- a/docs/docs/api-reference/index.md +++ b/docs/docs/api-reference/index.md @@ -18,8 +18,6 @@ available in EasyScience. persisting EasyScience objects (serializers and components). - [job](job.md) – Job and experiment abstractions for running and organizing analyses. -- [models](models.md) – Predefined model implementations (e.g. - polynomial models) used in fitting workflows. - [utils](utils.md) – Miscellaneous utility functions and helpers (class tools, decorators, type helpers). - [variable](variable.md) – Descriptor types and variable abstractions diff --git a/docs/docs/api-reference/models.md b/docs/docs/api-reference/models.md deleted file mode 100644 index 2471f604..00000000 --- a/docs/docs/api-reference/models.md +++ /dev/null @@ -1 +0,0 @@ -::: easyscience.models diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 91165ebc..42f40876 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -206,6 +206,5 @@ nav: - global_object: api-reference/global_object.md - io: api-reference/io.md - job: api-reference/job.md - - models: api-reference/models.md - utils: api-reference/utils.md - variable: api-reference/variable.md diff --git a/pixi.toml b/pixi.toml index 4873d04f..7d0e20fc 100644 --- a/pixi.toml +++ b/pixi.toml @@ -90,7 +90,7 @@ user = { features = ['py-max', 'user'] } ################## unit-tests = 'python -m pytest tests/unit/ --color=yes -v' -functional-tests = 'python -m pytest tests/functional/ --color=yes -v' +# functional-tests = 'python -m pytest tests/functional/ --color=yes -v' integration-tests = 'python -m pytest tests/integration/ --color=yes -n auto -v' notebook-tests = 'python -m pytest --nbmake docs/docs/tutorials/ --nbmake-timeout=1200 --color=yes -n auto -v' diff --git a/src/easyscience/io/serializer_dict.py b/src/easyscience/io/serializer_dict.py index f36ee302..13951335 100644 --- a/src/easyscience/io/serializer_dict.py +++ b/src/easyscience/io/serializer_dict.py @@ -75,6 +75,13 @@ def decode(cls, d: Dict[str, Any]) -> SerializerComponent: EasyScience object. """ if SerializerBase._is_serialized_easyscience_object(d): + # ``decode`` also receives values that are not EasyScience + # objects: plain dicts and lists, ``None``, and encoded numpy + # arrays or datetimes. Those have no ``@module``/``@class`` + # keys, or name a module outside the ``easy*`` packages, and + # must stay on the generic path below. Without the check, + # ``d['@module']`` would fail for plain dicts, lists and None. + # Local import to avoid a circular dependency from ..base_classes.new_base import NewBase diff --git a/src/easyscience/models/__init__.py b/src/easyscience/models/__init__.py deleted file mode 100644 index 3fc7f778..00000000 --- a/src/easyscience/models/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-FileCopyrightText: 2024 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from .polynomial import Polynomial # noqa: F401 diff --git a/src/easyscience/models/polynomial.py b/src/easyscience/models/polynomial.py deleted file mode 100644 index 1ff32812..00000000 --- a/src/easyscience/models/polynomial.py +++ /dev/null @@ -1,88 +0,0 @@ -# SPDX-FileCopyrightText: 2024 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from typing import Iterable -from typing import Optional -from typing import Union - -import numpy as np - -from ..base_classes import EasyList -from ..base_classes import ModelBase -from ..variable import Parameter - - -class Polynomial(ModelBase): - """ - A polynomial model. - - Parameters - ---------- - display_name : Optional[str], default='polynomial' - The display name of the model. - coefficients : Optional[Union[Iterable[Union[float, Parameter]], EasyList]], default=None - Coefficients used to populate ``self.coefficients``. - unique_name : Optional[str], default=None - Unique name of the model. By default, None. - - Raises - ------ - TypeError - If ``coefficients`` is neither an ``EasyList`` nor an iterable - of floats or ``Parameter`` instances. - """ - - def __init__( - self, - display_name: Optional[str] = 'polynomial', - coefficients: Optional[Union[Iterable[Union[float, Parameter]], EasyList]] = None, - unique_name: Optional[str] = None, - ): - super(Polynomial, self).__init__(unique_name=unique_name, display_name=display_name) - self._coefficients = EasyList(protected_types=Parameter) - if coefficients is not None: - if isinstance(coefficients, EasyList): - self._coefficients = coefficients - elif isinstance(coefficients, Iterable): - for index, item in enumerate(coefficients): - if issubclass(type(item), Parameter): - self._coefficients.append(item) - elif isinstance(item, float): - self._coefficients.append( - Parameter(value=item, display_name='c{}'.format(index)) - ) - else: - raise TypeError('Coefficients must be floats or Parameters') - else: - raise TypeError('coefficients must be a list or an EasyList') - - @property - def coefficients(self) -> EasyList: - """ - Get the coefficients of the polynomial. - - Returns - ------- - EasyList - Coefficients of the polynomial. - """ - return self._coefficients - - def __call__(self, x: np.ndarray, *args, **kwargs) -> np.ndarray: - return np.polyval([c.value for c in self.coefficients], x) - - def __repr__(self): - s = [] - if len(self.coefficients) >= 1: - s += [f'{self.coefficients[0].value}'] - if len(self.coefficients) >= 2: - s += [f'{self.coefficients[1].value}x'] - if len(self.coefficients) >= 3: - s += [ - f'{c.value}x^{i + 2}' - for i, c in enumerate(self.coefficients[2:]) - if c.value != 0 - ] - s.reverse() - s = ' + '.join(s) - return 'Polynomial({}, {})'.format(self.display_name, s) diff --git a/tests/conftest.py b/tests/conftest.py index 2f9a6768..bdad1f01 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,10 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] SRC_ROOT = PROJECT_ROOT / 'src' +# Shared test-only models, imported as ``from easy_test_models import ...`` +HELPERS_ROOT = Path(__file__).resolve().parent / 'helpers' -src_root_str = str(SRC_ROOT) -if src_root_str not in sys.path: - sys.path.insert(0, src_root_str) +for root in (SRC_ROOT, HELPERS_ROOT): + root_str = str(root) + if root_str not in sys.path: + sys.path.insert(0, root_str) diff --git a/tests/functional/test_smoke.py b/tests/functional/test_smoke.py deleted file mode 100644 index af5396fb..00000000 --- a/tests/functional/test_smoke.py +++ /dev/null @@ -1,20 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -import numpy as np -import pytest - -from easyscience import global_object -from easyscience.models.polynomial import Polynomial - - -@pytest.mark.fast -def test_polynomial_smoke() -> None: - """Exercise a minimal user-facing model workflow.""" - global_object.map._clear() - - model = Polynomial(display_name='smoke', coefficients=[1.0, 2.0, 3.0]) - x = np.array([0.0, 1.0, 2.0]) - - assert [coefficient.display_name for coefficient in model.coefficients] == ['c0', 'c1', 'c2'] - assert np.allclose(model(x), np.polyval([1.0, 2.0, 3.0], x)) diff --git a/tests/helpers/easy_test_models.py b/tests/helpers/easy_test_models.py new file mode 100644 index 00000000..55e196a5 --- /dev/null +++ b/tests/helpers/easy_test_models.py @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +""" +Test-only models shared across the test suite. + +``tests/conftest.py`` puts this directory on ``sys.path``, so tests +import from here with ``from easy_test_models import ...``. The module +name starts with ``easy`` on purpose: the serializer only treats a dict +as an EasyScience object (and dispatches to ``from_dict``) when its +``@module`` starts with ``easy``, so this keeps serializer round trips +working for these models. +""" + +from __future__ import annotations + +from typing import Iterable +from typing import Optional +from typing import Union + +import numpy as np + +from easyscience.base_classes import EasyList +from easyscience.base_classes import ModelBase +from easyscience.variable import Parameter + + +class CoefficientModel(ModelBase): + """ + A minimal model holding its parameters in an ``EasyList``. + + It exercises nested containers in serialization, copying, fitting + parameter discovery and calculator bindings. Plain numbers become + ``Parameter`` objects named ``c0``, ``c1``, ... The model evaluates + as a polynomial with the coefficients in ``numpy.polyval`` order. + + Parameters + ---------- + display_name : Optional[str], default=None + Display name of the model. + coefficients : Optional[Union[Iterable[Union[float, Parameter]], EasyList]], default=None + Initial coefficients, either numbers/``Parameter`` objects or a + ready-made ``EasyList``. + unique_name : Optional[str], default=None + Unique name of the model. + """ + + def __init__( + self, + display_name: Optional[str] = None, + coefficients: Optional[Union[Iterable[Union[float, Parameter]], EasyList]] = None, + unique_name: Optional[str] = None, + ): + super().__init__(unique_name=unique_name, display_name=display_name) + if isinstance(coefficients, EasyList): + self._coefficients = coefficients + return + self._coefficients = EasyList(protected_types=Parameter) + for index, item in enumerate(coefficients or []): + if not isinstance(item, Parameter): + item = Parameter(value=float(item), display_name=f'c{index}') + self._coefficients.append(item) + + @property + def coefficients(self) -> EasyList: + """The coefficient parameters.""" + return self._coefficients + + def __call__(self, x: np.ndarray, *args, **kwargs) -> np.ndarray: + return np.polyval([c.value for c in self.coefficients], x) diff --git a/tests/unit/base_classes/test_model_with_easy_list.py b/tests/unit/base_classes/test_model_with_easy_list.py new file mode 100644 index 00000000..a95b4dde --- /dev/null +++ b/tests/unit/base_classes/test_model_with_easy_list.py @@ -0,0 +1,109 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Behaviour of a ModelBase that holds its parameters in an EasyList.""" + +import copy + +import numpy as np +import pytest +from easy_test_models import CoefficientModel + +from easyscience import global_object +from easyscience.base_classes import EasyList +from easyscience.io.serializer_dict import SerializerDict +from easyscience.variable import Parameter + + +@pytest.fixture(autouse=True) +def clear(): + """Clear global object map before each test.""" + global_object.map._clear() + + +@pytest.mark.parametrize( + 'coefficients', + [(1.0,), (1.0, 2.0), (1.0, 2.0, 3.0), (-1.0, -2.0, -3.0), (0.72, 6.48, -0.48)], +) +def test_numbers_become_parameters(coefficients): + model = CoefficientModel(coefficients=coefficients) + + assert all(isinstance(c, Parameter) for c in model.coefficients) + assert [c.value for c in model.coefficients] == list(coefficients) + assert [c.display_name for c in model.coefficients] == [ + f'c{i}' for i in range(len(coefficients)) + ] + x = np.linspace(0, 10, 100) + assert np.allclose(model(x), np.polyval(coefficients, x)) + + +def test_default_initialization(): + model = CoefficientModel(display_name='empty') + + assert model.display_name == 'empty' + assert len(model.coefficients) == 0 + assert model.coefficients._protected_types == [Parameter] + + +def test_parameters_are_kept(): + p0 = Parameter(value=5.0, display_name='a') + p1 = Parameter(value=2.0, display_name='b') + + model = CoefficientModel(coefficients=[p0, p1, 1.0]) + + assert model.coefficients[0] is p0 + assert model.coefficients[1] is p1 + assert model.coefficients[2].display_name == 'c2' + + +def test_easy_list_is_used_directly(): + collection = EasyList(Parameter(value=1.0), Parameter(value=2.0), protected_types=Parameter) + + model = CoefficientModel(coefficients=collection) + + assert model.coefficients is collection + + +def test_get_fit_parameters(): + """Parameters inside the EasyList are exposed for fitting.""" + model = CoefficientModel(coefficients=[1.0, 2.0]) + + assert model.get_fit_parameters() == list(model.coefficients) + + +def test_dict_round_trip(): + model = CoefficientModel(display_name='round_trip', coefficients=[1.0, 2.0, 3.0]) + + new_model = CoefficientModel.from_dict(model.to_dict()) + + assert new_model.display_name == 'round_trip' + assert [c.value for c in new_model.coefficients] == [1.0, 2.0, 3.0] + assert [c.display_name for c in new_model.coefficients] == ['c0', 'c1', 'c2'] + + +def test_serializer_dict_round_trip(): + """The public dictionary decoder handles the typed EasyList.""" + model = CoefficientModel(display_name='round_trip', coefficients=[1.0, 2.0]) + + new_model = SerializerDict.decode(model.to_dict()) + + assert isinstance(new_model, CoefficientModel) + assert new_model.display_name == 'round_trip' + assert [c.value for c in new_model.coefficients] == [1.0, 2.0] + assert new_model.coefficients._protected_types == [Parameter] + + +@pytest.mark.parametrize('copier', [copy.copy, copy.deepcopy], ids=['copy', 'deepcopy']) +def test_copy_with_named_coefficients(copier): + """Copies get fresh coefficient identities, even for explicit names.""" + model = CoefficientModel( + coefficients=[Parameter(1.0, unique_name='coefficient'), Parameter(2.0)] + ) + + data = model.to_dict(skip=['unique_name']) + assert all('unique_name' not in c for c in data['coefficients']['data']) + + new_model = copier(model) + + assert [c.value for c in new_model.coefficients] == [1.0, 2.0] + assert new_model.coefficients[0].unique_name != 'coefficient' + assert all(new is not old for new, old in zip(new_model.coefficients, model.coefficients)) diff --git a/tests/unit/fitting/calculators/test_interface_factory.py b/tests/unit/fitting/calculators/test_interface_factory.py index 40fb46a4..cc351731 100644 --- a/tests/unit/fitting/calculators/test_interface_factory.py +++ b/tests/unit/fitting/calculators/test_interface_factory.py @@ -565,7 +565,7 @@ def mock_setter(link_name, **kwargs): def test_generate_bindings_with_model_base(): """Bindings are generated for a real ModelBase via get_all_variables.""" - from easyscience.models.polynomial import Polynomial + from easy_test_models import CoefficientModel global_object.map._clear() storage = {} @@ -592,7 +592,7 @@ def create(self, model): def fit_func(self, *args, **kwargs): return None - poly = Polynomial(coefficients=[1.0, 2.0]) + poly = CoefficientModel(coefficients=[1.0, 2.0]) factory = InterfaceFactoryTemplate([Calculator]) factory.generate_bindings(poly) diff --git a/tests/unit/global_object/test_integration_comprehensive.py b/tests/unit/global_object/test_integration_comprehensive.py index 4781f557..38bdfa5c 100644 --- a/tests/unit/global_object/test_integration_comprehensive.py +++ b/tests/unit/global_object/test_integration_comprehensive.py @@ -5,12 +5,12 @@ from unittest.mock import patch import pytest +from easy_test_models import CoefficientModel from easyscience import ModelBase from easyscience import Parameter from easyscience import global_object from easyscience.global_object.global_object import GlobalObject -from easyscience.models.polynomial import Polynomial from easyscience.variable import DescriptorBool @@ -373,7 +373,7 @@ def test_serialization_integration_with_global_state(self, clear_all): # Create objects param = Parameter(display_name='test_param', value=123.45, unit='kg') - obj = Polynomial(display_name='test_obj', coefficients=[param]) + obj = CoefficientModel(display_name='test_obj', coefficients=[param]) original_vertex_count = len(global_obj.map.vertices()) @@ -387,7 +387,7 @@ def test_serialization_integration_with_global_state(self, clear_all): # When - Deserialize objects new_param = Parameter.from_dict(param_dict) - new_obj = Polynomial.from_dict(obj_dict) + new_obj = CoefficientModel.from_dict(obj_dict) # Then - Should be registered in global map again assert len(global_obj.map.vertices()) >= 2 diff --git a/tests/unit/io/test_serializer_dict.py b/tests/unit/io/test_serializer_dict.py index aa299175..598554f0 100644 --- a/tests/unit/io/test_serializer_dict.py +++ b/tests/unit/io/test_serializer_dict.py @@ -114,9 +114,9 @@ def test_group_encode2(): p0 = Parameter(0, display_name='a') p1 = Parameter(1, display_name='b') - from easyscience.models.polynomial import Polynomial + from easy_test_models import CoefficientModel - b = Polynomial(display_name='outer', coefficients=[p0, p1]) + b = CoefficientModel(display_name='outer', coefficients=[p0, p1]) d = b.to_dict() assert isinstance(d['coefficients'], dict) assert len(d['coefficients']['data']) == 2 diff --git a/tests/unit/models/__init__.py b/tests/unit/models/__init__.py deleted file mode 100644 index b1990ec8..00000000 --- a/tests/unit/models/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - diff --git a/tests/unit/models/test_polynomial.py b/tests/unit/models/test_polynomial.py deleted file mode 100644 index 16eaea0f..00000000 --- a/tests/unit/models/test_polynomial.py +++ /dev/null @@ -1,241 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -import copy - -import numpy as np -import pytest - -from easyscience import global_object -from easyscience.base_classes import EasyList -from easyscience.models.polynomial import Polynomial -from easyscience.variable import Parameter - - -@pytest.fixture -def clear(): - """Clear global object map before each test.""" - global_object.map._clear() - - -poly_test_cases = ( - (1.0,), - ( - 1.0, - 2.0, - ), - (1.0, 2.0, 3.0), - (-1.0, -2.0, -3.0), - (0.72, 6.48, -0.48), -) - - -@pytest.mark.parametrize('coo', poly_test_cases) -def test_Polynomial_pars(clear, coo): - poly = Polynomial(coefficients=coo) - - vals = {coo.value for coo in poly.coefficients} - assert len(vals.difference(set(coo))) == 0 - - x = np.linspace(0, 10, 100) - y = np.polyval(coo, x) - assert np.allclose(poly(x), y) - - -def test_Polynomial_default_initialization(clear): - """Test Polynomial with no coefficients.""" - poly = Polynomial(display_name='test_poly') - - assert poly.display_name == 'test_poly' - assert len(poly.coefficients) == 0 - - # Test that calling the polynomial with empty coefficients works - x = np.array([1, 2, 3]) - result = poly(x) - assert len(result) == len(x) - - -def test_Polynomial_with_Parameter_objects(clear): - """Test Polynomial with Parameter objects as coefficients.""" - p0 = Parameter(value=1.0, display_name='c0') - p1 = Parameter(value=2.0, display_name='c1') - p2 = Parameter(value=3.0, display_name='c2') - - poly = Polynomial(coefficients=[p0, p1, p2]) - - assert len(poly.coefficients) == 3 - assert poly.coefficients[0].display_name == 'c0' - assert poly.coefficients[1].display_name == 'c1' - assert poly.coefficients[2].display_name == 'c2' - assert poly.coefficients[0].value == 1.0 - assert poly.coefficients[1].value == 2.0 - assert poly.coefficients[2].value == 3.0 - - # Test evaluation - coefficients passed directly to polyval - # polyval([1.0, 2.0, 3.0], x) = 1.0*x^2 + 2.0*x + 3.0 - x = np.array([0, 1, 2]) - expected = np.polyval([1.0, 2.0, 3.0], x) - assert np.allclose(poly(x), expected) - - -def test_Polynomial_with_mixed_coefficients(clear): - """Test Polynomial with mixed float and Parameter coefficients.""" - p0 = Parameter(value=5.0, display_name='c0') - - poly = Polynomial(coefficients=[p0, 2.0, 1.0]) - - assert len(poly.coefficients) == 3 - assert poly.coefficients[0].display_name == 'c0' - assert poly.coefficients[1].display_name == 'c1' - assert poly.coefficients[2].display_name == 'c2' - - # polyval([5.0, 2.0, 1.0], x) = 5.0*x^2 + 2.0*x + 1.0 - x = np.array([1, 2, 3]) - expected = np.polyval([5.0, 2.0, 1.0], x) - assert np.allclose(poly(x), expected) - - -def test_Polynomial_with_EasyList(clear): - """Test Polynomial initialized with an EasyList.""" - collection = EasyList(protected_types=Parameter) - collection.append(Parameter(value=1.0, display_name='c0')) - collection.append(Parameter(value=2.0, display_name='c1')) - collection.append(Parameter(value=3.0, display_name='c2')) - - poly = Polynomial(coefficients=collection) - - assert poly.coefficients is collection - assert len(poly.coefficients) == 3 - - # polyval([1.0, 2.0, 3.0], x) = 1.0*x^2 + 2.0*x + 3.0 - x = np.array([0, 1, 2]) - expected = np.polyval([1.0, 2.0, 3.0], x) - assert np.allclose(poly(x), expected) - - -def test_Polynomial_invalid_coefficient_type(clear): - """Test that invalid coefficient types raise TypeError.""" - with pytest.raises(TypeError, match='Coefficients must be floats or Parameters'): - Polynomial(coefficients=[1.0, 'invalid', 3.0]) - - with pytest.raises(TypeError, match='Coefficients must be floats or Parameters'): - Polynomial(coefficients=[1, 2, 3]) # integers, not floats - - -def test_Polynomial_invalid_coefficients_type(clear): - """Test that invalid coefficients argument type raises TypeError.""" - # String is iterable, so it will iterate over characters and fail with first error - with pytest.raises(TypeError, match='Coefficients must be floats or Parameters'): - Polynomial(coefficients='invalid') - - # Integer is not iterable, so it will fail with second error - with pytest.raises(TypeError, match='coefficients must be a list or an EasyList'): - Polynomial(coefficients=42) - - -def test_Polynomial_repr_no_coefficients(clear): - """Test __repr__ with no coefficients.""" - poly = Polynomial(display_name='empty') - - repr_str = repr(poly) - assert 'Polynomial(empty, )' == repr_str - - -def test_Polynomial_repr_one_coefficient(clear): - """Test __repr__ with one coefficient.""" - poly = Polynomial(coefficients=[5.0]) - - repr_str = repr(poly) - assert 'Polynomial(polynomial, 5.0)' == repr_str - - -def test_Polynomial_repr_two_coefficients(clear): - """Test __repr__ with two coefficients.""" - poly = Polynomial(coefficients=[3.0, 2.0]) - - repr_str = repr(poly) - assert 'Polynomial(polynomial, 2.0x + 3.0)' == repr_str - - -def test_Polynomial_repr_three_coefficients(clear): - """Test __repr__ with three coefficients.""" - poly = Polynomial(coefficients=[1.0, 2.0, 3.0]) - - repr_str = repr(poly) - assert 'Polynomial(polynomial, 3.0x^2 + 2.0x + 1.0)' == repr_str - - -def test_Polynomial_repr_with_zero_coefficients(clear): - """Test __repr__ with some zero coefficients.""" - poly = Polynomial(coefficients=[1.0, 0.0, 3.0, 0.0, 5.0]) - - repr_str = repr(poly) - # Zero coefficients for higher powers should be excluded - assert 'Polynomial(polynomial, 5.0x^4 + 3.0x^2 + 0.0x + 1.0)' == repr_str - - -def test_Polynomial_repr_negative_coefficients(clear): - """Test __repr__ with negative coefficients.""" - poly = Polynomial(coefficients=[-1.0, -2.0, -3.0]) - - repr_str = repr(poly) - assert 'Polynomial(polynomial, -3.0x^2 + -2.0x + -1.0)' == repr_str - - -def test_Polynomial_call_with_args_kwargs(clear): - """Test that __call__ accepts *args and **kwargs.""" - poly = Polynomial(coefficients=[1.0, 2.0, 3.0]) - - x = np.array([0, 1, 2]) - # These additional args/kwargs should be ignored - result = poly(x, 'extra_arg', extra_kwarg='value') - # polyval([1.0, 2.0, 3.0], x) = 1.0*x^2 + 2.0*x + 3.0 - expected = np.polyval([1.0, 2.0, 3.0], x) - assert np.allclose(result, expected) - - -def test_Polynomial_get_fit_parameters(clear): - """Coefficients held in the EasyList are exposed for fitting.""" - poly = Polynomial(coefficients=[1.0, 2.0]) - - assert poly.get_fit_parameters() == list(poly.coefficients) - - -def test_Polynomial_dict_round_trip(clear): - """Test that coefficients survive serialization.""" - poly = Polynomial(display_name='round_trip', coefficients=[1.0, 2.0, 3.0]) - - new_poly = Polynomial.from_dict(poly.to_dict()) - - assert new_poly.display_name == 'round_trip' - assert [c.value for c in new_poly.coefficients] == [1.0, 2.0, 3.0] - assert [c.display_name for c in new_poly.coefficients] == ['c0', 'c1', 'c2'] - - -def test_Polynomial_serializer_dict_round_trip(clear): - """The public dictionary decoder handles the typed coefficient list.""" - from easyscience.io.serializer_dict import SerializerDict - - poly = Polynomial(display_name='round_trip', coefficients=[1.0, 2.0]) - - new_poly = SerializerDict.decode(poly.to_dict()) - - assert isinstance(new_poly, Polynomial) - assert new_poly.display_name == 'round_trip' - assert [c.value for c in new_poly.coefficients] == [1.0, 2.0] - assert new_poly.coefficients._protected_types == [Parameter] - - -@pytest.mark.parametrize('copier', [copy.copy, copy.deepcopy], ids=['copy', 'deepcopy']) -def test_Polynomial_copy_with_named_coefficients(clear, copier): - """Copies get fresh coefficient identities, even for explicit names.""" - poly = Polynomial(coefficients=[Parameter(1.0, unique_name='coefficient'), Parameter(2.0)]) - - data = poly.to_dict(skip=['unique_name']) - assert all('unique_name' not in c for c in data['coefficients']['data']) - - new_poly = copier(poly) - - assert [c.value for c in new_poly.coefficients] == [1.0, 2.0] - assert new_poly.coefficients[0].unique_name != 'coefficient' - assert all(new is not old for new, old in zip(new_poly.coefficients, poly.coefficients)) From d27019666fa08ba91d2b5566348c5d91848c04d2 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 28 Sep 2026 14:32:24 +0200 Subject: [PATCH 05/11] commented out functional tests (Polynomial only) --- .github/workflows/test.yml | 22 +++++++++++----------- pixi.toml | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b247f0da..14a18ba3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -102,20 +102,20 @@ jobs: pixi run --environment $env unit-tests done - - name: Run functional tests - shell: bash - run: | - set -euo pipefail + # - name: Run functional tests + # shell: bash + # run: | + # set -euo pipefail - for py_ver in $PY_VERSIONS; do - echo - echo "🔹🔸🔹🔸🔹 Python: $py_ver 🔹🔸🔹🔸🔹" + # for py_ver in $PY_VERSIONS; do + # echo + # echo "🔹🔸🔹🔸🔹 Python: $py_ver 🔹🔸🔹🔸🔹" - env="py-$(echo $py_ver | tr -d .)-env" # Converts 3.XX -> py-3XX-env + # env="py-$(echo $py_ver | tr -d .)-env" # Converts 3.XX -> py-3XX-env - echo "Running tests in environment: $env" - pixi run --environment $env functional-tests - done + # echo "Running tests in environment: $env" + # pixi run --environment $env functional-tests + # done - name: Run integration tests ${{ needs.env-prepare.outputs.pytest-marks }} shell: bash diff --git a/pixi.toml b/pixi.toml index 7d0e20fc..bbbca890 100644 --- a/pixi.toml +++ b/pixi.toml @@ -149,7 +149,7 @@ raw-metrics-json = 'radon raw -s -j src/' ############# unit-tests-coverage = 'pixi run unit-tests --cov=src/easyscience --cov-report=term-missing' -functional-tests-coverage = 'pixi run functional-tests --cov=src/easyscience --cov-report=term-missing' +# functional-tests-coverage = 'pixi run functional-tests --cov=src/easyscience --cov-report=term-missing' integration-tests-coverage = 'pixi run integration-tests --cov=src/easyscience --cov-report=term-missing' docstring-coverage = 'interrogate -c pyproject.toml src/easyscience' From 1be0e786cdb982f01fa2ef5dab866e4dbcd8d25b Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 28 Sep 2026 14:43:35 +0200 Subject: [PATCH 06/11] removed legacy exclude from pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d20a9ac3..58e9ed21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -177,7 +177,7 @@ testpaths = ['tests'] # https://docs.astral.sh/ruff/rules/ [tool.ruff] -exclude = ['tests', 'legacy'] +exclude = ['tests'] indent-width = 4 line-length = 99 # See also `max-line-length` in [tool.ruff.lint.pycodestyle] preview = true # Enable new rules that are not yet stable, like DOC From 9c00901d4f38a49e5f7fc8d2749978e701d44888 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 28 Sep 2026 14:59:03 +0200 Subject: [PATCH 07/11] update unit tests for Codecov --- tests/unit/base_classes/test_easy_list.py | 19 +++ tests/unit/io/test_serializer_dict.py | 58 ++++++++ tests/unit/job/__init__.py | 2 + tests/unit/job/test_job.py | 169 ++++++++++++++++++++++ 4 files changed, 248 insertions(+) create mode 100644 tests/unit/job/__init__.py create mode 100644 tests/unit/job/test_job.py diff --git a/tests/unit/base_classes/test_easy_list.py b/tests/unit/base_classes/test_easy_list.py index 8a593766..c08a1b2c 100644 --- a/tests/unit/base_classes/test_easy_list.py +++ b/tests/unit/base_classes/test_easy_list.py @@ -568,6 +568,25 @@ def test_from_dict_round_trip(self): assert el2[1].unique_name == 'a2' assert d == el2.to_dict() # The dicts should be the same after round trip + def test_to_dict_forwards_skip_to_items(self): + a1 = Alpha(unique_name='a1', display_name='first') + el = EasyList(a1, unique_name='my_list', protected_types=Alpha) + + d = el.to_dict(skip=['display_name']) + + assert 'display_name' not in d['data'][0] + assert d['data'][0]['unique_name'] == 'a1' + + def test_convert_to_dict_without_skip(self): + """The serializer hook also works when called without ``skip``.""" + a1 = Alpha(unique_name='a1') + el = EasyList(a1, protected_types=Alpha) + + d = el._convert_to_dict({}, encoder=None) + + assert d['data'][0]['unique_name'] == 'a1' + assert d['protected_types'][0]['@class'] == 'Alpha' + # --- get_all_variables --- def test_get_all_variables_empty_list(self): diff --git a/tests/unit/io/test_serializer_dict.py b/tests/unit/io/test_serializer_dict.py index 598554f0..8708393d 100644 --- a/tests/unit/io/test_serializer_dict.py +++ b/tests/unit/io/test_serializer_dict.py @@ -120,3 +120,61 @@ def test_group_encode2(): d = b.to_dict() assert isinstance(d['coefficients'], dict) assert len(d['coefficients']['data']) == 2 + + +class TestSerializerDictDecodeDispatch: + """``SerializerDict.decode`` routes by what the dict describes.""" + + def test_new_base_uses_from_dict(self): + from easy_test_models import CoefficientModel + + global_object.map._clear() + + model = CoefficientModel(display_name='model', coefficients=[1.0, 2.0]) + + new_model = SerializerDict.decode(model.to_dict()) + + assert isinstance(new_model, CoefficientModel) + assert [c.value for c in new_model.coefficients] == [1.0, 2.0] + + @pytest.mark.parametrize( + 'value', + [None, 1.5, 'text', [1, 2], {'plain': 'dict'}], + ids=['none', 'float', 'str', 'list', 'plain_dict'], + ) + def test_non_easyscience_values_pass_through(self, value): + assert SerializerDict.decode(value) == value + + def test_numpy_array_uses_generic_path(self): + import numpy as np + + encoded = {'@module': 'numpy', '@class': 'array', 'dtype': 'float64', 'data': [1.0, 2.0]} + + result = SerializerDict.decode(encoded) + + assert isinstance(result, np.ndarray) + assert np.array_equal(result, [1.0, 2.0]) + + def test_unknown_easyscience_class_falls_back(self): + """A class missing from its module is left to the generic decoder.""" + encoded = {'@module': 'easyscience.variable', '@class': 'NoSuchClass', 'x': 1} + + assert SerializerDict.decode(encoded) == encoded + + def test_easyscience_non_new_base_uses_generic_path(self): + """EasyScience classes outside NewBase are rebuilt from their kwargs.""" + from easyscience.fitting.calculators.interface_factory import ItemContainer + + encoded = { + '@module': 'easyscience.fitting.calculators.interface_factory', + '@class': 'ItemContainer', + 'link_name': 'link', + 'name_conversion': {'a': 'b'}, + 'getter_fn': None, + 'setter_fn': None, + } + + result = SerializerDict.decode(encoded) + + assert isinstance(result, ItemContainer) + assert result.link_name == 'link' diff --git a/tests/unit/job/__init__.py b/tests/unit/job/__init__.py new file mode 100644 index 00000000..4e798e20 --- /dev/null +++ b/tests/unit/job/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause diff --git a/tests/unit/job/test_job.py b/tests/unit/job/test_job.py new file mode 100644 index 00000000..d6cf4fb4 --- /dev/null +++ b/tests/unit/job/test_job.py @@ -0,0 +1,169 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import numpy as np +import pytest + +from easyscience import global_object +from easyscience.base_classes import ModelBase +from easyscience.fitting.calculators.interface_factory import InterfaceFactoryTemplate +from easyscience.job import AnalysisBase +from easyscience.job import ExperimentBase +from easyscience.job import JobBase +from easyscience.job import TheoreticalModelBase + + +@pytest.fixture(autouse=True) +def clear(): + global_object.map._clear() + yield + global_object.map._clear() + + +class Analysis(AnalysisBase): + def calculate_theory(self, x, **kwargs): + return super().calculate_theory(x, **kwargs) + + def fit(self, x, y, e, **kwargs): + return super().fit(x, y, e, **kwargs) + + +class Job(JobBase): + @JobBase.theoretical_model.setter + def theoretical_model(self, theory): + JobBase.theoretical_model.fset(self, theory) + + @JobBase.experiment.setter + def experiment(self, experiment): + JobBase.experiment.fset(self, experiment) + + @JobBase.analysis.setter + def analysis(self, analysis): + JobBase.analysis.fset(self, analysis) + + def calculate_theory(self, *args, **kwargs): + return super().calculate_theory(*args, **kwargs) + + def fit(self, *args, **kwargs): + return super().fit(*args, **kwargs) + + +class CalculatorA: + name = 'A' + + def create(self, model): + return [] + + def fit_func(self, *args, **kwargs): + return None + + +class CalculatorB(CalculatorA): + name = 'B' + + +@pytest.mark.parametrize('cls', [Analysis, ExperimentBase, Job, TheoreticalModelBase]) +def test_job_classes_are_model_bases(cls): + obj = cls(display_name='name', unique_name=f'{cls.__name__}_unique') + + assert isinstance(obj, ModelBase) + assert obj.display_name == 'name' + assert obj.unique_name == f'{cls.__name__}_unique' + assert obj.get_all_variables() == [] + + +class TestAnalysisBase: + def test_defaults(self): + analysis = Analysis(display_name='analysis') + + assert analysis.interface is None + assert analysis.calculator is None + assert analysis.minimizer is None + assert str(analysis) == 'Analysis: analysis' + + def test_calculator_comes_from_interface(self): + interface = InterfaceFactoryTemplate([CalculatorA, CalculatorB]) + + analysis = Analysis(interface=interface) + + assert analysis.calculator == 'A' + + def test_calculator_setter_switches_interface(self): + interface = InterfaceFactoryTemplate([CalculatorA, CalculatorB]) + analysis = Analysis(interface=interface) + + analysis.calculator = 'B' + + assert interface.current_interface_name == 'B' + + def test_minimizer_setter(self): + analysis = Analysis() + minimizer = object() + + analysis.minimizer = minimizer + + assert analysis.minimizer is minimizer + + def test_abstract_methods_raise(self): + analysis = Analysis() + x = np.array([1.0]) + + with pytest.raises(NotImplementedError, match='calculate_theory'): + analysis.calculate_theory(x) + with pytest.raises(NotImplementedError, match='fit'): + analysis.fit(x, x, x) + + def test_cannot_instantiate_abstract_base(self): + with pytest.raises(TypeError): + AnalysisBase() + + +class TestExperimentBase: + def test_str(self): + assert str(ExperimentBase(display_name='experiment')) == 'Experiment: experiment' + + def test_str_falls_back_to_unique_name(self): + experiment = ExperimentBase(unique_name='exp_1') + + assert str(experiment) == 'Experiment: exp_1' + + +class TestTheoreticalModelBase: + def test_str_not_implemented(self): + with pytest.raises(NotImplementedError): + str(TheoreticalModelBase()) + + +class TestJobBase: + def test_defaults(self): + job = Job(display_name='job') + + assert job.theoretical_model is None + assert job.experiment is None + assert job.analysis is None + + @pytest.mark.parametrize( + ('attribute', 'value_factory'), + [ + ('theoretical_model', TheoreticalModelBase), + ('experiment', ExperimentBase), + ('analysis', Analysis), + ], + ) + def test_base_setters_are_abstract(self, attribute, value_factory): + job = Job() + + with pytest.raises(NotImplementedError, match='setter not implemented'): + setattr(job, attribute, value_factory()) + + def test_abstract_methods_raise(self): + job = Job() + + with pytest.raises(NotImplementedError, match='calculate_theory'): + job.calculate_theory() + with pytest.raises(NotImplementedError, match='fit'): + job.fit() + + def test_cannot_instantiate_abstract_base(self): + with pytest.raises(TypeError): + JobBase() From 94f8293d34c3d8b7e52b06b3f7cac8d8d60d01df Mon Sep 17 00:00:00 2001 From: rozyczko Date: Wed, 30 Sep 2026 14:04:32 +0200 Subject: [PATCH 08/11] clean up the code a tad --- .github/workflows/pypi-test.yml | 4 ---- .github/workflows/test.yml | 34 --------------------------- src/easyscience/io/serializer_dict.py | 6 ++--- 3 files changed, 3 insertions(+), 41 deletions(-) diff --git a/.github/workflows/pypi-test.yml b/.github/workflows/pypi-test.yml index ba9d1b32..2c97a4ef 100644 --- a/.github/workflows/pypi-test.yml +++ b/.github/workflows/pypi-test.yml @@ -69,10 +69,6 @@ jobs: working-directory: easyscience run: pixi run python -m pytest ../tests/unit/ --color=yes -v - # - name: Run functional tests to verify the installation - # working-directory: easyscience - # run: pixi run python -m pytest ../tests/functional/ --color=yes -v - - name: Run integration tests to verify the installation working-directory: easyscience run: pixi run python -m pytest ../tests/integration/ --color=yes -n auto diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 14a18ba3..b04d87bd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -102,21 +102,6 @@ jobs: pixi run --environment $env unit-tests done - # - name: Run functional tests - # shell: bash - # run: | - # set -euo pipefail - - # for py_ver in $PY_VERSIONS; do - # echo - # echo "🔹🔸🔹🔸🔹 Python: $py_ver 🔹🔸🔹🔸🔹" - - # env="py-$(echo $py_ver | tr -d .)-env" # Converts 3.XX -> py-3XX-env - - # echo "Running tests in environment: $env" - # pixi run --environment $env functional-tests - # done - - name: Run integration tests ${{ needs.env-prepare.outputs.pytest-marks }} shell: bash run: | @@ -258,25 +243,6 @@ jobs: cd .. done - # - name: Run functional tests - # shell: bash - # run: | - # set -euo pipefail - - # for py_ver in $PY_VERSIONS; do - # echo - # echo "🔹🔸🔹🔸🔹 Python: $py_ver 🔹🔸🔹🔸🔹" - - # echo "Entering pixi project directory easyscience_py$py_ver" - # cd easyscience_py$py_ver - - # echo "Running tests" - # pixi run python -m pytest ../tests/functional/ --color=yes -v - - # echo "Exiting pixi project directory" - # cd .. - # done - - name: Run integration tests ${{ needs.env-prepare.outputs.pytest-marks }} shell: bash run: | diff --git a/src/easyscience/io/serializer_dict.py b/src/easyscience/io/serializer_dict.py index 13951335..0b8ff478 100644 --- a/src/easyscience/io/serializer_dict.py +++ b/src/easyscience/io/serializer_dict.py @@ -78,9 +78,9 @@ def decode(cls, d: Dict[str, Any]) -> SerializerComponent: # ``decode`` also receives values that are not EasyScience # objects: plain dicts and lists, ``None``, and encoded numpy # arrays or datetimes. Those have no ``@module``/``@class`` - # keys, or name a module outside the ``easy*`` packages, and - # must stay on the generic path below. Without the check, - # ``d['@module']`` would fail for plain dicts, lists and None. + # keys, or name a module outside the ``easy*`` packages. + # Without the check, ``d['@module']`` would fail for + # plain dicts, lists and None. # Local import to avoid a circular dependency from ..base_classes.new_base import NewBase From 0f1fb8bf11bebb5ac7f22b6310772f39abf98381 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 5 Oct 2026 14:31:54 +0200 Subject: [PATCH 09/11] extensive CR fixes --- src/easyscience/base_classes/easy_list.py | 33 ++-- src/easyscience/base_classes/model_base.py | 2 +- src/easyscience/base_classes/new_base.py | 9 +- .../fitting/calculators/interface_factory.py | 2 +- src/easyscience/io/__init__.py | 4 - src/easyscience/io/serializer_base.py | 70 ++++--- src/easyscience/io/serializer_component.py | 123 ------------ src/easyscience/io/serializer_dict.py | 96 ---------- tests/helpers/easy_test_models.py | 58 +++++- tests/unit/base_classes/test_easy_list.py | 45 ++++- .../base_classes/test_model_with_easy_list.py | 21 +- tests/unit/base_classes/test_new_base.py | 28 +++ tests/unit/global_object/test_map.py | 26 ++- tests/unit/io/test_serializer_base.py | 46 +++-- tests/unit/io/test_serializer_dict.py | 180 ------------------ tests/unit/variable/test_descriptor_base.py | 1 - ...test_parameter_dependency_serialization.py | 48 ++--- 17 files changed, 246 insertions(+), 546 deletions(-) delete mode 100644 src/easyscience/io/serializer_component.py delete mode 100644 src/easyscience/io/serializer_dict.py delete mode 100644 tests/unit/io/test_serializer_dict.py diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py index 33358690..c48756db 100644 --- a/src/easyscience/base_classes/easy_list.py +++ b/src/easyscience/base_classes/easy_list.py @@ -364,39 +364,32 @@ def pop(self, index: int | str = -1) -> ProtectedType_: # Serialization support - def _convert_to_dict( - self, dict_repr: dict, encoder: Any, skip: Any = None, **kwargs: Any - ) -> dict: + def to_dict(self, skip: list[str] | None = None) -> dict: """ - Add the list items and protected types to a serialized dict. + Convert the EasyList to a dictionary for serialization. - The serializer calls this hook, so the contents are written both - by ``to_dict`` and when the list is nested in another object. + The serializer calls ``to_dict`` on nested EasyScience objects. Parameters ---------- - dict_repr : dict - Dictionary produced so far by the serializer. - encoder : Any - The serializer performing the conversion. - skip : Any, default=None - Field names to skip, forwarded to each list item. - **kwargs : Any - Additional keyword arguments from the serializer. Unused. + skip : list[str] | None, default=None + List of field names as strings to skip when forming the + dictionary. By default, None. Returns ------- dict - The dictionary with ``protected_types`` and ``data`` added. + Dictionary representation of the EasyList. """ - if skip is None: - skip = [] + skip = list(skip or []) + # protected_types holds classes, which the generic encoder + # cannot handle; they are encoded explicitly below. + dict_repr = super().to_dict(skip=[*skip, 'protected_types']) if self._protected_types != [NewBase]: dict_repr['protected_types'] = [ {'@module': cls_.__module__, '@class': cls_.__name__} for cls_ in self._protected_types ] # noqa: E501 - # Each item gets its own copy, as ``to_dict`` appends to ``skip`` dict_repr['data'] = [item.to_dict(skip=list(skip)) for item in self._data] return dict_repr @@ -408,8 +401,8 @@ def from_dict(cls, obj_dict: Dict[str, Any]) -> NewBase: Parameters ---------- obj_dict : Dict[str, Any] - Dictionary containing the serialized contents (from - ``SerializerDict``) of an EasyScience object. + Dictionary containing the serialized contents + of an EasyScience object. Returns ------- diff --git a/src/easyscience/base_classes/model_base.py b/src/easyscience/base_classes/model_base.py index dee45ba5..cc5bc2ff 100644 --- a/src/easyscience/base_classes/model_base.py +++ b/src/easyscience/base_classes/model_base.py @@ -117,7 +117,7 @@ def from_dict(cls, obj_dict: Dict[str, Any]) -> ModelBase: ---------- obj_dict : Dict[str, Any] Dictionary containing the serialized contents (from - ``SerializerDict``) of an EasyScience object. + ``to_dict``) of an EasyScience object. Returns ------- diff --git a/src/easyscience/base_classes/new_base.py b/src/easyscience/base_classes/new_base.py index 106e6f3c..0354392b 100644 --- a/src/easyscience/base_classes/new_base.py +++ b/src/easyscience/base_classes/new_base.py @@ -147,12 +147,7 @@ def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: ``None``. Pass ``skip`` to drop further fields. """ serializer = SerializerBase() - if skip is None: - skip = [] - if self._default_unique_name and 'unique_name' not in skip: - skip.append('unique_name') - if self._display_name is None: - skip.append('display_name') + skip = list(skip) if skip else [] return serializer._convert_to_dict(self, skip=skip, full_encode=False) @classmethod @@ -164,7 +159,7 @@ def from_dict(cls, obj_dict: Dict[str, Any]) -> NewBase: ---------- obj_dict : Dict[str, Any] Dictionary containing the serialized contents (from - ``SerializerDict``) of an EasyScience object. + ``to_dict``) of an EasyScience object. Returns ------- diff --git a/src/easyscience/fitting/calculators/interface_factory.py b/src/easyscience/fitting/calculators/interface_factory.py index 47878353..772131d3 100644 --- a/src/easyscience/fitting/calculators/interface_factory.py +++ b/src/easyscience/fitting/calculators/interface_factory.py @@ -68,7 +68,7 @@ def switch(self, new_interface: str, fitter: Optional[Type[Fitter]] = None) -> N """ Changes the current interface to a new interface. - The current interface is destroyed and all SerializerComponent + The current interface is destroyed and all parameters carried over to the new interface. i.e. pick up where you left off. diff --git a/src/easyscience/io/__init__.py b/src/easyscience/io/__init__.py index 6a53db96..85bce3e6 100644 --- a/src/easyscience/io/__init__.py +++ b/src/easyscience/io/__init__.py @@ -2,11 +2,7 @@ # SPDX-License-Identifier: BSD-3-Clause from .serializer_base import SerializerBase -from .serializer_component import SerializerComponent -from .serializer_dict import SerializerDict __all__ = [ SerializerBase, - SerializerComponent, - SerializerDict, ] diff --git a/src/easyscience/io/serializer_base.py b/src/easyscience/io/serializer_base.py index f0b66612..5d9cb419 100644 --- a/src/easyscience/io/serializer_base.py +++ b/src/easyscience/io/serializer_base.py @@ -9,20 +9,15 @@ from enum import Enum from importlib import import_module from inspect import getfullargspec -from typing import TYPE_CHECKING from typing import Any from typing import Callable from typing import Dict from typing import List from typing import MutableSequence -from typing import Optional from typing import Tuple import numpy as np -if TYPE_CHECKING: - from .serializer_component import SerializerComponent - _e = json.JSONEncoder() @@ -33,20 +28,19 @@ class SerializerBase: ``encode`` and ``decode`` are abstract methods to be implemented for each serializer. It is expected that the helper function - `_convert_to_dict` will be used as a base for encoding (or the - ``SerializerDict`` as it's more flexible). + `_convert_to_dict` will be used as a base for encoding. """ @abstractmethod - def encode(self, obj: SerializerComponent, skip: Optional[List[str]] = None, **kwargs) -> any: + def encode(self, obj: Any, skip: List[str] | None = None, **kwargs) -> any: """ Abstract implementation of an encoder. Parameters ---------- - obj : SerializerComponent + obj : Any Object to be encoded. - skip : Optional[List[str]], default=None + skip : List[str] | None, default=None List of field names as strings to skip when forming the encoded object. By default, None. **kwargs : @@ -67,7 +61,6 @@ def encode(self, obj: SerializerComponent, skip: Optional[List[str]] = None, **k def decode(cls, obj: Any) -> Any: """ Re-create an EasyScience object from the output of an encoder. - The default decoder is ``SerializerDict``. Parameters ---------- @@ -148,14 +141,19 @@ def _encode_objs(obj: Any) -> Dict[str, Any]: def _convert_to_dict( self, - obj: SerializerComponent, - skip: Optional[List[str]] = None, + obj: Any, + skip: List[str] | None = None, full_encode: bool = False, **kwargs, ) -> dict: """A JSON serializable dict representation of an object.""" if skip is None: skip = [] + own_skip = list(skip) + if getattr(obj, '_default_unique_name', False) and 'unique_name' not in own_skip: + own_skip.append('unique_name') + if hasattr(obj, '_display_name') and obj._display_name is None: + own_skip.append('display_name') if full_encode: new_obj = SerializerBase._encode_objs(obj) @@ -184,7 +182,7 @@ def runner(o): return o for c in args: - if c not in skip: + if c not in own_skip: if c in redirect.keys(): if redirect[c] is None: continue @@ -196,25 +194,14 @@ def runner(o): try: a = runner(obj.__getattribute__('_' + c)) except AttributeError: - err = True - if hasattr(obj, 'kwargs'): - # type: ignore - option = getattr(obj, 'kwargs') - if hasattr(option, c): - v = getattr(option, c) - delattr(option, c) - d.update(runner(v)) # pylint: disable=E1101 - err = False - if err: - raise NotImplementedError( - 'Unable to automatically determine to_dict ' - 'format from class. MSONAble requires all ' - 'args to be present as either self.argname or ' - 'self._argname, and kwargs to be present under' - 'a self.kwargs variable to automatically ' - 'determine the dict format. Alternatively, ' - 'you can implement both to_dict and from_dict.' - ) + raise NotImplementedError( + 'Unable to automatically determine to_dict ' + 'format from class. All args must be present ' + 'as either self.argname or self._argname to ' + 'automatically determine the dict format. ' + 'Alternatively, you can implement both to_dict ' + 'and from_dict.' + ) from None d[c] = self._recursive_encoder( a, skip=skip, encoder=self, full_encode=full_encode, **kwargs ) @@ -224,7 +211,11 @@ def runner(o): d.update({'value': runner(obj.value)}) # pylint: disable=E1101 if hasattr(obj, '_convert_to_dict'): d = obj._convert_to_dict(d, self, skip=skip, **kwargs) - if hasattr(obj, '_global_object') and 'unique_name' not in d and 'unique_name' not in skip: + if ( + hasattr(obj, '_global_object') + and 'unique_name' not in d + and 'unique_name' not in own_skip + ): d['unique_name'] = obj.unique_name return d @@ -275,7 +266,9 @@ def _convert_from_dict(d: Any) -> Any: return np.array(d['data'], dtype=d['dtype']) if issubclass(T_, (list, MutableSequence)): - return [SerializerBase._convert_from_dict(x) for x in d] + # Elements that are serialized EasyScience objects are rebuilt + # by their own ``from_dict``; anything else is decoded here. + return [SerializerBase._deserialize_value(x) for x in d] return d @staticmethod @@ -405,7 +398,10 @@ def _recursive_encoder( ): # strings have encode return encoder._convert_to_dict(obj, skip, full_encode, **kwargs) elif hasattr(obj, 'to_dict') and obj.__class__.__module__.startswith('easy'): - return encoder._convert_to_dict(obj, skip, full_encode, **kwargs) + # EasyScience objects serialize themselves, so that + # ``to_dict`` overrides (e.g. EasyList, Parameter) apply + # when the object is nested as well as at the top level. + return obj.to_dict(skip=list(skip)) else: return [ self._recursive_encoder(it, skip, encoder, full_encode, **kwargs) for it in obj @@ -420,5 +416,5 @@ def _recursive_encoder( ): # strings have encode return encoder._convert_to_dict(obj, skip, full_encode, **kwargs) elif hasattr(obj, 'to_dict') and obj.__class__.__module__.startswith('easy'): - return encoder._convert_to_dict(obj, skip, full_encode, **kwargs) + return obj.to_dict(skip=list(skip)) return obj diff --git a/src/easyscience/io/serializer_component.py b/src/easyscience/io/serializer_component.py deleted file mode 100644 index 9ef079ab..00000000 --- a/src/easyscience/io/serializer_component.py +++ /dev/null @@ -1,123 +0,0 @@ -# SPDX-FileCopyrightText: 2025 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -from typing import TYPE_CHECKING -from typing import Any -from typing import Dict -from typing import List -from typing import Optional - -from .serializer_dict import SerializerDict - -if TYPE_CHECKING: - from .serializer_base import SerializerBase - - -class SerializerComponent: - """ - This base class adds the capability of saving and loading - (encoding/decoding, serializing/deserializing) easyscience objects - via the ``encode`` and ``decode`` methods. The default encoder is - ``SerializerDict``, which converts the object to a dictionary. - - Shortcuts for dictionary and encoding is also present. - """ - - def __deepcopy__(self, memo): - return self.from_dict(self.to_dict()) - - def encode( - self, skip: Optional[List[str]] = None, encoder: Optional[SerializerBase] = None, **kwargs - ) -> Any: - """ - Use an encoder to covert an EasyScience object into another - format. Default is to a dictionary using ``SerializerDict``. - - Parameters - ---------- - skip : Optional[List[str]], default=None - List of field names as strings to skip when forming the - encoded object. By default, None. - encoder : Optional[SerializerBase], default=None - The encoder to be used for encoding the data. By default, - None. - **kwargs : - Any additional key word arguments to be passed to the - encoder. - - Returns - ------- - Any - Encoded object containing all information to reform an - EasyScience object. - """ - if encoder is None: - encoder = SerializerDict - encoder_obj = encoder() - return encoder_obj.encode(self, skip=skip, **kwargs) - - @classmethod - def decode(cls, obj: Any, decoder: Optional[SerializerBase] = None) -> Any: - """ - Re-create an EasyScience object from the output of an encoder. - The default decoder is ``SerializerDict``. - - Parameters - ---------- - obj : Any - Encoded EasyScience object. - decoder : Optional[SerializerBase], default=None - Decoder to be used to reform the EasyScience object. By - default, None. - - Returns - ------- - Any - Reformed EasyScience object. - """ - - if decoder is None: - decoder = SerializerDict - return decoder.decode(obj) - - def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: - """ - Convert an EasyScience object into a full dictionary using - ``SerializerDict``. This is a shortcut for - ```obj.encode(encoder=SerializerDict)``` - - Parameters - ---------- - skip : Optional[List[str]], default=None - List of field names as strings to skip when forming - the dictionary. By default, None. - - Returns - ------- - Dict[str, Any] - Encoded object containing all information to reform an - EasyScience object. - """ - - return self.encode(skip=skip, encoder=SerializerDict) - - @classmethod - def from_dict(cls, obj_dict: Dict[str, Any]) -> Any: - """ - Re-create an EasyScience object from a full encoded dictionary. - - Parameters - ---------- - obj_dict : Dict[str, Any] - Dictionary containing the serialized contents (from - ``SerializerDict``) of an EasyScience object. - - Returns - ------- - Any - Reformed EasyScience object. - """ - - return cls.decode(obj_dict, decoder=SerializerDict) diff --git a/src/easyscience/io/serializer_dict.py b/src/easyscience/io/serializer_dict.py deleted file mode 100644 index 0b8ff478..00000000 --- a/src/easyscience/io/serializer_dict.py +++ /dev/null @@ -1,96 +0,0 @@ -# SPDX-FileCopyrightText: 2025 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from __future__ import annotations - -__author__ = 'https://github.com/materialsvirtuallab/monty/blob/master/monty/json.py' -__version__ = '3.0.0' - - -from typing import TYPE_CHECKING -from typing import Any -from typing import Dict -from typing import List -from typing import Optional - -from .serializer_base import SerializerBase - -if TYPE_CHECKING: - from .serializer_component import SerializerComponent - - -class SerializerDict(SerializerBase): - """ - This is a serializer that can encode and decode EasyScience objects - to and from a dictionary. - """ - - def encode( - self, - obj: SerializerComponent, - skip: Optional[List[str]] = None, - full_encode: bool = False, - **kwargs: Any, - ) -> Dict[str, Any]: - """ - Convert an EasyScience object to a dictionary. - - Parameters - ---------- - obj : SerializerComponent - Object to be encoded. - skip : Optional[List[str]], default=None - List of field names as strings to skip when forming the - encoded object. By default, None. - full_encode : bool, default=False - Should the data also be encoded (default False). By default, - False. - **kwargs : Any - Any additional key word arguments to be passed to the - encoder. - - Returns - ------- - Dict[str, Any] - Object encoded to dictionary containing all information to - reform an EasyScience object. - """ - - return self._convert_to_dict(obj, skip=skip, full_encode=full_encode, **kwargs) - - @classmethod - def decode(cls, d: Dict[str, Any]) -> SerializerComponent: - """ - Re-create an EasyScience object from the dictionary - representation. - - Parameters - ---------- - d : Dict[str, Any] - Dict representation of an EasyScience object. - - Returns - ------- - SerializerComponent - EasyScience object. - """ - if SerializerBase._is_serialized_easyscience_object(d): - # ``decode`` also receives values that are not EasyScience - # objects: plain dicts and lists, ``None``, and encoded numpy - # arrays or datetimes. Those have no ``@module``/``@class`` - # keys, or name a module outside the ``easy*`` packages. - # Without the check, ``d['@module']`` would fail for - # plain dicts, lists and None. - - # Local import to avoid a circular dependency - from ..base_classes.new_base import NewBase - - try: - cls_ = SerializerBase._import_class(d['@module'], d['@class']) - except (ImportError, ValueError): - cls_ = None - # NewBase subclasses know how to rebuild nested members - # (e.g. EasyList protected types), so defer to their from_dict. - if isinstance(cls_, type) and issubclass(cls_, NewBase): - return cls_.from_dict(d) - return SerializerBase._convert_from_dict(d) diff --git a/tests/helpers/easy_test_models.py b/tests/helpers/easy_test_models.py index 55e196a5..4c65a316 100644 --- a/tests/helpers/easy_test_models.py +++ b/tests/helpers/easy_test_models.py @@ -14,8 +14,6 @@ from __future__ import annotations from typing import Iterable -from typing import Optional -from typing import Union import numpy as np @@ -35,20 +33,20 @@ class CoefficientModel(ModelBase): Parameters ---------- - display_name : Optional[str], default=None + display_name : str | None, default=None Display name of the model. - coefficients : Optional[Union[Iterable[Union[float, Parameter]], EasyList]], default=None + coefficients : Iterable[float | Parameter] | EasyList | None, default=None Initial coefficients, either numbers/``Parameter`` objects or a ready-made ``EasyList``. - unique_name : Optional[str], default=None + unique_name : str | None, default=None Unique name of the model. """ def __init__( self, - display_name: Optional[str] = None, - coefficients: Optional[Union[Iterable[Union[float, Parameter]], EasyList]] = None, - unique_name: Optional[str] = None, + display_name: str | None = None, + coefficients: Iterable[float | Parameter] | EasyList | None = None, + unique_name: str | None = None, ): super().__init__(unique_name=unique_name, display_name=display_name) if isinstance(coefficients, EasyList): @@ -67,3 +65,47 @@ def coefficients(self) -> EasyList: def __call__(self, x: np.ndarray, *args, **kwargs) -> np.ndarray: return np.polyval([c.value for c in self.coefficients], x) + + +class PairModel(ModelBase): + """ + Model holding two parameters as plain attributes. + + Used to check that a nested ``Parameter`` is serialized through its + own ``to_dict``, so dependency information survives a round trip. + + Following the ``ModelBase.from_dict`` convention, a plain number is + wrapped in a new ``Parameter`` while a ``Parameter`` is used as is. + + Parameters + ---------- + a : float | Parameter, default=0.0 + First parameter. + b : float | Parameter, default=0.0 + Second parameter, possibly dependent on ``a``. + unique_name : str | None, default=None + Unique name of the model. + display_name : str | None, default=None + Display name of the model. + """ + + def __init__( + self, + a: float | Parameter = 0.0, + b: float | Parameter = 0.0, + unique_name: str | None = None, + display_name: str | None = None, + ): + super().__init__(unique_name=unique_name, display_name=display_name) + self._a = a if isinstance(a, Parameter) else Parameter(value=float(a), display_name='a') + self._b = b if isinstance(b, Parameter) else Parameter(value=float(b), display_name='b') + + @property + def a(self) -> Parameter: + """First parameter.""" + return self._a + + @property + def b(self) -> Parameter: + """Second parameter.""" + return self._b diff --git a/tests/unit/base_classes/test_easy_list.py b/tests/unit/base_classes/test_easy_list.py index c08a1b2c..96823f70 100644 --- a/tests/unit/base_classes/test_easy_list.py +++ b/tests/unit/base_classes/test_easy_list.py @@ -577,15 +577,54 @@ def test_to_dict_forwards_skip_to_items(self): assert 'display_name' not in d['data'][0] assert d['data'][0]['unique_name'] == 'a1' - def test_convert_to_dict_without_skip(self): - """The serializer hook also works when called without ``skip``.""" + def test_to_dict_unnamed_list_keeps_item_names(self): + """A list without its own names must not strip names from its items.""" + a1 = Alpha(unique_name='a1', display_name='first') + el = EasyList(a1, protected_types=Alpha) # auto unique_name, no display_name + + d = el.to_dict() + + assert 'unique_name' not in d + assert 'display_name' not in d + assert d['data'][0]['unique_name'] == 'a1' + assert d['data'][0]['display_name'] == 'first' + + def test_to_dict_does_not_mutate_skip(self): + el = EasyList(Alpha(unique_name='a1'), protected_types=Alpha) + skip = ['display_name'] + + el.to_dict(skip=skip) + + assert skip == ['display_name'] + + def test_to_dict_without_skip(self): + """``to_dict`` works when called without ``skip``.""" a1 = Alpha(unique_name='a1') el = EasyList(a1, protected_types=Alpha) - d = el._convert_to_dict({}, encoder=None) + d = el.to_dict() assert d['data'][0]['unique_name'] == 'a1' assert d['protected_types'][0]['@class'] == 'Alpha' + assert not hasattr(el, '_convert_to_dict') + + def test_to_dict_nested_list_uses_override(self): + """A list held by another object is serialized by its own ``to_dict``.""" + inner = EasyList(Alpha(unique_name='a1'), protected_types=Alpha) + outer = EasyList(inner, unique_name='outer') + + d = outer.to_dict() + + nested = d['data'][0] + assert nested['@class'] == 'EasyList' + assert nested['protected_types'][0]['@class'] == 'Alpha' + assert nested['data'][0]['unique_name'] == 'a1' + + global_object.map._clear() + outer2 = EasyList.from_dict(d) + assert isinstance(outer2[0], EasyList) + assert outer2[0]._protected_types == [Alpha] + assert outer2[0][0].unique_name == 'a1' # --- get_all_variables --- 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 a95b4dde..5bc9d50e 100644 --- a/tests/unit/base_classes/test_model_with_easy_list.py +++ b/tests/unit/base_classes/test_model_with_easy_list.py @@ -10,7 +10,6 @@ from easyscience import global_object from easyscience.base_classes import EasyList -from easyscience.io.serializer_dict import SerializerDict from easyscience.variable import Parameter @@ -80,16 +79,20 @@ def test_dict_round_trip(): assert [c.display_name for c in new_model.coefficients] == ['c0', 'c1', 'c2'] -def test_serializer_dict_round_trip(): - """The public dictionary decoder handles the typed EasyList.""" - model = CoefficientModel(display_name='round_trip', coefficients=[1.0, 2.0]) +def test_dict_round_trip_unnamed_model_keeps_coefficient_names(): + """The model's own missing names must not be skipped on nested items.""" + model = CoefficientModel( + coefficients=[Parameter(1.0, display_name='c0', unique_name='my_c0'), 2.0] + ) - new_model = SerializerDict.decode(model.to_dict()) + d = model.to_dict() + global_object.map._clear() # the explicit name is kept, so free it first + new_model = CoefficientModel.from_dict(d) - assert isinstance(new_model, CoefficientModel) - assert new_model.display_name == 'round_trip' - assert [c.value for c in new_model.coefficients] == [1.0, 2.0] - assert new_model.coefficients._protected_types == [Parameter] + assert 'unique_name' not in d + assert 'display_name' not in d + assert [c.display_name for c in new_model.coefficients] == ['c0', 'c1'] + assert new_model.coefficients[0].unique_name == 'my_c0' @pytest.mark.parametrize('copier', [copy.copy, copy.deepcopy], ids=['copy', 'deepcopy']) diff --git a/tests/unit/base_classes/test_new_base.py b/tests/unit/base_classes/test_new_base.py index e1562860..fdda04f1 100644 --- a/tests/unit/base_classes/test_new_base.py +++ b/tests/unit/base_classes/test_new_base.py @@ -119,6 +119,34 @@ def test_to_dict_default_params(self): assert 'unique_name' not in obj_dict assert 'display_name' not in obj_dict + def test_to_dict_does_not_mutate_skip(self): + obj = NewBase() + skip = ['foo'] + obj.to_dict(skip=skip) + assert skip == ['foo'] + + def test_to_dict_nested_object_keeps_own_names(self): + """A parent without names must not strip names from a nested object.""" + + class Holder(NewBase): + def __init__(self, item=None, unique_name=None, display_name=None): + super().__init__(unique_name=unique_name, display_name=display_name) + self._item = item + + @property + def item(self): + return self._item + + inner = NewBase(unique_name='inner', display_name='Inner') + outer = Holder(item=inner) # auto unique_name, no display_name + + d = outer.to_dict() + + assert 'unique_name' not in d + assert 'display_name' not in d + assert d['item']['unique_name'] == 'inner' + assert d['item']['display_name'] == 'Inner' + def test_to_dict_with_skip(self): # When obj = NewBase(unique_name='skip_test', display_name='Skip Test Object') diff --git a/tests/unit/global_object/test_map.py b/tests/unit/global_object/test_map.py index 45ba82d3..4062db2f 100644 --- a/tests/unit/global_object/test_map.py +++ b/tests/unit/global_object/test_map.py @@ -6,9 +6,9 @@ import pytest -from easyscience import ModelBase 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 @@ -137,7 +137,7 @@ def clear(self): @pytest.fixture def base_object(self): - return ModelBase(display_name='test') + return NewBase(display_name='test') @pytest.fixture def parameter_object(self): @@ -160,7 +160,7 @@ def test_clear(self, clear, base_object): def test_weakref(self, clear): # When - test_obj = ModelBase(display_name='test') + test_obj = NewBase(display_name='test') assert len(global_object.map._store) == 1 assert len(global_object.map._Map__type_dict) == 1 # Then @@ -183,7 +183,7 @@ def test_get_item_by_key(self, clear, base_object, parameter_object): assert global_object.map.get_item_by_key(parameter_object.unique_name) == parameter_object @pytest.mark.parametrize( - 'cls, kwargs', [(ModelBase, {'display_name': 'test'}), (Parameter, {'value': 2.0})] + 'cls, kwargs', [(NewBase, {'display_name': 'test'}), (Parameter, {'value': 2.0})] ) def test_identical_unique_names_exception(self, clear, cls, kwargs): # When @@ -194,13 +194,13 @@ def test_identical_unique_names_exception(self, clear, cls, kwargs): def test_unique_name_change_still_in_map(self, clear, base_object, parameter_object): # When - assert global_object.map.get_item_by_key('ModelBase_0') == base_object + 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('ModelBase_0') == base_object + 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 @@ -210,14 +210,12 @@ def test_add_vertex_duplicate_name_error(self, clear, base_object): # When/Then with pytest.raises(ValueError, match='already exists'): # Try to add another object with same unique_name - duplicate_obj = ModelBase( - display_name='duplicate', unique_name=base_object.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 = ModelBase(display_name='test') + obj = NewBase(display_name='test') # When - Object is automatically added during construction # Then @@ -253,7 +251,7 @@ def test_find_type_unknown_object(self, clear): def test_returned_objs_access_safe_under_modification(self, clear): """Ensure accessing returned_objs doesn't raise when entries change size during iteration.""" - objs = [ModelBase(display_name=f'race_{i}') for i in range(8)] + 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') @@ -384,7 +382,7 @@ def test_edges_generation(self, clear, base_object, parameter_object): def test_type_filtering_properties(self, clear): """Test type filtering properties""" # Given - obj1 = ModelBase(display_name='obj1') # 'created' type + obj1 = NewBase(display_name='obj1') # 'created' type obj2 = Parameter(display_name='obj2', value=1) # 'created' type global_object.map.change_type(obj1, 'argument') @@ -497,7 +495,7 @@ def test_get_item_by_key_not_found(self, clear): def test_clear_with_finalizers(self, clear): """Test clearing map properly calls finalizers""" # Given - obj = ModelBase(display_name='test') + obj = NewBase(display_name='test') original_count = len(global_object.map.vertices()) # When @@ -695,7 +693,7 @@ def test_prune_vertex_from_edge_parent_not_in_map(self, clear, parameter_object) def test_created_internal_property(self, clear): """Test created_internal property.""" # Given - obj = ModelBase(display_name='internal_obj') + obj = NewBase(display_name='internal_obj') global_object.map.change_type(obj, 'created_internal') # When diff --git a/tests/unit/io/test_serializer_base.py b/tests/unit/io/test_serializer_base.py index 43c9fe67..93085eb4 100644 --- a/tests/unit/io/test_serializer_base.py +++ b/tests/unit/io/test_serializer_base.py @@ -18,7 +18,13 @@ from easyscience import global_object from easyscience.base_classes import NewBase from easyscience.io import SerializerBase -from easyscience.io import SerializerComponent + + +class MockSerializable: + """Minimal stand-in for an object that knows how to encode itself.""" + + def encode(self, skip: Optional[List[str]] = None, encoder=None, **kwargs) -> Any: + return (encoder or ConcreteSerializer)().encode(self, skip=skip, **kwargs) class TestEnum(Enum): @@ -26,7 +32,7 @@ class TestEnum(Enum): ANOTHER_VALUE = 42 -class MockSerializerComponent(SerializerComponent): +class MockSerializableObj(MockSerializable): """Mock serializer component for testing""" def __init__( @@ -39,7 +45,7 @@ def __init__( self._global_object = True -class MockSerializerWithRedirect(SerializerComponent): +class MockSerializerWithRedirect(MockSerializable): """Mock with _REDIRECT for testing redirect functionality""" _REDIRECT = {'special_attr': lambda obj: obj.value * 2, 'none_attr': None} @@ -52,7 +58,7 @@ def __init__(self, name: str = 'test', value: int = 1, special_attr: int = 5): self._global_object = True -class MockSerializerWithConvertToDict(SerializerComponent): +class MockSerializerWithConvertToDict(MockSerializable): """Mock with custom _convert_to_dict method""" def __init__(self, name: str = 'test', value: int = 1): @@ -69,7 +75,7 @@ def _convert_to_dict(self, d, encoder, skip=None, **kwargs): class ConcreteSerializer(SerializerBase): """Concrete implementation for testing abstract methods""" - def encode(self, obj: SerializerComponent, skip: Optional[List[str]] = None, **kwargs) -> Any: + def encode(self, obj: Any, skip: Optional[List[str]] = None, **kwargs) -> Any: return self._convert_to_dict(obj, skip=skip, **kwargs) @classmethod @@ -95,7 +101,7 @@ def serializer(self): @pytest.fixture def mock_obj(self): - return MockSerializerComponent('test_obj', 42, 'optional_value') + return MockSerializableObj('test_obj', 42, 'optional_value') def test_abstract_methods_are_placeholders(self): """Test that SerializerBase can be instantiated but abstract methods just pass""" @@ -309,7 +315,7 @@ def test_convert_to_dict_with_enum_object(self, serializer, clear): """Test _convert_to_dict when the object itself is an enum""" # Test that enum values in objects remain as enums without full_encode - class MockObjWithEnum(SerializerComponent): + class MockObjWithEnum(MockSerializable): def __init__(self, name: str, enum_val: TestEnum): self.name = name self.enum_val = enum_val @@ -327,7 +333,7 @@ def test_convert_to_dict_full_encode(self, serializer, clear): """Test _convert_to_dict with full_encode=True""" dt = datetime.datetime(2023, 10, 17, 14, 30, 45) - class MockObjWithDateTime(SerializerComponent): + class MockObjWithDateTime(MockSerializable): def __init__(self, name: str, dt: datetime.datetime): self.name = name self.dt = dt @@ -344,7 +350,7 @@ def __init__(self, name: str, dt: datetime.datetime): def test_convert_to_dict_without_global_object(self, serializer): """Test _convert_to_dict with object without _global_object""" - class MockObjNoGlobal(SerializerComponent): + class MockObjNoGlobal(MockSerializable): def __init__(self, name: str): self.name = name @@ -357,7 +363,7 @@ def __init__(self, name: str): def test_convert_to_dict_with_arg_spec(self, serializer, clear): """Test _convert_to_dict with custom _arg_spec""" - class MockObjCustomArgSpec(SerializerComponent): + class MockObjCustomArgSpec(MockSerializable): def __init__(self, name: str, value: int, extra: str = 'default'): self.name = name self.value = value @@ -375,7 +381,7 @@ def __init__(self, name: str, value: int, extra: str = 'default'): def test_recursive_encoder_with_lists(self, serializer, clear): """Test _recursive_encoder with lists""" - mock_obj = MockSerializerComponent('list_test', 1) + mock_obj = MockSerializableObj('list_test', 1) data = [mock_obj, 'string', 42, {'key': 'value'}] result = serializer._recursive_encoder(data) @@ -390,7 +396,7 @@ def test_recursive_encoder_with_lists(self, serializer, clear): def test_recursive_encoder_with_dicts(self, serializer, clear): """Test _recursive_encoder with dictionaries""" - mock_obj = MockSerializerComponent('dict_test', 2) + mock_obj = MockSerializableObj('dict_test', 2) data = {'obj': mock_obj, 'simple': 'value'} result = serializer._recursive_encoder(data) @@ -402,7 +408,7 @@ def test_recursive_encoder_with_dicts(self, serializer, clear): def test_recursive_encoder_with_tuples(self, serializer, clear): """Test _recursive_encoder with tuples""" - mock_obj = MockSerializerComponent('tuple_test', 3) + mock_obj = MockSerializableObj('tuple_test', 3) data = (mock_obj, 'string', 42) result = serializer._recursive_encoder(data) @@ -442,7 +448,7 @@ def test_convert_to_dict_no_version(self, mock_import, serializer, clear): del mock_module.__version__ # Remove version attribute mock_import.return_value = mock_module - mock_obj = MockSerializerComponent('no_version', 1) + mock_obj = MockSerializableObj('no_version', 1) result = serializer._convert_to_dict(mock_obj) assert result['@version'] is None @@ -452,7 +458,7 @@ def test_convert_to_dict_import_error(self, mock_import, serializer, clear): """Test _convert_to_dict when import_module raises ImportError""" mock_import.side_effect = ImportError('Module not found') - mock_obj = MockSerializerComponent('import_error', 1) + mock_obj = MockSerializableObj('import_error', 1) result = serializer._convert_to_dict(mock_obj) assert result['@version'] is None @@ -460,7 +466,7 @@ def test_convert_to_dict_import_error(self, mock_import, serializer, clear): def test_convert_to_dict_attribute_error_handling(self, serializer, clear): """Test _convert_to_dict handles AttributeError for missing attributes""" - class MockObjMissingAttrs(SerializerComponent): + class MockObjMissingAttrs(MockSerializable): def __init__(self, name: str, missing_param: str = 'default'): self.name = name self.unique_name = f'missing_{name}' @@ -475,7 +481,7 @@ def __init__(self, name: str, missing_param: str = 'default'): def test_convert_to_dict_varargs_handling(self, serializer, clear): """Test _convert_to_dict with varargs (*args) handling""" - class MockObjWithVarargs(SerializerComponent): + class MockObjWithVarargs(MockSerializable): def __init__(self, name: str, *args): self.name = name self.args = args @@ -519,7 +525,7 @@ def test_convert_from_dict_edge_cases(self): def test_concrete_serializer_implementation(self, clear): """Test that ConcreteSerializer works correctly""" serializer = ConcreteSerializer() - mock_obj = MockSerializerComponent('concrete_test', 100) + mock_obj = MockSerializableObj('concrete_test', 100) # Test encode encoded = serializer.encode(mock_obj) @@ -530,7 +536,7 @@ def test_concrete_serializer_implementation(self, clear): # Test decode global_object.map._clear() # Clear before decode decoded = ConcreteSerializer.decode(encoded) - assert isinstance(decoded, MockSerializerComponent) + assert isinstance(decoded, MockSerializableObj) assert decoded.name == 'concrete_test' assert decoded.value == 100 @@ -559,7 +565,7 @@ def test_encode_objs_without_numpy(self): def test_recursive_encoder_with_nested_structures(self, serializer, clear): """Test _recursive_encoder with deeply nested structures""" - mock_obj = MockSerializerComponent('nested', 1) + mock_obj = MockSerializableObj('nested', 1) data = {'level1': {'level2': [mock_obj, {'level3': mock_obj}]}} diff --git a/tests/unit/io/test_serializer_dict.py b/tests/unit/io/test_serializer_dict.py deleted file mode 100644 index 8708393d..00000000 --- a/tests/unit/io/test_serializer_dict.py +++ /dev/null @@ -1,180 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from copy import deepcopy -from typing import Type - -import pytest - -from easyscience import DescriptorNumber -from easyscience import Parameter -from easyscience import global_object -from easyscience.io.serializer_dict import SerializerDict - -from .test_serializer_component import check_dict -from .test_serializer_component import dp_param_dict -from .test_serializer_component import skip_dict - - -def recursive_remove(d, remove_keys: list) -> dict: - """ - Remove keys from a dictionary. - """ - if not isinstance(remove_keys, list): - remove_keys = [remove_keys] - if isinstance(d, dict): - dd = {} - for k in d.keys(): - if k not in remove_keys: - dd[k] = recursive_remove(d[k], remove_keys) - return dd - else: - return d - - -######################################################################################################################## -# TESTING ENCODING -######################################################################################################################## -@pytest.mark.parametrize(**skip_dict) -@pytest.mark.parametrize(**dp_param_dict) -def test_variable_SerializerDict(dp_kwargs: dict, dp_cls: Type[DescriptorNumber], skip): - data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != '@'} - - obj = dp_cls(**data_dict) - - dp_kwargs = deepcopy(dp_kwargs) - - if isinstance(skip, str): - del dp_kwargs[skip] - - if not isinstance(skip, list): - skip = [skip] - - enc = SerializerDict().encode(obj, skip=skip) - - expected_keys = set(dp_kwargs.keys()) - obtained_keys = set(enc.keys()) - - dif = expected_keys.difference(obtained_keys) - - assert len(dif) == 0 - - check_dict(dp_kwargs, enc) - - -######################################################################################################################## -# TESTING DECODING -######################################################################################################################## -@pytest.mark.parametrize(**dp_param_dict) -def test_variable_SerializerDict_decode(dp_kwargs: dict, dp_cls: Type[DescriptorNumber]): - data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != '@'} - - obj = dp_cls(**data_dict) - - enc = SerializerDict().encode(obj) - global_object.map._clear() - dec = SerializerDict.decode(enc) - - for k in data_dict.keys(): - if hasattr(obj, k) and hasattr(dec, k): - assert getattr(obj, k) == getattr(dec, k) - else: - raise AttributeError(f'{k} not found in decoded object') - - -@pytest.mark.parametrize(**dp_param_dict) -def test_variable_SerializerDict_from_dict(dp_kwargs: dict, dp_cls: Type[DescriptorNumber]): - data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != '@'} - - obj = dp_cls(**data_dict) - - enc = SerializerDict().encode(obj) - global_object.map._clear() - dec = dp_cls.from_dict(enc) - - for k in data_dict.keys(): - if hasattr(obj, k) and hasattr(dec, k): - assert getattr(obj, k) == getattr(dec, k) - else: - raise AttributeError(f'{k} not found in decoded object') - - -def test_group_encode(): - d0 = DescriptorNumber(0, display_name='a') - d1 = DescriptorNumber(1, display_name='b') - - from easyscience.base_classes import EasyList - - b = EasyList(d0, d1) - d = b.to_dict() - assert isinstance(d['data'], list) - - -def test_group_encode2(): - p0 = Parameter(0, display_name='a') - p1 = Parameter(1, display_name='b') - - from easy_test_models import CoefficientModel - - b = CoefficientModel(display_name='outer', coefficients=[p0, p1]) - d = b.to_dict() - assert isinstance(d['coefficients'], dict) - assert len(d['coefficients']['data']) == 2 - - -class TestSerializerDictDecodeDispatch: - """``SerializerDict.decode`` routes by what the dict describes.""" - - def test_new_base_uses_from_dict(self): - from easy_test_models import CoefficientModel - - global_object.map._clear() - - model = CoefficientModel(display_name='model', coefficients=[1.0, 2.0]) - - new_model = SerializerDict.decode(model.to_dict()) - - assert isinstance(new_model, CoefficientModel) - assert [c.value for c in new_model.coefficients] == [1.0, 2.0] - - @pytest.mark.parametrize( - 'value', - [None, 1.5, 'text', [1, 2], {'plain': 'dict'}], - ids=['none', 'float', 'str', 'list', 'plain_dict'], - ) - def test_non_easyscience_values_pass_through(self, value): - assert SerializerDict.decode(value) == value - - def test_numpy_array_uses_generic_path(self): - import numpy as np - - encoded = {'@module': 'numpy', '@class': 'array', 'dtype': 'float64', 'data': [1.0, 2.0]} - - result = SerializerDict.decode(encoded) - - assert isinstance(result, np.ndarray) - assert np.array_equal(result, [1.0, 2.0]) - - def test_unknown_easyscience_class_falls_back(self): - """A class missing from its module is left to the generic decoder.""" - encoded = {'@module': 'easyscience.variable', '@class': 'NoSuchClass', 'x': 1} - - assert SerializerDict.decode(encoded) == encoded - - def test_easyscience_non_new_base_uses_generic_path(self): - """EasyScience classes outside NewBase are rebuilt from their kwargs.""" - from easyscience.fitting.calculators.interface_factory import ItemContainer - - encoded = { - '@module': 'easyscience.fitting.calculators.interface_factory', - '@class': 'ItemContainer', - 'link_name': 'link', - 'name_conversion': {'a': 'b'}, - 'getter_fn': None, - 'setter_fn': None, - } - - result = SerializerDict.decode(encoded) - - assert isinstance(result, ItemContainer) - assert result.link_name == 'link' diff --git a/tests/unit/variable/test_descriptor_base.py b/tests/unit/variable/test_descriptor_base.py index 980aac51..d52db693 100644 --- a/tests/unit/variable/test_descriptor_base.py +++ b/tests/unit/variable/test_descriptor_base.py @@ -6,7 +6,6 @@ from easyscience import global_object from easyscience.base_classes import EasyList from easyscience.base_classes import NewBase -from easyscience.io import SerializerComponent from easyscience.variable import DescriptorNumber from easyscience.variable.descriptor_base import DescriptorBase diff --git a/tests/unit/variable/test_parameter_dependency_serialization.py b/tests/unit/variable/test_parameter_dependency_serialization.py index 5618b606..bf16241e 100644 --- a/tests/unit/variable/test_parameter_dependency_serialization.py +++ b/tests/unit/variable/test_parameter_dependency_serialization.py @@ -382,28 +382,6 @@ def test_error_handling_missing_dependency(self, clear_global_map): with pytest.raises(ValueError, match='Cannot find parameter with serializer_id'): new_b.resolve_pending_dependencies() - def test_backward_compatibility_base_deserializer(self, clear_global_map): - """Test that the base deserializer path still works for dependent parameters.""" - from easyscience.io.serializer_dict import SerializerDict - - # Create dependent parameter - a = Parameter(display_name='a', value=2.0, unit='m') - b = Parameter.from_dependency( - display_name='b', dependency_expression='3 * a', dependency_map={'a': a}, unit='m' - ) - - # Use base serializer path (SerializerDict.decode) - serialized = SerializerDict().encode(b) - global_object.map._clear() - - # This should not raise the "_independent" error anymore - deserialized = SerializerDict.decode(serialized) - - # Should be a valid Parameter (but without dependency resolution) - assert isinstance(deserialized, Parameter) - assert deserialized.display_name == 'b' - assert deserialized.independent is True # Base path doesn't handle dependencies - @pytest.mark.parametrize( 'order', [['x', 'y', 'z'], ['z', 'x', 'y'], ['y', 'z', 'x'], ['z', 'y', 'x']], @@ -523,3 +501,29 @@ def test_deserialize_and_resolve_parameters_helper(self, clear_global_map): # Verify no pending dependencies remain pending = get_parameters_with_pending_dependencies(new_params) assert len(pending) == 0 + + def test_nested_dependent_parameter_round_trip(self, clear_global_map): + """A dependent parameter held by a model keeps its dependency through to_dict/from_dict.""" + from easy_test_models import PairModel + + a = Parameter(display_name='a', value=2.0, unit='m') + b = Parameter.from_dependency( + display_name='b', dependency_expression='3 * a', dependency_map={'a': a}, unit='m' + ) + model = PairModel(a=a, b=b) + + model_dict = model.to_dict() + + # The nested parameter was serialized by its own to_dict + assert model_dict['b']['_independent'] is False + assert model_dict['b']['_dependency_string'] == '3 * a' + assert '_dependency_map_serializer_ids' in model_dict['b'] + + global_object.map._clear() + new_model = PairModel.from_dict(model_dict) + new_model.b.resolve_pending_dependencies() + + assert new_model.b.independent is False + assert new_model.b.value == 6.0 + new_model.a.value = 5.0 + assert new_model.b.value == 15.0 From 0b71d25a648e27fd9eb4edfcf70d96bc8e1fd6ee Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Mon, 5 Oct 2026 17:21:01 +0200 Subject: [PATCH 10/11] Remove _REDIRECT; serializer relies on _arg_spec only --- src/easyscience/io/serializer_base.py | 44 +++++++++----------- src/easyscience/variable/descriptor_base.py | 2 - src/easyscience/variable/parameter.py | 16 ++++--- tests/unit/io/test_serializer_base.py | 46 ++++++++++++++++----- 4 files changed, 65 insertions(+), 43 deletions(-) diff --git a/src/easyscience/io/serializer_base.py b/src/easyscience/io/serializer_base.py index 5d9cb419..6d5b23de 100644 --- a/src/easyscience/io/serializer_base.py +++ b/src/easyscience/io/serializer_base.py @@ -173,8 +173,6 @@ def _convert_to_dict( if hasattr(obj, '_arg_spec'): args = obj._arg_spec - redirect = getattr(obj, '_REDIRECT', {}) - def runner(o): if full_encode: return SerializerBase._encode_objs(o) @@ -182,29 +180,25 @@ def runner(o): return o for c in args: - if c not in own_skip: - if c in redirect.keys(): - if redirect[c] is None: - continue - a = runner(redirect[c](obj)) - else: - try: - a = runner(obj.__getattribute__(c)) - except AttributeError: - try: - a = runner(obj.__getattribute__('_' + c)) - except AttributeError: - raise NotImplementedError( - 'Unable to automatically determine to_dict ' - 'format from class. All args must be present ' - 'as either self.argname or self._argname to ' - 'automatically determine the dict format. ' - 'Alternatively, you can implement both to_dict ' - 'and from_dict.' - ) from None - d[c] = self._recursive_encoder( - a, skip=skip, encoder=self, full_encode=full_encode, **kwargs - ) + if c in own_skip: + continue + try: + a = runner(obj.__getattribute__(c)) + except AttributeError: + try: + a = runner(obj.__getattribute__('_' + c)) + except AttributeError: + raise NotImplementedError( + 'Unable to automatically determine to_dict ' + 'format from class. All args must be present ' + 'as either self.argname or self._argname to ' + 'automatically determine the dict format. ' + 'Alternatively, you can implement both to_dict ' + 'and from_dict.' + ) from None + d[c] = self._recursive_encoder( + a, skip=skip, encoder=self, full_encode=full_encode, **kwargs + ) if spec.varargs is not None and getattr(obj, spec.varargs, None) is not None: d.update({spec.varargs: getattr(obj, spec.varargs)}) if isinstance(obj, Enum): diff --git a/src/easyscience/variable/descriptor_base.py b/src/easyscience/variable/descriptor_base.py index 1eb4f3e3..c081e039 100644 --- a/src/easyscience/variable/descriptor_base.py +++ b/src/easyscience/variable/descriptor_base.py @@ -32,8 +32,6 @@ class DescriptorBase(NewBase, metaclass=abc.ABCMeta): """ _global_object = global_object - # Used by serializer - _REDIRECT = {} def __init__( self, diff --git a/src/easyscience/variable/parameter.py b/src/easyscience/variable/parameter.py index 8dda0f7d..ad95f348 100644 --- a/src/easyscience/variable/parameter.py +++ b/src/easyscience/variable/parameter.py @@ -11,6 +11,7 @@ from typing import Dict from typing import List from typing import Optional +from typing import Set from typing import Union import numpy as np @@ -33,11 +34,6 @@ class Parameter(DescriptorNumber): It has additional fields to facilitate this. """ - # Used by serializer - # We copy the parent's _REDIRECT and modify it to avoid altering the parent's class dict - _REDIRECT = DescriptorNumber._REDIRECT.copy() - _REDIRECT['callback'] = None - def __init__( self, value: numbers.Number, @@ -889,6 +885,16 @@ def free(self) -> bool: def free(self, value: bool) -> None: self.fixed = not value + @property + def _arg_spec(self) -> Set[str]: + """ + Names of the constructor arguments the serializer has to collect. + + ``callback`` links to a calculator object. + Holds a ``property`` that cannot be serialized. + """ + return super()._arg_spec - {'callback'} + def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: """ Overwrite the to_dict method to handle dependency information. diff --git a/tests/unit/io/test_serializer_base.py b/tests/unit/io/test_serializer_base.py index 93085eb4..b58ab8f0 100644 --- a/tests/unit/io/test_serializer_base.py +++ b/tests/unit/io/test_serializer_base.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: BSD-3-Clause import datetime +import json from enum import Enum from typing import Any from typing import List @@ -16,6 +17,7 @@ from easyscience import DescriptorNumber from easyscience import Parameter from easyscience import global_object +from easyscience.base_classes import ModelBase from easyscience.base_classes import NewBase from easyscience.io import SerializerBase @@ -45,18 +47,28 @@ def __init__( self._global_object = True -class MockSerializerWithRedirect(MockSerializable): - """Mock with _REDIRECT for testing redirect functionality""" - - _REDIRECT = {'special_attr': lambda obj: obj.value * 2, 'none_attr': None} +class MockSerializerWithArgSpec(MockSerializable): + """Mock with an _arg_spec that leaves out one constructor argument""" def __init__(self, name: str = 'test', value: int = 1, special_attr: int = 5): self.name = name self.value = value self.special_attr = special_attr - self.unique_name = f'redirect_{name}' + self.unique_name = f'arg_spec_{name}' self._global_object = True + @property + def _arg_spec(self): + return {'name', 'value'} + + +class MockModelWithParameter(ModelBase): + """Model holding a Parameter as a constructor argument""" + + def __init__(self, p: Optional[Parameter] = None): + super().__init__() + self._p = p + class MockSerializerWithConvertToDict(MockSerializable): """Mock with custom _convert_to_dict method""" @@ -294,13 +306,25 @@ def test_convert_to_dict_with_skip(self, serializer, mock_obj, clear): assert 'value' not in result assert 'optional_param' not in result - def test_convert_to_dict_with_redirect(self, serializer, clear): - """Test _convert_to_dict with _REDIRECT""" - obj = MockSerializerWithRedirect('redirect_test', 10) + def test_convert_to_dict_with_arg_spec(self, serializer, clear): + """Test _convert_to_dict only collects the names in _arg_spec""" + obj = MockSerializerWithArgSpec('arg_spec_test', 10) result = serializer._convert_to_dict(obj) - assert result['special_attr'] == 20 # 10 * 2 from redirect - assert 'none_attr' not in result # Should be skipped due to None redirect + assert result['name'] == 'arg_spec_test' + assert result['value'] == 10 + assert 'special_attr' not in result + + def test_convert_to_dict_nested_parameter_omits_callback(self, serializer, clear): + """A Parameter nested in a model serializes without its callback""" + model = MockModelWithParameter(p=Parameter(2.0, display_name='p')) + result = serializer._convert_to_dict(model) + + nested = result['p'] + assert nested['@class'] == 'Parameter' + assert nested['value'] == 2.0 + assert 'callback' not in nested + json.dumps(nested) def test_convert_to_dict_with_custom_convert_to_dict(self, serializer, clear): """Test _convert_to_dict with custom _convert_to_dict method""" @@ -360,7 +384,7 @@ def __init__(self, name: str): assert result['name'] == 'test' assert 'unique_name' not in result - def test_convert_to_dict_with_arg_spec(self, serializer, clear): + def test_convert_to_dict_with_instance_arg_spec(self, serializer, clear): """Test _convert_to_dict with custom _arg_spec""" class MockObjCustomArgSpec(MockSerializable): From f071d05b410460a65636a551303da3c072dd3374 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Tue, 6 Oct 2026 09:12:50 +0200 Subject: [PATCH 11/11] merged 308-remove-name --- docs/docs/user-guide/index.md | 3 +++ docs/mkdocs.yml | 2 ++ src/easyscience/base_classes/new_base.py | 17 ----------------- src/easyscience/io/serializer_base.py | 6 +++++- src/easyscience/variable/descriptor_base.py | 17 ----------------- src/easyscience/variable/parameter.py | 7 ++++--- tests/unit/base_classes/test_easy_list.py | 19 +++++++++++++++++++ tests/unit/base_classes/test_new_base.py | 21 +++++++++++++++++---- tests/unit/io/test_serializer_base.py | 11 +++++++++++ 9 files changed, 61 insertions(+), 42 deletions(-) diff --git a/docs/docs/user-guide/index.md b/docs/docs/user-guide/index.md index 84fe5c09..7c2e58ec 100644 --- a/docs/docs/user-guide/index.md +++ b/docs/docs/user-guide/index.md @@ -8,3 +8,6 @@ icon: material/book-open-variant – How to suppress, filter, or redirect the messages that EasyScience produces. Covers the `EASYSCIENCE_LOG_LEVEL` environment variable, the logger hierarchy, context managers, and common recipes. +- [:material-link-variant: Serializing Dependent Parameters](parameter-dependency-serialization.md) + – How to save and load parameters that depend on other parameters, and + how dependencies are resolved after loading. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 42f40876..01714b90 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -192,6 +192,8 @@ nav: - User Guide: - User Guide: user-guide/index.md - Controlling Log Output: user-guide/controlling-log-output.md + - Serializing Dependent Parameters: + user-guide/parameter-dependency-serialization.md - Tutorials: - Tutorials: tutorials/index.md - Bayesian analysis: tutorials/fitting-bayesian.ipynb diff --git a/src/easyscience/base_classes/new_base.py b/src/easyscience/base_classes/new_base.py index 0354392b..9bd42748 100644 --- a/src/easyscience/base_classes/new_base.py +++ b/src/easyscience/base_classes/new_base.py @@ -3,7 +3,6 @@ from __future__ import annotations -from inspect import signature from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -12,7 +11,6 @@ from typing import Iterable from typing import List from typing import Optional - from typing import Set from easyscience import global_object @@ -43,21 +41,6 @@ def __init__(self, unique_name: Optional[str] = None, display_name: Optional[str raise TypeError('Display name must be a string or None') self._display_name = display_name - @property - def _arg_spec(self) -> Set[str]: - """ - This method is used by the serializer to determine which - arguments are needed by the constructor to deserialize the - object. - """ - sign = signature(self.__class__.__init__) - names = [ - param.name - for param in sign.parameters.values() - if param.kind == param.POSITIONAL_OR_KEYWORD - ] - return set(names[1:]) - @property def unique_name(self) -> str: """Get the unique name of the object.""" diff --git a/src/easyscience/io/serializer_base.py b/src/easyscience/io/serializer_base.py index 6d5b23de..c5c2c089 100644 --- a/src/easyscience/io/serializer_base.py +++ b/src/easyscience/io/serializer_base.py @@ -80,6 +80,10 @@ def get_arg_spec(func: Callable) -> Tuple[Any, List[str]]: Get the full argument specification of a function (typically ``__init__``) + Both positional-or-keyword and keyword-only arguments are + returned, so constructors that use ``*`` to force keyword-only + arguments are serialized correctly. + Parameters ---------- func : Callable @@ -92,7 +96,7 @@ def get_arg_spec(func: Callable) -> Tuple[Any, List[str]]: """ spec = getfullargspec(func) - args = spec.args[1:] + args = spec.args[1:] + spec.kwonlyargs return spec, args @staticmethod diff --git a/src/easyscience/variable/descriptor_base.py b/src/easyscience/variable/descriptor_base.py index c081e039..ba1614df 100644 --- a/src/easyscience/variable/descriptor_base.py +++ b/src/easyscience/variable/descriptor_base.py @@ -4,12 +4,10 @@ from __future__ import annotations import abc -from inspect import signature from typing import Any from typing import Dict from typing import List from typing import Optional -from typing import Set from easyscience import global_object from easyscience.base_classes.new_base import NewBase @@ -52,8 +50,6 @@ def __init__( a model and is non-fittable and generally changes the state of an object. - All arguments are keyword-only. - Parameters ---------- unique_name : Optional[str], default=None @@ -86,19 +82,6 @@ def __init__( url = '' self._url: str = url - @property - def _arg_spec(self) -> Set[str]: - """ - Names of the constructor arguments the serializer has to collect. - """ - sign = signature(self.__class__.__init__) - return { - param.name - for param in sign.parameters.values() - if param.kind in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY) - and param.name != 'self' - } - @property def description(self) -> str: """ diff --git a/src/easyscience/variable/parameter.py b/src/easyscience/variable/parameter.py index ad95f348..4c5dd48d 100644 --- a/src/easyscience/variable/parameter.py +++ b/src/easyscience/variable/parameter.py @@ -11,7 +11,6 @@ from typing import Dict from typing import List from typing import Optional -from typing import Set from typing import Union import numpy as np @@ -22,6 +21,7 @@ from easyscience import global_object from easyscience.global_object.undo_redo import property_stack +from easyscience.io.serializer_base import SerializerBase from .descriptor_number import DescriptorNumber from .descriptor_number import notify_observers @@ -886,14 +886,15 @@ def free(self, value: bool) -> None: self.fixed = not value @property - def _arg_spec(self) -> Set[str]: + def _arg_spec(self) -> List[str]: """ Names of the constructor arguments the serializer has to collect. ``callback`` links to a calculator object. Holds a ``property`` that cannot be serialized. """ - return super()._arg_spec - {'callback'} + _, args = SerializerBase.get_arg_spec(self.__class__.__init__) + return [name for name in args if name != 'callback'] def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: """ diff --git a/tests/unit/base_classes/test_easy_list.py b/tests/unit/base_classes/test_easy_list.py index 96823f70..f2c5ea6a 100644 --- a/tests/unit/base_classes/test_easy_list.py +++ b/tests/unit/base_classes/test_easy_list.py @@ -626,6 +626,25 @@ def test_to_dict_nested_list_uses_override(self): assert outer2[0]._protected_types == [Alpha] assert outer2[0][0].unique_name == 'a1' + def test_from_dict_round_trip_keyword_only_arguments(self): + a1 = Alpha(unique_name='a1') + el = EasyList(a1, unique_name='my_list', display_name='My list', protected_types=Alpha) + d = el.to_dict() + assert d['unique_name'] == 'my_list' + assert d['display_name'] == 'My list' + global_object.map._clear() + el2 = EasyList.from_dict(d) + assert el2.unique_name == 'my_list' + assert el2.display_name == 'My list' + assert el2._protected_types == [Alpha] + + def test_to_dict_skip(self): + a1 = Alpha(unique_name='a1') + el = EasyList(a1, unique_name='my_list', display_name='My list', protected_types=Alpha) + d = el.to_dict(skip=['display_name']) + assert 'display_name' not in d + assert 'protected_types' in d + # --- get_all_variables --- def test_get_all_variables_empty_list(self): diff --git a/tests/unit/base_classes/test_new_base.py b/tests/unit/base_classes/test_new_base.py index fdda04f1..bd86298f 100644 --- a/tests/unit/base_classes/test_new_base.py +++ b/tests/unit/base_classes/test_new_base.py @@ -41,14 +41,27 @@ def test_constructor_invalid_display_name(self): with pytest.raises(TypeError, match='Display name must be a string or None'): NewBase(display_name=456) - def test_arg_spec(self): + def test_no_arg_spec_override(self): # When obj = NewBase() + # Expect + assert not hasattr(obj, '_arg_spec') + + def test_to_dict_includes_keyword_only_args(self, clear): + # When + class KeywordOnly(NewBase): + def __init__(self, value, *args, unit='', unique_name=None, **kwargs): + super().__init__(unique_name=unique_name) + self.value = value + self.unit = unit + + obj = KeywordOnly(1.0, unit='m', unique_name='kw') # Then - arg_spec = obj._arg_spec + d = obj.to_dict() # Expect - assert 'unique_name' in arg_spec - assert 'display_name' in arg_spec + assert d['value'] == 1.0 + assert d['unit'] == 'm' + assert d['unique_name'] == 'kw' def test_unique_name_setter(self, clear): # When diff --git a/tests/unit/io/test_serializer_base.py b/tests/unit/io/test_serializer_base.py index b58ab8f0..bc9bbd88 100644 --- a/tests/unit/io/test_serializer_base.py +++ b/tests/unit/io/test_serializer_base.py @@ -577,6 +577,17 @@ def complex_func(self, required_arg, optional_arg='default', *args, **kwargs): assert spec.varkw == 'kwargs' assert spec.defaults == ('default',) + def test_get_arg_spec_keyword_only(self): + """Keyword-only arguments are included after positional ones""" + + def kw_func(self, value, *, unit='', unique_name=None, **kwargs): + pass + + spec, args = SerializerBase.get_arg_spec(kw_func) + + assert args == ['value', 'unit', 'unique_name'] + assert spec.kwonlyargs == ['unit', 'unique_name'] + @patch('easyscience.io.serializer_base.np', None) def test_encode_objs_without_numpy(self): """Test _encode_objs when numpy is not available"""