-
Notifications
You must be signed in to change notification settings - Fork 138
feat: coerce nested dataclass fields in Relation.load #2741
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5c1b270
498554a
518ca11
a6cb225
f2d48c9
ad1b14c
14f95db
d81e1cd
99b6a12
4c0d6bf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,8 @@ | |
| import logging | ||
| import os | ||
| import pathlib | ||
| import types | ||
| import typing | ||
| import warnings | ||
| from collections.abc import Mapping | ||
| from typing import ( | ||
|
|
@@ -33,6 +35,7 @@ | |
| TypedDict, | ||
| TypeVar, | ||
| cast, | ||
| get_type_hints, | ||
| ) | ||
|
|
||
| from . import model | ||
|
|
@@ -1680,10 +1683,13 @@ def _juju_fields(cls: type[object]) -> dict[str, str]: | |
| for field in dataclasses.fields(cls): | ||
| alias = field.metadata.get('alias', field.name) | ||
| # If this a Pydantic dataclass, then it handles the alias. | ||
| # Using pydantic.dataclasses.is_pydantic_dataclass() would be | ||
| # best here, but we don't want to import pydantic in ops, so | ||
| # we look more explicitly. | ||
| if getattr(cls, '__is_pydantic_dataclass__', False): | ||
| # Using pydantic.dataclasses.is_pydantic_dataclass() would be best | ||
| # here, but we don't want to import pydantic in ops, so we check | ||
| # for the attribute that function itself checks for. Note that | ||
| # '__is_pydantic_dataclass__' only exists from pydantic 2.11, so | ||
| # relying on that one misses every earlier 2.x pydantic dataclass, | ||
| # which reads the aliases back under their field names instead. | ||
| if '__pydantic_validator__' in cls.__dict__: | ||
| juju_to_arg[alias] = alias | ||
| else: | ||
| juju_to_arg[alias] = field.name | ||
|
|
@@ -1699,6 +1705,115 @@ def _juju_fields(cls: type[object]) -> dict[str, str]: | |
| raise ValueError('Unable to find class fields') | ||
|
|
||
|
|
||
| def _coerce_field(tp: Any, value: Any) -> Any: | ||
| """Coerce a decoded ``value`` into the dataclass field type ``tp``. | ||
|
|
||
| Used by :meth:`ops.Relation.load` to recursively construct nested | ||
| dataclasses and enum values from JSON-decoded relation data. An | ||
| ``Optional``/``Union`` field is coerced against its single non-``None`` | ||
| member; ``dict``/``Mapping`` fields are coerced against their value type; | ||
| a variable-length ``tuple[X, ...]`` is coerced element-wise against ``X`` | ||
| and a fixed-length ``tuple[X, Y, ...]`` is coerced positionally. | ||
|
|
||
| Raises ``TypeError`` if the value for a sequence field is a string, bytes | ||
| or a mapping, or if the value for a mapping field is not a mapping: those | ||
| are all iterable, so coercing them element-wise would quietly produce a | ||
| wrong answer rather than fail. | ||
| """ | ||
| origin = typing.get_origin(tp) | ||
| if origin is not None: | ||
| args = typing.get_args(tp) | ||
| if origin is typing.Union or origin is types.UnionType: | ||
| non_none = [a for a in args if a is not type(None)] | ||
| if len(non_none) == 1: | ||
| # Optional[X]: coerce against the one concrete member, unless | ||
| # the value really is None, which X itself won't accept. | ||
| if value is None: | ||
| return None | ||
| return _coerce_field(non_none[0], value) | ||
| # A Union of more than one concrete type: no way to tell which | ||
| # member to coerce against, so accept the value as-is. | ||
| return value | ||
| # A str, bytes or mapping is iterable, so coercing element-wise would | ||
| # silently succeed with nonsense: a list of characters, or of the | ||
| # mapping's keys. None of those is a sequence the charm meant, so | ||
| # refuse rather than hand back the wrong answer. | ||
| if ( | ||
| origin in (list, tuple, set, frozenset) | ||
| and args | ||
| and isinstance(value, (str, bytes, Mapping)) | ||
| ): | ||
| given = cast('Any', value) | ||
| raise TypeError(f'expected a sequence for {tp}, got {type(given).__name__}: {given!r}') | ||
| if origin is list and args: | ||
| return [_coerce_field(args[0], v) for v in value] | ||
|
Comment on lines
+1748
to
+1749
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think there's a kind of regression here (and in subsequent branches) because we don't check the type of Consider a charm that currently has: @dataclasses.dataclass
class Data:
tags: str # no type annotation suggesting a listAnd the databag contains Now the charm author sees this PR's feature and thinks "great, I can add proper types." They change the annotation to @dataclasses.dataclass
class Data:
tags: list[str]Same databag data: The charm's behavior changed without the databag data changing — purely because the author added a type annotation. And the new behavior is silently wrong (a list of characters) instead of obviously wrong (a string where a list was expected) or a clear error. |
||
| if origin is tuple and args: | ||
| if args[-1] is Ellipsis: | ||
| return tuple(_coerce_field(args[0], v) for v in value) | ||
| return tuple(_coerce_field(t, v) for t, v in zip(args, value, strict=True)) | ||
| if origin is set and args: | ||
| return {_coerce_field(args[0], v) for v in value} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will always produce a |
||
| if origin is frozenset and args: | ||
| return frozenset(_coerce_field(args[0], v) for v in value) | ||
| if isinstance(origin, type) and issubclass(origin, Mapping) and len(args) == 2: | ||
| if not isinstance(value, Mapping): | ||
| raise TypeError( | ||
| f'expected a mapping for {tp}, got {type(value).__name__}: {value!r}' | ||
| ) | ||
| mapping = cast('Mapping[Any, Any]', value) | ||
| return {k: _coerce_field(args[1], v) for k, v in mapping.items()} | ||
| # Literal and other constructed generics: accept the value as-is. | ||
| return value | ||
| if isinstance(tp, type): | ||
| if dataclasses.is_dataclass(tp): | ||
| if isinstance(value, tp): | ||
| # Already the class we want: a caller's keyword argument passed | ||
| # through to `Relation.load`, rather than anything from a | ||
| # databag, so there is nothing to coerce. | ||
| return value | ||
| if not isinstance(value, Mapping): | ||
| # Without this, a remote app writing a string or a list where a | ||
| # nested dataclass belongs gets a default-constructed object | ||
| # that corresponds to nothing in the databag: `field.name not in | ||
| # 'oops'` is False for every field, so every one is skipped. | ||
| raise TypeError( | ||
| f'expected a mapping for {tp.__name__}, got {type(value).__name__}: {value!r}' | ||
| ) | ||
| return _build_dataclass(tp, cast('Mapping[str, Any]', value)) | ||
| if issubclass(tp, enum.Enum): | ||
| return tp(value) | ||
| return value | ||
|
|
||
|
|
||
| def _build_dataclass(cls: Any, data: Mapping[str, Any], *args: Any) -> Any: | ||
| """Construct dataclass ``cls`` from ``data`` and any positional ``args``. | ||
|
|
||
| Recursively coerces nested dataclass / enum / list / set / tuple / dict | ||
| fields supplied via ``data``. Any leading fields already filled | ||
| positionally by ``args`` are matched by position, not by name, so they are | ||
| passed through as given rather than coerced. | ||
|
|
||
| Falls back to the un-coerced ``cls(*args, **data)`` if ``cls``'s type hints | ||
| can't be resolved, for example a ``TYPE_CHECKING``-only import with no | ||
| runtime name: ``get_type_hints`` resolves every field's annotation | ||
| eagerly, so one unresolvable field would otherwise break construction even | ||
| when the relation data at hand doesn't touch it. | ||
|
|
||
| Raises ``TypeError`` (via the dataclass ``__init__``) if a required field is | ||
| missing, and ``ValueError``/``TypeError`` from coercion of malformed values. | ||
| """ | ||
| try: | ||
| hints = get_type_hints(cls) | ||
| except NameError: | ||
| return cls(*args, **data) | ||
| kwargs: dict[str, Any] = {} | ||
| for field in dataclasses.fields(cls)[len(args) :]: | ||
| if field.name not in data: | ||
| continue | ||
| kwargs[field.name] = _coerce_field(hints[field.name], data[field.name]) | ||
| return cls(*args, **kwargs) | ||
|
|
||
|
|
||
| class CharmMeta: | ||
| """Object containing the metadata for the charm. | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is a regression. If
valueis actuallyNone(from anullin the databag JSON), we'll end up calling_build_dataclass(Something, None), which will raise atif field.name not in data.To match the current behaviour we need something like this, I think:
Is it only a regression in the case the charm code uses types and fails to anticipate
None? I'm not 100% confident in my reasoning.