|
| 1 | +# Copyright 2023 Canonical Ltd. |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +r"""Library to provide simple API for promoting typed, validated and structured dataclass in charms. |
| 16 | +
|
| 17 | +Dict-like data structure are often used in charms. They are used for config, action parameters |
| 18 | +and databag. This library aims at providing simple API for using pydantic BaseModel-derived class |
| 19 | +in charms, in order to enhance: |
| 20 | +* Validation, by embedding custom business logic to validate single parameters or even have |
| 21 | + validators that acts across different fields |
| 22 | +* Parsing, by loading data into pydantic object we can both allow for other types (e.g. float) to |
| 23 | + be used in configuration/parameters as well as specify even nested complex objects for databags |
| 24 | +* Static typing checks, by moving from dict-like object to classes with typed-annotated properties, |
| 25 | + that can be statically checked using mypy to ensure that the code is correct. |
| 26 | +
|
| 27 | +Pydantic models can be used on: |
| 28 | +
|
| 29 | +* Charm Configuration (as defined in config.yaml) |
| 30 | +* Actions parameters (as defined in actions.yaml) |
| 31 | +* Application/Unit Databag Information (thus making it more structured and encoded) |
| 32 | +
|
| 33 | +
|
| 34 | +## Creating models |
| 35 | +
|
| 36 | +Any data-structure can be modeled using dataclasses instead of dict-like objects (e.g. storing |
| 37 | +config, action parameters and databags). Within pydantic, we can define dataclasses that provides |
| 38 | +also parsing and validation on standard dataclass implementation: |
| 39 | +
|
| 40 | +```python |
| 41 | +
|
| 42 | +from charms.data_platform_libs.v0.data_models import BaseConfigModel |
| 43 | +
|
| 44 | +class MyConfig(BaseConfigModel): |
| 45 | +
|
| 46 | + my_key: int |
| 47 | +
|
| 48 | + @validator("my_key") |
| 49 | + def is_lower_than_100(cls, v: int): |
| 50 | + if v > 100: |
| 51 | + raise ValueError("Too high") |
| 52 | +
|
| 53 | +``` |
| 54 | +
|
| 55 | +This should allow to collapse both parsing and validation as the dataclass object is parsed and |
| 56 | +created: |
| 57 | +
|
| 58 | +```python |
| 59 | +dataclass = MyConfig(my_key="1") |
| 60 | +
|
| 61 | +dataclass.my_key # this returns 1 (int) |
| 62 | +dataclass["my_key"] # this returns 1 (int) |
| 63 | +
|
| 64 | +dataclass = MyConfig(my_key="102") # this returns a ValueError("Too High") |
| 65 | +``` |
| 66 | +
|
| 67 | +## Charm Configuration Model |
| 68 | +
|
| 69 | +Using the class above, we can implement parsing and validation of configuration by simply |
| 70 | +extending our charms using the `TypedCharmBase` class, as shown below. |
| 71 | +
|
| 72 | +```python |
| 73 | +class MyCharm(TypedCharmBase[MyConfig]): |
| 74 | + config_type = MyConfig |
| 75 | +
|
| 76 | + # everywhere in the code you will have config property already parsed and validate |
| 77 | + def my_method(self): |
| 78 | + self.config: MyConfig |
| 79 | +``` |
| 80 | +
|
| 81 | +## Action parameters |
| 82 | +
|
| 83 | +In order to parse action parameters, we can use a decorator to be applied to action event |
| 84 | +callbacks, as shown below. |
| 85 | +
|
| 86 | +```python |
| 87 | +@validate_params(PullActionModel) |
| 88 | +def _pull_site_action( |
| 89 | + self, event: ActionEvent, |
| 90 | + params: Optional[Union[PullActionModel, ValidationError]] = None |
| 91 | +): |
| 92 | + if isinstance(params, ValidationError): |
| 93 | + # handle errors |
| 94 | + else: |
| 95 | + # do stuff |
| 96 | +``` |
| 97 | +
|
| 98 | +Note that this changes the signature of the callbacks by adding an extra parameter with the parsed |
| 99 | +counterpart of the `event.params` dict-like field. If validation fails, we return (not throw!) the |
| 100 | +exception, to be handled (or raised) in the callback. |
| 101 | +
|
| 102 | +## Databag |
| 103 | +
|
| 104 | +In order to parse databag fields, we define a decorator to be applied to base relation event |
| 105 | +callbacks. |
| 106 | +
|
| 107 | +```python |
| 108 | +@parse_relation_data(app_model=AppDataModel, unit_model=UnitDataModel) |
| 109 | +def _on_cluster_relation_joined( |
| 110 | + self, event: RelationEvent, |
| 111 | + app_data: Optional[Union[AppDataModel, ValidationError]] = None, |
| 112 | + unit_data: Optional[Union[UnitDataModel, ValidationError]] = None |
| 113 | +) -> None: |
| 114 | + ... |
| 115 | +``` |
| 116 | +
|
| 117 | +The parameters `app_data` and `unit_data` refers to the databag of the entity which fired the |
| 118 | +RelationEvent. |
| 119 | +
|
| 120 | +When we want to access to a relation databag outsides of an action, it can be useful also to |
| 121 | +compact multiple databags into a single object (if there are no conflicting fields), e.g. |
| 122 | +
|
| 123 | +```python |
| 124 | +
|
| 125 | +class ProviderDataBag(BaseClass): |
| 126 | + provider_key: str |
| 127 | +
|
| 128 | +class RequirerDataBag(BaseClass): |
| 129 | + requirer_key: str |
| 130 | +
|
| 131 | +class MergedDataBag(ProviderDataBag, RequirerDataBag): |
| 132 | + pass |
| 133 | +
|
| 134 | +merged_data = get_relation_data_as( |
| 135 | + MergedDataBag, relation.data[self.app], relation.data[relation.app] |
| 136 | +) |
| 137 | +
|
| 138 | +merged_data.requirer_key |
| 139 | +merged_data.provider_key |
| 140 | +
|
| 141 | +``` |
| 142 | +
|
| 143 | +The above code can be generalized to other kinds of merged objects, e.g. application and unit, and |
| 144 | +it can be extended to multiple sources beyond 2: |
| 145 | +
|
| 146 | +```python |
| 147 | +merged_data = get_relation_data_as( |
| 148 | + MergedDataBag, relation.data[self.app], relation.data[relation.app], ... |
| 149 | +) |
| 150 | +``` |
| 151 | +
|
| 152 | +""" |
| 153 | + |
| 154 | +import json |
| 155 | +from functools import reduce, wraps |
| 156 | +from typing import Callable, Generic, MutableMapping, Optional, Type, TypeVar, Union |
| 157 | + |
| 158 | +import pydantic |
| 159 | +from ops.charm import ActionEvent, CharmBase, RelationEvent |
| 160 | +from ops.model import RelationDataContent |
| 161 | +from pydantic import BaseModel, ValidationError |
| 162 | + |
| 163 | +# The unique Charmhub library identifier, never change it |
| 164 | +LIBID = "cb2094c5b07d47e1bf346aaee0fcfcfe" |
| 165 | + |
| 166 | +# Increment this major API version when introducing breaking changes |
| 167 | +LIBAPI = 0 |
| 168 | + |
| 169 | +# Increment this PATCH version before using `charmcraft publish-lib` or reset |
| 170 | +# to 0 if you are raising the major API version |
| 171 | +LIBPATCH = 4 |
| 172 | + |
| 173 | +PYDEPS = ["ops>=2.0.0", "pydantic>=1.10,<2"] |
| 174 | + |
| 175 | +G = TypeVar("G") |
| 176 | +T = TypeVar("T", bound=BaseModel) |
| 177 | +AppModel = TypeVar("AppModel", bound=BaseModel) |
| 178 | +UnitModel = TypeVar("UnitModel", bound=BaseModel) |
| 179 | + |
| 180 | +DataBagNativeTypes = (int, str, float) |
| 181 | + |
| 182 | + |
| 183 | +class BaseConfigModel(BaseModel): |
| 184 | + """Class to be used for defining the structured configuration options.""" |
| 185 | + |
| 186 | + def __getitem__(self, x): |
| 187 | + """Return the item using the notation instance[key].""" |
| 188 | + return getattr(self, x.replace("-", "_")) |
| 189 | + |
| 190 | + |
| 191 | +class TypedCharmBase(CharmBase, Generic[T]): |
| 192 | + """Class to be used for extending config-typed charms.""" |
| 193 | + |
| 194 | + config_type: Type[T] |
| 195 | + |
| 196 | + @property |
| 197 | + def config(self) -> T: |
| 198 | + """Return a config instance validated and parsed using the provided pydantic class.""" |
| 199 | + translated_keys = {k.replace("-", "_"): v for k, v in self.model.config.items()} |
| 200 | + return self.config_type(**translated_keys) |
| 201 | + |
| 202 | + |
| 203 | +def validate_params(cls: Type[T]): |
| 204 | + """Return a decorator to allow pydantic parsing of action parameters. |
| 205 | +
|
| 206 | + Args: |
| 207 | + cls: Pydantic class representing the model to be used for parsing the content of the |
| 208 | + action parameter |
| 209 | + """ |
| 210 | + |
| 211 | + def decorator( |
| 212 | + f: Callable[[CharmBase, ActionEvent, Union[T, ValidationError]], G] |
| 213 | + ) -> Callable[[CharmBase, ActionEvent], G]: |
| 214 | + @wraps(f) |
| 215 | + def event_wrapper(self: CharmBase, event: ActionEvent): |
| 216 | + try: |
| 217 | + params = cls( |
| 218 | + **{key.replace("-", "_"): value for key, value in event.params.items()} |
| 219 | + ) |
| 220 | + except ValidationError as e: |
| 221 | + params = e |
| 222 | + return f(self, event, params) |
| 223 | + |
| 224 | + return event_wrapper |
| 225 | + |
| 226 | + return decorator |
| 227 | + |
| 228 | + |
| 229 | +def write(relation_data: RelationDataContent, model: BaseModel): |
| 230 | + """Write the data contained in a domain object to the relation databag. |
| 231 | +
|
| 232 | + Args: |
| 233 | + relation_data: pointer to the relation databag |
| 234 | + model: instance of pydantic model to be written |
| 235 | + """ |
| 236 | + for key, value in model.dict(exclude_none=False).items(): |
| 237 | + if value: |
| 238 | + relation_data[key.replace("_", "-")] = ( |
| 239 | + str(value) |
| 240 | + if any(isinstance(value, _type) for _type in DataBagNativeTypes) |
| 241 | + else json.dumps(value) |
| 242 | + ) |
| 243 | + else: |
| 244 | + relation_data[key.replace("_", "-")] = "" |
| 245 | + |
| 246 | + |
| 247 | +def read(relation_data: MutableMapping[str, str], obj: Type[T]) -> T: |
| 248 | + """Read data from a relation databag and parse it into a domain object. |
| 249 | +
|
| 250 | + Args: |
| 251 | + relation_data: pointer to the relation databag |
| 252 | + obj: pydantic class representing the model to be used for parsing |
| 253 | + """ |
| 254 | + return obj( |
| 255 | + **{ |
| 256 | + field_name: ( |
| 257 | + relation_data[parsed_key] |
| 258 | + if field.outer_type_ in DataBagNativeTypes |
| 259 | + else json.loads(relation_data[parsed_key]) |
| 260 | + ) |
| 261 | + for field_name, field in obj.__fields__.items() |
| 262 | + # pyright: ignore[reportGeneralTypeIssues] |
| 263 | + if (parsed_key := field_name.replace("_", "-")) in relation_data |
| 264 | + if relation_data[parsed_key] |
| 265 | + } |
| 266 | + ) |
| 267 | + |
| 268 | + |
| 269 | +def parse_relation_data( |
| 270 | + app_model: Optional[Type[AppModel]] = None, unit_model: Optional[Type[UnitModel]] = None |
| 271 | +): |
| 272 | + """Return a decorator to allow pydantic parsing of the app and unit databags. |
| 273 | +
|
| 274 | + Args: |
| 275 | + app_model: Pydantic class representing the model to be used for parsing the content of the |
| 276 | + app databag. None if no parsing ought to be done. |
| 277 | + unit_model: Pydantic class representing the model to be used for parsing the content of the |
| 278 | + unit databag. None if no parsing ought to be done. |
| 279 | + """ |
| 280 | + |
| 281 | + def decorator( |
| 282 | + f: Callable[ |
| 283 | + [ |
| 284 | + CharmBase, |
| 285 | + RelationEvent, |
| 286 | + Optional[Union[AppModel, ValidationError]], |
| 287 | + Optional[Union[UnitModel, ValidationError]], |
| 288 | + ], |
| 289 | + G, |
| 290 | + ] |
| 291 | + ) -> Callable[[CharmBase, RelationEvent], G]: |
| 292 | + @wraps(f) |
| 293 | + def event_wrapper(self: CharmBase, event: RelationEvent): |
| 294 | + try: |
| 295 | + app_data = ( |
| 296 | + read(event.relation.data[event.app], app_model) |
| 297 | + if app_model is not None and event.app |
| 298 | + else None |
| 299 | + ) |
| 300 | + except pydantic.ValidationError as e: |
| 301 | + app_data = e |
| 302 | + |
| 303 | + try: |
| 304 | + unit_data = ( |
| 305 | + read(event.relation.data[event.unit], unit_model) |
| 306 | + if unit_model is not None and event.unit |
| 307 | + else None |
| 308 | + ) |
| 309 | + except pydantic.ValidationError as e: |
| 310 | + unit_data = e |
| 311 | + |
| 312 | + return f(self, event, app_data, unit_data) |
| 313 | + |
| 314 | + return event_wrapper |
| 315 | + |
| 316 | + return decorator |
| 317 | + |
| 318 | + |
| 319 | +class RelationDataModel(BaseModel): |
| 320 | + """Base class to be used for creating data models to be used for relation databags.""" |
| 321 | + |
| 322 | + def write(self, relation_data: RelationDataContent): |
| 323 | + """Write data to a relation databag. |
| 324 | +
|
| 325 | + Args: |
| 326 | + relation_data: pointer to the relation databag |
| 327 | + """ |
| 328 | + return write(relation_data, self) |
| 329 | + |
| 330 | + @classmethod |
| 331 | + def read(cls, relation_data: RelationDataContent) -> "RelationDataModel": |
| 332 | + """Read data from a relation databag and parse it as an instance of the pydantic class. |
| 333 | +
|
| 334 | + Args: |
| 335 | + relation_data: pointer to the relation databag |
| 336 | + """ |
| 337 | + return read(relation_data, cls) |
| 338 | + |
| 339 | + |
| 340 | +def get_relation_data_as( |
| 341 | + model_type: Type[AppModel], |
| 342 | + *relation_data: RelationDataContent, |
| 343 | +) -> Union[AppModel, ValidationError]: |
| 344 | + """Return a merged representation of the provider and requirer databag into a single object. |
| 345 | +
|
| 346 | + Args: |
| 347 | + model_type: pydantic class representing the merged databag |
| 348 | + relation_data: list of RelationDataContent of provider/requirer/unit sides |
| 349 | + """ |
| 350 | + try: |
| 351 | + app_data = read(reduce(lambda x, y: dict(x) | dict(y), relation_data, {}), model_type) |
| 352 | + except pydantic.ValidationError as e: |
| 353 | + app_data = e |
| 354 | + return app_data |
0 commit comments