Skip to content

Commit 4675ec1

Browse files
committed
Add core abstract and base classes.
Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
1 parent e6ce99a commit 4675ec1

File tree

2 files changed

+384
-0
lines changed

2 files changed

+384
-0
lines changed

src/akkudoktoreos/core/coreabc.py

+159
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""Abstract and base classes for EOS core.
2+
3+
This module provides foundational classes for handling configuration and prediction functionality
4+
in EOS. It includes base classes that provide convenient access to global
5+
configuration and prediction instances through properties.
6+
7+
Classes:
8+
- ConfigMixin: Mixin class for managing and accessing global configuration.
9+
- PredictionMixin: Mixin class for managing and accessing global prediction data.
10+
- SingletonMixin: Mixin class to create singletons.
11+
"""
12+
13+
import threading
14+
from typing import Any, ClassVar, Dict, Type
15+
16+
from akkudoktoreos.utils.logutil import get_logger
17+
18+
logger = get_logger(__name__)
19+
20+
21+
class ConfigMixin:
22+
"""Mixin class for managing EOS configuration data.
23+
24+
This class serves as a foundational component for EOS-related classes requiring access
25+
to the global EOS configuration. It provides a `config` property that dynamically retrieves
26+
the configuration instance, ensuring up-to-date access to configuration settings.
27+
28+
Usage:
29+
Subclass this base class to gain access to the `config` attribute, which retrieves the
30+
global configuration instance lazily to avoid import-time circular dependencies.
31+
32+
Attributes:
33+
config (ConfigEOS): Property to access the global EOS configuration.
34+
35+
Example:
36+
```python
37+
class MyEOSClass(ConfigMixin):
38+
def my_method(self):
39+
if self.config.myconfigval:
40+
```
41+
"""
42+
43+
@property
44+
def config(self):
45+
"""Convenience method/ attribute to retrieve the EOS onfiguration data.
46+
47+
Returns:
48+
ConfigEOS: The configuration.
49+
"""
50+
# avoid circular dependency at import time
51+
from akkudoktoreos.config.config import get_config
52+
53+
return get_config()
54+
55+
56+
class PredictionMixin:
57+
"""Mixin class for managing EOS prediction data.
58+
59+
This class serves as a foundational component for EOS-related classes requiring access
60+
to global prediction data. It provides a `prediction` property that dynamically retrieves
61+
the prediction instance, ensuring up-to-date access to prediction results.
62+
63+
Usage:
64+
Subclass this base class to gain access to the `prediction` attribute, which retrieves the
65+
global prediction instance lazily to avoid import-time circular dependencies.
66+
67+
Attributes:
68+
prediction (Prediction): Property to access the global EOS prediction data.
69+
70+
Example:
71+
```python
72+
class MyOptimizationClass(PredictionMixin):
73+
def analyze_myprediction(self):
74+
prediction_data = self.prediction.mypredictionresult
75+
# Perform analysis
76+
```
77+
"""
78+
79+
@property
80+
def prediction(self):
81+
"""Convenience method/ attribute to retrieve the EOS prediction data.
82+
83+
Returns:
84+
Prediction: The prediction.
85+
"""
86+
# avoid circular dependency at import time
87+
from akkudoktoreos.prediction.prediction import get_prediction
88+
89+
return get_prediction()
90+
91+
92+
class SingletonMixin:
93+
"""A thread-safe singleton mixin class.
94+
95+
Ensures that only one instance of the derived class is created, even when accessed from multiple
96+
threads. This mixin is intended to be combined with other classes, such as Pydantic models,
97+
to make them singletons.
98+
99+
Attributes:
100+
_instances (Dict[Type, Any]): A dictionary holding instances of each singleton class.
101+
_lock (threading.Lock): A lock to synchronize access to singleton instance creation.
102+
103+
Usage:
104+
- Inherit from `SingletonMixin` alongside other classes to make them singletons.
105+
- Avoid using `__init__` to reinitialize the singleton instance after it has been created.
106+
107+
Example:
108+
class MySingletonModel(SingletonMixin, BaseModel):
109+
name: str
110+
111+
instance1 = MySingletonModel(name="Instance 1")
112+
instance2 = MySingletonModel(name="Instance 2")
113+
114+
assert instance1 is instance2 # True
115+
print(instance1.name) # Output: "Instance 1"
116+
"""
117+
118+
_lock: ClassVar[threading.Lock] = threading.Lock()
119+
_instances: ClassVar[Dict[Type, Any]] = {}
120+
121+
def __new__(cls: Type["SingletonMixin"], *args: Any, **kwargs: Any) -> "SingletonMixin":
122+
"""Creates or returns the singleton instance of the class.
123+
124+
Ensures thread-safe instance creation by locking during the first instantiation.
125+
126+
Args:
127+
*args: Positional arguments for instance creation (ignored if instance exists).
128+
**kwargs: Keyword arguments for instance creation (ignored if instance exists).
129+
130+
Returns:
131+
SingletonMixin: The singleton instance of the derived class.
132+
"""
133+
if cls not in cls._instances:
134+
with cls._lock:
135+
if cls not in cls._instances:
136+
instance = super().__new__(cls)
137+
cls._instances[cls] = instance
138+
return cls._instances[cls]
139+
140+
@classmethod
141+
def reset_instance(cls):
142+
"""Resets the singleton instance, forcing it to be recreated on next access."""
143+
with cls._lock:
144+
if cls in cls._instances:
145+
del cls._instances[cls]
146+
logger.debug(f"{cls.__name__} singleton instance has been reset.")
147+
148+
def __init__(self, *args: Any, **kwargs: Any) -> None:
149+
"""Initializes the singleton instance if it has not been initialized previously.
150+
151+
Further calls to `__init__` are ignored for the singleton instance.
152+
153+
Args:
154+
*args: Positional arguments for initialization.
155+
**kwargs: Keyword arguments for initialization.
156+
"""
157+
if not hasattr(self, "_initialized"):
158+
super().__init__(*args, **kwargs)
159+
self._initialized = True

src/akkudoktoreos/core/pydantic.py

+225
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""Module for managing and serializing Pydantic-based models with custom support.
2+
3+
This module introduces the `PydanticBaseModel` class, which extends Pydantic’s `BaseModel` to facilitate
4+
custom serialization and deserialization for `pendulum.DateTime` objects. The main features include
5+
automatic handling of `pendulum.DateTime` fields, custom serialization to ISO 8601 format, and utility
6+
methods for converting model instances to and from dictionary and JSON formats.
7+
8+
Key Classes:
9+
- PendulumDateTime: A custom type adapter that provides serialization and deserialization
10+
functionality for `pendulum.DateTime` objects, converting them to ISO 8601 strings and back.
11+
- PydanticBaseModel: A base model class for handling prediction records or configuration data
12+
with automatic Pendulum DateTime handling and additional methods for JSON and dictionary
13+
conversion.
14+
15+
Classes:
16+
PendulumDateTime(TypeAdapter[pendulum.DateTime]): Type adapter for `pendulum.DateTime` fields
17+
with ISO 8601 serialization. Includes:
18+
- serialize: Converts `pendulum.DateTime` instances to ISO 8601 string.
19+
- deserialize: Converts ISO 8601 strings to `pendulum.DateTime` instances.
20+
- is_iso8601: Validates if a string matches the ISO 8601 date format.
21+
22+
PydanticBaseModel(BaseModel): Extends `pydantic.BaseModel` to handle `pendulum.DateTime` fields
23+
and adds convenience methods for dictionary and JSON serialization. Key methods:
24+
- model_dump: Dumps the model, converting `pendulum.DateTime` fields to ISO 8601.
25+
- model_construct: Constructs a model instance with automatic deserialization of
26+
`pendulum.DateTime` fields from ISO 8601.
27+
- to_dict: Serializes the model instance to a dictionary.
28+
- from_dict: Constructs a model instance from a dictionary.
29+
- to_json: Converts the model instance to a JSON string.
30+
- from_json: Creates a model instance from a JSON string.
31+
32+
Usage Example:
33+
# Define custom settings in a model using PydanticBaseModel
34+
class PredictionCommonSettings(PydanticBaseModel):
35+
prediction_start: pendulum.DateTime = Field(...)
36+
37+
# Serialize a model instance to a dictionary or JSON
38+
config = PredictionCommonSettings(prediction_start=pendulum.now())
39+
config_dict = config.to_dict()
40+
config_json = config.to_json()
41+
42+
# Deserialize from dictionary or JSON
43+
new_config = PredictionCommonSettings.from_dict(config_dict)
44+
restored_config = PredictionCommonSettings.from_json(config_json)
45+
46+
Dependencies:
47+
- `pendulum`: Required for handling timezone-aware datetime fields.
48+
- `pydantic`: Required for model and validation functionality.
49+
50+
Notes:
51+
- This module enables custom handling of Pendulum DateTime fields within Pydantic models,
52+
which is particularly useful for applications requiring consistent ISO 8601 datetime formatting
53+
and robust timezone-aware datetime support.
54+
"""
55+
56+
import json
57+
import re
58+
from typing import Any, Type
59+
60+
import pendulum
61+
from pydantic import BaseModel, ConfigDict, TypeAdapter
62+
63+
64+
# Custom type adapter for Pendulum DateTime fields
65+
class PendulumDateTime(TypeAdapter[pendulum.DateTime]):
66+
@classmethod
67+
def serialize(cls, value: Any) -> str:
68+
"""Convert pendulum.DateTime to ISO 8601 string."""
69+
if isinstance(value, pendulum.DateTime):
70+
return value.to_iso8601_string()
71+
raise ValueError(f"Expected pendulum.DateTime, got {type(value)}")
72+
73+
@classmethod
74+
def deserialize(cls, value: Any) -> pendulum.DateTime:
75+
"""Convert ISO 8601 string to pendulum.DateTime."""
76+
if isinstance(value, str) and cls.is_iso8601(value):
77+
try:
78+
return pendulum.parse(value)
79+
except pendulum.parsing.exceptions.ParserError as e:
80+
raise ValueError(f"Invalid date format: {value}") from e
81+
elif isinstance(value, pendulum.DateTime):
82+
return value
83+
raise ValueError(f"Expected ISO 8601 string or pendulum.DateTime, got {type(value)}")
84+
85+
@staticmethod
86+
def is_iso8601(value: str) -> bool:
87+
"""Check if the string is a valid ISO 8601 date string."""
88+
iso8601_pattern = (
89+
r"^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})?)$"
90+
)
91+
return bool(re.match(iso8601_pattern, value))
92+
93+
94+
class PydanticBaseModel(BaseModel):
95+
"""Base model class with automatic serialization and deserialization of `pendulum.DateTime` fields.
96+
97+
This model serializes pendulum.DateTime objects to ISO 8601 strings and
98+
deserializes ISO 8601 strings to pendulum.DateTime objects.
99+
"""
100+
101+
# Enable custom serialization globally in config
102+
model_config = ConfigDict(
103+
arbitrary_types_allowed=True,
104+
use_enum_values=True,
105+
)
106+
107+
# Override Pydantic’s serialization for all DateTime fields
108+
def model_dump(self, *args, **kwargs) -> dict:
109+
"""Custom dump method to handle serialization for DateTime fields."""
110+
result = super().model_dump(*args, **kwargs)
111+
for key, value in result.items():
112+
if isinstance(value, pendulum.DateTime):
113+
result[key] = PendulumDateTime.serialize(value)
114+
return result
115+
116+
@classmethod
117+
def model_construct(cls, data: dict) -> "PydanticBaseModel":
118+
"""Custom constructor to handle deserialization for DateTime fields."""
119+
for key, value in data.items():
120+
if isinstance(value, str) and PendulumDateTime.is_iso8601(value):
121+
data[key] = PendulumDateTime.deserialize(value)
122+
return super().model_construct(data)
123+
124+
def reset(self) -> "PydanticBaseModel":
125+
"""Resets all optional fields in the model to None.
126+
127+
Iterates through all model fields and sets any optional (non-required)
128+
fields to None. The modification is done in-place on the current instance.
129+
130+
Returns:
131+
PydanticBaseModel: The current instance with all optional fields
132+
reset to None.
133+
134+
Example:
135+
>>> settings = PydanticBaseModel(name="test", optional_field="value")
136+
>>> settings.reset()
137+
>>> assert settings.optional_field is None
138+
"""
139+
for field_name, field in self.model_fields.items():
140+
if field.is_required is False: # Check if field is optional
141+
setattr(self, field_name, None)
142+
return self
143+
144+
def to_dict(self) -> dict:
145+
"""Convert this PredictionRecord instance to a dictionary representation.
146+
147+
Returns:
148+
dict: A dictionary where the keys are the field names of the PydanticBaseModel,
149+
and the values are the corresponding field values.
150+
"""
151+
return self.model_dump()
152+
153+
@classmethod
154+
def from_dict(cls: Type["PydanticBaseModel"], data: dict) -> "PydanticBaseModel":
155+
"""Create a PydanticBaseModel instance from a dictionary.
156+
157+
Args:
158+
data (dict): A dictionary containing data to initialize the PydanticBaseModel.
159+
Keys should match the field names defined in the model.
160+
161+
Returns:
162+
PydanticBaseModel: An instance of the PydanticBaseModel populated with the data.
163+
164+
Notes:
165+
Works with derived classes by ensuring the `cls` argument is used to instantiate the object.
166+
"""
167+
return cls.model_validate(data)
168+
169+
@classmethod
170+
def from_dict_with_reset(cls, data: dict | None = None) -> "PydanticBaseModel":
171+
"""Creates a new instance with reset optional fields, then updates from dict.
172+
173+
First creates an instance with default values, resets all optional fields
174+
to None, then updates the instance with the provided dictionary data if any.
175+
176+
Args:
177+
data (dict | None): Dictionary containing field values to initialize
178+
the instance with. Defaults to None.
179+
180+
Returns:
181+
PydanticBaseModel: A new instance with all optional fields initially
182+
reset to None and then updated with provided data.
183+
184+
Example:
185+
>>> data = {"name": "test", "optional_field": "value"}
186+
>>> settings = PydanticBaseModel.from_dict_with_reset(data)
187+
>>> # All non-specified optional fields will be None
188+
"""
189+
# Create instance with model defaults
190+
instance = cls()
191+
192+
# Reset all optional fields to None
193+
instance.reset()
194+
195+
# Update with provided data if any
196+
if data:
197+
# Use model_validate to ensure proper type conversion and validation
198+
updated_instance = instance.model_validate({**instance.model_dump(), **data})
199+
return updated_instance
200+
201+
return instance
202+
203+
def to_json(self) -> str:
204+
"""Convert the PydanticBaseModel instance to a JSON string.
205+
206+
Returns:
207+
str: The JSON representation of the instance.
208+
"""
209+
return self.model_dump_json()
210+
211+
@classmethod
212+
def from_json(cls: Type["PydanticBaseModel"], json_str: str) -> "PydanticBaseModel":
213+
"""Create an instance of the PydanticBaseModel class or its subclass from a JSON string.
214+
215+
Args:
216+
json_str (str): JSON string to parse and convert into a PydanticBaseModel instance.
217+
218+
Returns:
219+
PydanticBaseModel: A new instance of the class, populated with data from the JSON string.
220+
221+
Notes:
222+
Works with derived classes by ensuring the `cls` argument is used to instantiate the object.
223+
"""
224+
data = json.loads(json_str)
225+
return cls.model_validate(data)

0 commit comments

Comments
 (0)