|
| 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