Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a0aadea
Create new easyscience base class and model_base class.
damskii9992 Nov 19, 2025
66a199e
Pr comments
damskii9992 Nov 20, 2025
690ce16
Simplify NewBase from_dict class using `_is_serialized_easyscience_ob…
damskii9992 Nov 20, 2025
6687c33
Sort imports
damskii9992 Nov 21, 2025
5def3e7
Merge branch 'new_base_class' of https://github.com/EasyScience/EasyS…
damskii9992 Nov 21, 2025
884df73
Test new serializer_base methods except deserialize_dict
damskii9992 Nov 21, 2025
0f8b932
Last new SerializerBase test
damskii9992 Nov 24, 2025
8e3ab34
Create new easyscience base class and model_base class.
damskii9992 Nov 19, 2025
51100ad
Sort imports
damskii9992 Nov 21, 2025
4189a27
Pr comments
damskii9992 Nov 20, 2025
64f09c1
Simplify NewBase from_dict class using `_is_serialized_easyscience_ob…
damskii9992 Nov 20, 2025
4e26dfc
Test new serializer_base methods except deserialize_dict
damskii9992 Nov 21, 2025
f396c70
Last new SerializerBase test
damskii9992 Nov 24, 2025
ef6ee5c
Merge branch 'new_base_class' of https://github.com/easyscience/corel…
damskii9992 Nov 24, 2025
d19f256
Add as_dict tests
damskii9992 Nov 24, 2025
cecc7b0
Format and Lint
damskii9992 Nov 24, 2025
fef80c0
Pixi update and final NewBase tests
damskii9992 Nov 24, 2025
390ad80
First ModelBase tests
damskii9992 Nov 24, 2025
113ec7c
Changes from ADR discussions
damskii9992 Nov 25, 2025
e10a4fa
Test all get_variable methods
damskii9992 Nov 25, 2025
99c83e3
Fix to recursive encoder to work with new base classes and nested des…
damskii9992 Nov 25, 2025
5e67822
Final ModelBase unit tests
damskii9992 Nov 25, 2025
f8b0817
Add minimization integration test with ModelBase
damskii9992 Nov 25, 2025
a57f922
Pr comments
damskii9992 Nov 27, 2025
667ca97
Replace getfullargspec with signature which is the more modern approach.
damskii9992 Nov 27, 2025
7ddaea6
Exchange getfullargspec in BasedBase with inspect signature.
damskii9992 Nov 27, 2025
2fb951c
Update type hint for display_name setter
damskii9992 Dec 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,292 changes: 1,961 additions & 331 deletions pixi.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ dev = [
"pytest",
"pytest-cov",
"ruff",
"tox-gh-actions"
"tox-gh-actions",
"jupyterlab"
]
docs = [
"doc8",
Expand Down
4 changes: 4 additions & 0 deletions src/easyscience/base_classes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from .based_base import BasedBase
from .collection_base import CollectionBase
from .model_base import ModelBase
from .new_base import NewBase
from .obj_base import ObjBase

__all__ = [
BasedBase,
CollectionBase,
ObjBase,
ModelBase,
NewBase,
]
8 changes: 4 additions & 4 deletions src/easyscience/base_classes/based_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# SPDX-FileCopyrightText: 2025 EasyScience contributors <core@easyscience.software>
# SPDX-License-Identifier: BSD-3-Clause
# © 2021-2025 Contributors to the EasyScience project <https://github.com/easyScience/EasyScience
from inspect import getfullargspec
from inspect import signature
from typing import TYPE_CHECKING
from typing import Any
from typing import Dict
Expand Down Expand Up @@ -40,9 +40,9 @@ def __init__(self, name: str, interface: Optional[InterfaceFactoryTemplate] = No
@property
def _arg_spec(self) -> Set[str]:
base_cls = getattr(self, '__old_class__', self.__class__)
spec = getfullargspec(base_cls.__init__)
names = set(spec.args[1:])
return names
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):
"""
Expand Down
119 changes: 119 additions & 0 deletions src/easyscience/base_classes/model_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
from __future__ import annotations

# SPDX-FileCopyrightText: 2025 EasyScience contributors <core@easyscience.software>
# SPDX-License-Identifier: BSD-3-Clause
# © 2021-2025 Contributors to the EasyScience project <https://github.com/easyScience/EasyScience
from typing import TYPE_CHECKING

from easyscience.variable.descriptor_number import DescriptorNumber

if TYPE_CHECKING:
from typing import Any
from typing import Dict
from typing import List
from typing import Optional

from ..io import SerializerBase
from ..variable import Parameter
from ..variable.descriptor_base import DescriptorBase
from .new_base import NewBase


class ModelBase(NewBase):
"""
This is the base class for all model classes in EasyScience.
It provides methods to get parameters for fitting and analysis as well as proper serialization/deserialization for
DescriptorNumber/Parameter attributes.

It assumes that Parameters/DescriptorNumbers are assigned as properties with the getters returning the parameter
but the setter only setting the value of the parameter.
e.g.
```python
@property
def my_param(self) -> Parameter:
return self._my_param

@my_param.setter
def my_param(self, new_value: float) -> None:
self._my_param.value = new_value
```
"""

def __init__(self, unique_name: Optional[str] = None, display_name: Optional[str] = None):
super().__init__(unique_name=unique_name, display_name=display_name)

def get_all_variables(self) -> List[DescriptorBase]:
"""
Get all `Descriptor` and `Parameter` objects as a list.

:return: List of `Descriptor` and `Parameter` objects.
"""
vars = []
for attr_name in dir(self):
attr = getattr(self, attr_name)
if isinstance(attr, DescriptorBase):
vars.append(attr)
elif hasattr(attr, 'get_all_variables'):
vars += attr.get_all_variables()
return vars

def get_all_parameters(self) -> List[Parameter]:
"""
Get all `Parameter` objects as a list.

:return: List of `Parameter` objects.
"""
return [param for param in self.get_all_variables() if isinstance(param, Parameter)]

def get_fittable_parameters(self) -> List[Parameter]:
"""
Get all parameters which can be fitted as a list.

:return: List of `Parameter` objects.
"""
return [param for param in self.get_all_parameters() if param.independent]

def get_free_parameters(self) -> List[Parameter]:
"""
Get all parameters which are currently free to be fitted as a list.

:return: List of `Parameter` objects.
"""
return [param for param in self.get_fittable_parameters() if not param.fixed]

def get_fit_parameters(self) -> List[Parameter]:
"""
This is an alias for `get_free_parameters`.
To be removed when fully moved to new base classes and minimizer can be changed.
"""
return self.get_free_parameters()

@classmethod
def from_dict(cls, obj_dict: Dict[str, Any]) -> ModelBase:
"""
Re-create an EasyScience object with DescriptorNumber attributes from a full encoded dictionary.

:param obj_dict: dictionary containing the serialized contents (from `SerializerDict`) of an EasyScience object
:return: Reformed EasyScience object
"""
if not SerializerBase._is_serialized_easyscience_object(obj_dict):
raise ValueError('Input must be a dictionary representing an EasyScience object.')
if obj_dict['@class'] == cls.__name__:
kwargs = SerializerBase.deserialize_dict(obj_dict)
parameter_placeholder = {}
for key, value in kwargs.items():
if isinstance(value, DescriptorNumber):
parameter_placeholder[key] = value
kwargs[key] = value.value
cls_instance = cls(**kwargs)
for key, value in parameter_placeholder.items():
try:
temp_param = getattr(cls_instance, key)
setattr(cls_instance, '_' + key, value)
cls_instance._global_object.map.prune(temp_param.unique_name)
except Exception as e:
raise SyntaxError(f"""Could not set parameter {key} during `from_dict` with full deserialized variable. \n'
This should be fixed in the class definition. Error: {e}""") from e
return cls_instance
else:
raise ValueError(f'Class name in dictionary does not match the expected class: {cls.__name__}.')
145 changes: 145 additions & 0 deletions src/easyscience/base_classes/new_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
from __future__ import annotations

# SPDX-FileCopyrightText: 2025 EasyScience contributors <core@easyscience.software>
# SPDX-License-Identifier: BSD-3-Clause
# © 2021-2025 Contributors to the EasyScience project <https://github.com/easyScience/EasyScience
from inspect import signature
from typing import TYPE_CHECKING

if 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 ..global_object.undo_redo import property_stack
from ..io.serializer_base import SerializerBase


class NewBase:
"""
This is the new base class for easyscience objects.
It provides serialization capabilities as well as unique naming and display naming.
"""

def __init__(self, unique_name: Optional[str] = None, display_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._default_unique_name = True
else:
self._default_unique_name = False
if not isinstance(unique_name, str):
raise TypeError('Unique name has to be a string.')
self._unique_name = unique_name
self._global_object.map.add_vertex(self, obj_type='created')
if display_name is not None and not isinstance(display_name, str):
raise TypeError('Display name must be a string or None')
self._display_name = display_name

@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."""
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.

:param new_unique_name: New unique name for the object"""
if not isinstance(new_unique_name, str):
raise TypeError('Unique name has to be a string.')
self._unique_name = new_unique_name
self._global_object.map.add_vertex(self)
self._default_unique_name = False

@property
def display_name(self) -> str:
"""
Get a pretty display name.

:return: The pretty display name.
"""
display_name = self._display_name
if display_name is None:
display_name = self.unique_name
return display_name

@display_name.setter
@property_stack
def display_name(self, name: str | None) -> None:
"""
Set the pretty display name.

:param name: Pretty display name of the object.
"""
if name is not None and not isinstance(name, str):
raise TypeError('Display name must be a string or None')
self._display_name = name

def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
"""
Convert an EasyScience object into a full dictionary using `SerializerBase`s generic `convert_to_dict` method.

:param skip: List of field names as strings to skip when forming the dictionary
:return: encoded object containing all information to reform an EasyScience object.
"""
serializer = SerializerBase()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we instantiate SerializerBase just once in the init?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can.
But that would slow down class instantiation. I actually prefer to have it here, as to_dict should only be called once, if even at all. So at best we get a speed-up by only instantiating it when we need it, and at worst we get the same total time. Unless we of course serialize multiple times.
I also thought about having it in the global_object, as we only really need 1 instance of the class globally.
I also thought about making it simple functions, since most of the methods are static functions, which hints that it shouldn't even be a class.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping SerializerBase makes sense - all serialization related methods are in one location and there are enough instance methods to justify it being a separate class. If we take all the static methods away and plop them into say, serialization_utils, we would have to import them separately later and remember about that...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's only _convert_to_dict and its utility method _recursive_encoder which are not static methods. And both of those could just as easily have been made static methods. The class doesn't even have an __init__ or any attributes . . .

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think _convert_to_dict can be easily made static...

_recursive_encoder - yes. but not the other one.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_convert_to_dict only uses self to later refer where to pull _convert_to_dict and _recursive_encoder from. If we only use these functions from one SerializerBase encoder - they can be static. And not just static but even plain funcitons.

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')
return serializer._convert_to_dict(self, skip=skip, full_encode=False)

@classmethod
def from_dict(cls, obj_dict: Dict[str, Any]) -> NewBase:
"""
Re-create an EasyScience object from a full encoded dictionary.

:param obj_dict: dictionary containing the serialized contents (from `SerializerDict`) of an EasyScience object
:return: Reformed EasyScience object
"""
if not SerializerBase._is_serialized_easyscience_object(obj_dict):
raise ValueError('Input must be a dictionary representing an EasyScience object.')
if obj_dict['@class'] == cls.__name__:
kwargs = SerializerBase.deserialize_dict(obj_dict)
return cls(**kwargs)
else:
raise ValueError(f'Class name in dictionary does not match the expected class: {cls.__name__}.')

def __dir__(self) -> Iterable[str]:
"""
This creates auto-completion and helps out in iPython notebooks.

:return: 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) -> NewBase:
"""Return a copy of the object."""
temp = self.to_dict(skip=['unique_name'])
new_obj = self.__class__.from_dict(temp)
return new_obj

def __deepcopy__(self, memo):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my linter complains for memo not used with the quick fix to del memo in the body of the method

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But in this context, memo is not used. I understand that it's necessary to have as a parameter, but the fact that it's not used in the method body leads to a linter problem. del memo is a common easy fix to dealing with unused function parameters.

tbh i don't think that's an issue, just a reminder in case ruff check fails here or smth

return self.__copy__()

def __repr__(self) -> str:
return f'{self.__class__.__name__} `{self.unique_name}`'
Loading
Loading