From 1a38eadcaaba5cb6d00ff76100ba71b676f27ee4 Mon Sep 17 00:00:00 2001 From: Dima Tisnek Date: Wed, 4 Feb 2026 10:16:29 +0900 Subject: [PATCH 1/7] fix: support Pydantic MISSING sentinal in ops.Relation.save --- ops/model.py | 2 +- test/test_model_relation_data_class.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ops/model.py b/ops/model.py index 00ea3260e..4f0489968 100644 --- a/ops/model.py +++ b/ops/model.py @@ -1915,7 +1915,7 @@ def _add_transfer(self): values = {field: getattr(obj, field) for field in fields} # Encode each value, and then pass it over to Juju. - data = {field: encoder(values[attr]) for attr, field in sorted(fields.items())} + data = {field: encoder(values[attr]) if attr in values else '' for attr, field in sorted(fields.items())} self.data[dst].update(data) diff --git a/test/test_model_relation_data_class.py b/test/test_model_relation_data_class.py index 61a588053..a9612abfd 100644 --- a/test/test_model_relation_data_class.py +++ b/test/test_model_relation_data_class.py @@ -31,6 +31,11 @@ except ImportError: pydantic = None +try: + from pydantic.experimental.missing_sentinel import MISSING +except ImportError: + MISSING = None # type: ignore + import ops from ops import testing @@ -193,6 +198,7 @@ class MyPydanticDatabag(pydantic.BaseModel): bar: int = pydantic.Field(default=0, ge=0) baz: list[str] = pydantic.Field(default_factory=list) quux: Nested = pydantic.Field(default_factory=Nested) + miss: str | MISSING = MISSING # type: ignore @pydantic.field_validator('baz') @classmethod From 547a56d1fe8fe828aa0f6facfe74b4a6c00557d5 Mon Sep 17 00:00:00 2001 From: Dima Tisnek Date: Mon, 16 Feb 2026 10:15:10 +0900 Subject: [PATCH 2/7] ruff format --- ops/model.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ops/model.py b/ops/model.py index 4f0489968..6a07949a6 100644 --- a/ops/model.py +++ b/ops/model.py @@ -1915,7 +1915,10 @@ def _add_transfer(self): values = {field: getattr(obj, field) for field in fields} # Encode each value, and then pass it over to Juju. - data = {field: encoder(values[attr]) if attr in values else '' for attr, field in sorted(fields.items())} + data = { + field: encoder(values[attr]) if attr in values else '' + for attr, field in sorted(fields.items()) + } self.data[dst].update(data) From f0fb91eec22356a5769f01a5f0161495175a7936 Mon Sep 17 00:00:00 2001 From: Dima Tisnek Date: Mon, 16 Feb 2026 10:50:15 +0900 Subject: [PATCH 3/7] make a separate test for MISSING --- test/test_model_relation_data_class.py | 28 +++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/test/test_model_relation_data_class.py b/test/test_model_relation_data_class.py index a9612abfd..98ff0eab1 100644 --- a/test/test_model_relation_data_class.py +++ b/test/test_model_relation_data_class.py @@ -198,7 +198,6 @@ class MyPydanticDatabag(pydantic.BaseModel): bar: int = pydantic.Field(default=0, ge=0) baz: list[str] = pydantic.Field(default_factory=list) quux: Nested = pydantic.Field(default_factory=Nested) - miss: str | MISSING = MISSING # type: ignore @pydantic.field_validator('baz') @classmethod @@ -219,6 +218,33 @@ def databag_class(self) -> type[DatabagProtocol]: _test_classes.extend((MyPydanticDataclassCharm, MyPydanticBaseModelCharm)) +if pydantic and MISSING is not None: + class MissingPydanticDatabag(pydantic.BaseModel): + foo: str + bar: int = pydantic.Field(default=0, ge=0) + baz: list[str] = pydantic.Field(default_factory=list) + quux: Nested = pydantic.Field(default_factory=Nested) + miss: str | MISSING = MISSING # type: ignore + + @pydantic.field_validator('baz') + @classmethod + def check_foo_not_in_baz(cls, baz: list[str], values: Any): + data = cast('dict[str, Any]', values.data) + foo = data.get('foo') + if foo in baz: + raise ValueError('foo cannot be in baz') + return baz + + model_config = pydantic.ConfigDict(validate_assignment=True) + + class MissingPydanticBaseModelCharm(BaseTestCharm): + @property + def databag_class(self) -> type[DatabagProtocol]: + return MissingPydanticDatabag + + _test_classes.append(MissingPydanticBaseModelCharm) + + @pytest.mark.parametrize('charm_class', _test_classes) def test_relation_load_simple(charm_class: type[BaseTestCharm]): class Charm(charm_class): From 2276269eebce733c2b2717d34ffa1048ecad71be Mon Sep 17 00:00:00 2001 From: Dima Tisnek Date: Mon, 16 Feb 2026 10:50:37 +0900 Subject: [PATCH 4/7] ruff format --- test/test_model_relation_data_class.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_model_relation_data_class.py b/test/test_model_relation_data_class.py index 98ff0eab1..c9f9af74b 100644 --- a/test/test_model_relation_data_class.py +++ b/test/test_model_relation_data_class.py @@ -219,6 +219,7 @@ def databag_class(self) -> type[DatabagProtocol]: if pydantic and MISSING is not None: + class MissingPydanticDatabag(pydantic.BaseModel): foo: str bar: int = pydantic.Field(default=0, ge=0) From b3c4b4c47d8fd583d3942cc2eecb6554532fe03c Mon Sep 17 00:00:00 2001 From: Dima Tisnek Date: Thu, 19 Feb 2026 15:48:54 +0900 Subject: [PATCH 5/7] Document that fields without a value are erased --- ops/model.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ops/model.py b/ops/model.py index 6a07949a6..e3a8cb2dd 100644 --- a/ops/model.py +++ b/ops/model.py @@ -1853,8 +1853,9 @@ def save( fields will be saved through to the relation data. Pydantic fields that have an ``alias``, or dataclasses that have a ``metadata{'alias'=}``, will have the object's value saved to the Juju relation data with the - alias as the key. For other classes, all of the object's attributes that - have a class type annotation and value set on the object will be saved + alias as the key. Fields without a value (e.g. Pydantic's ``MISSING`` + sentinel) will be erased. For other classes, all of the object's attributes + that have a class type annotation and value set on the object will be saved through to the relation data. For example:: @@ -1915,6 +1916,7 @@ def _add_transfer(self): values = {field: getattr(obj, field) for field in fields} # Encode each value, and then pass it over to Juju. + # Missing values are erased from the databag via an empty string. data = { field: encoder(values[attr]) if attr in values else '' for attr, field in sorted(fields.items()) From 5dfe611df49b850497f4890b7fb7b8a625074b93 Mon Sep 17 00:00:00 2001 From: Dima Tisnek Date: Fri, 20 Feb 2026 12:22:12 +0900 Subject: [PATCH 6/7] rewrite the .save() doc string --- ops/model.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ops/model.py b/ops/model.py index e3a8cb2dd..74b970839 100644 --- a/ops/model.py +++ b/ops/model.py @@ -1853,9 +1853,8 @@ def save( fields will be saved through to the relation data. Pydantic fields that have an ``alias``, or dataclasses that have a ``metadata{'alias'=}``, will have the object's value saved to the Juju relation data with the - alias as the key. Fields without a value (e.g. Pydantic's ``MISSING`` - sentinel) will be erased. For other classes, all of the object's attributes - that have a class type annotation and value set on the object will be saved + alias as the key. For other classes, all of the object's attributes that + have a class type annotation and value set on the object will be saved through to the relation data. For example:: @@ -1874,6 +1873,10 @@ def _add_transfer(self): # data.destination will be stored under the Juju relation key 'to' relation.save(data, self.unit) + If a class declares a field, but the object does not have a value for it, + the field will be erased from the relation data. + This is possible when using Pydantic's ``MISSING`` sentinel. + Args: obj: an object with attributes to save to the relation data, typically a Pydantic ``BaseModel`` subclass or dataclass. From 5290144aecc127bc87e2bc393840dc7fae33d236 Mon Sep 17 00:00:00 2001 From: Dima Tisnek Date: Wed, 25 Feb 2026 10:37:23 +0900 Subject: [PATCH 7/7] better doc string and comment --- ops/model.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ops/model.py b/ops/model.py index 74b970839..b75a950c4 100644 --- a/ops/model.py +++ b/ops/model.py @@ -1873,9 +1873,9 @@ def _add_transfer(self): # data.destination will be stored under the Juju relation key 'to' relation.save(data, self.unit) - If a class declares a field, but the object does not have a value for it, - the field will be erased from the relation data. - This is possible when using Pydantic's ``MISSING`` sentinel. + If a Pydantic model's ``model_dump`` method omits any field (e.g. if its + value is Pydantic's ``MISSING`` sentinel) the field will be erased from + the relation data. Args: obj: an object with attributes to save to the relation data, typically @@ -1919,7 +1919,7 @@ def _add_transfer(self): values = {field: getattr(obj, field) for field in fields} # Encode each value, and then pass it over to Juju. - # Missing values are erased from the databag via an empty string. + # Missing values are erased from the databag using empty string values. data = { field: encoder(values[attr]) if attr in values else '' for attr, field in sorted(fields.items())