Skip to content

Commit d18cbbb

Browse files
committed
Pass by_alias when serializing models in update and update_status
resource.update and resource.update_status serialize Pydantic models with model_dump(exclude_unset=True), which doesn't pass by_alias=True. A model field that carries a Pydantic alias is then serialized under its Python attribute name rather than its alias, which is the resource's real wire name. This bites fields whose KRM name is a Python keyword or builtin. datamodel-code-generator can't name a field bool, int, from, continue, or schema, so it emits a bool_ attribute aliased to bool, int_ aliased to int, and so on. When a function composes a resource that sets such a field, update wrote the Python name into the desired resource: data = source.model_dump(exclude_unset=True) # -> {"bool_": True}, but the resource's field is "bool" The composed resource then carried bool_: true instead of bool: true, which doesn't match the resource's schema. The API server rejects it or silently drops the unknown field, and the field the function set never takes effect. This surfaced with Kubernetes Dynamic Resource Allocation, whose device attribute value is a one-of over string, version, bool, and int. This change passes by_alias=True alongside exclude_unset=True in both functions. It's a no-op for ordinary fields, which have no alias, and corrects only the keyword-collision cases. It's also symmetric with how pydantic deserializes these models: by default an aliased field is populated only by its alias, so reads and writes now both speak wire names. Fixes #210. Signed-off-by: Nic Cope <nicc@rk0n.org>
1 parent 572ae75 commit d18cbbb

5 files changed

Lines changed: 203 additions & 3 deletions

File tree

crossplane/function/resource.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,15 @@ def update(r: fnv1.Resource, source: dict | structpb.Struct | pydantic.BaseModel
4444
# Crossplane treats desired resources as server-side apply intent,
4545
# so a function should own exactly the fields it has an opinion
4646
# about and leave the rest to the API server.
47-
data = source.model_dump(exclude_unset=True, warnings=False)
47+
#
48+
# by_alias emits each field under its alias, which is its real
49+
# wire name. datamodel-code-generator aliases fields whose KRM
50+
# name collides with a Python keyword or builtin (e.g. it emits a
51+
# bool_ attribute aliased to bool, continue_ aliased to continue).
52+
# Without by_alias those fields serialize under the Python name and
53+
# don't match the resource's schema. It's a no-op for ordinary
54+
# fields, which have no alias.
55+
data = source.model_dump(exclude_unset=True, by_alias=True, warnings=False)
4856
# apiVersion and kind identify the resource but are rarely passed
4957
# as kwargs, so they're usually unset. Add them back explicitly.
5058
data["apiVersion"] = source.apiVersion
@@ -73,10 +81,11 @@ def update_status(
7381
7482
Sets ``r.resource.status`` from the supplied status. When the status
7583
is a Pydantic model, fields the caller didn't explicitly set are
76-
excluded, matching the behavior of :func:`update`.
84+
excluded and aliased fields are emitted under their wire names,
85+
matching the behavior of :func:`update`.
7786
"""
7887
if isinstance(status, pydantic.BaseModel):
79-
status = status.model_dump(exclude_unset=True, warnings=False)
88+
status = status.model_dump(exclude_unset=True, by_alias=True, warnings=False)
8089
update(r, {"status": status})
8190

8291

tests/test_resource.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import crossplane.function.proto.v1.run_function_pb2 as fnv1
2424
from crossplane.function import logging, resource
25+
from tests.testdata.models.io.k8s.api.resource import v1 as resourcev1
2526
from tests.testdata.models.io.upbound.aws.s3 import v1beta2 as s3v1beta2
2627
from tests.testdata.models.io.upbound.m.aws.iam.accountalias import (
2728
v1beta1 as accountaliasv1beta1,
@@ -84,6 +85,21 @@ class TestCase:
8485
"status": {"region": "us-west-2", "forceDestroy": False},
8586
},
8687
),
88+
TestCase(
89+
reason="Setting status from a Pydantic model with keyword-"
90+
"aliased fields should emit the fields under their aliases.",
91+
r=fnv1.Resource(
92+
resource=resource.dict_to_struct(
93+
{"apiVersion": "example.org", "kind": "XR"}
94+
),
95+
),
96+
status=resourcev1.DeviceAttribute(**{"bool": True}),
97+
want={
98+
"apiVersion": "example.org",
99+
"kind": "XR",
100+
"status": {"bool": True},
101+
},
102+
),
87103
TestCase(
88104
reason="Setting status on an empty resource should work.",
89105
r=fnv1.Resource(),
@@ -192,6 +208,51 @@ class TestCase:
192208
),
193209
),
194210
),
211+
TestCase(
212+
# datamodel-code-generator can't name a field bool or int, so
213+
# it emits bool_ aliased to bool and int_ aliased to int. The
214+
# alias is the resource's real wire name, so update must emit
215+
# fields under their aliases.
216+
reason="Updating from a Pydantic model with keyword-aliased "
217+
"fields should emit the fields under their aliases.",
218+
r=fnv1.Resource(),
219+
source=resourcev1.ResourceSlice(
220+
spec=resourcev1.Spec(
221+
devices=[
222+
resourcev1.Device(
223+
name="gpu",
224+
attributes={
225+
"powered": resourcev1.DeviceAttribute(
226+
**{"bool": True},
227+
),
228+
"lanes": resourcev1.DeviceAttribute(
229+
**{"int": 16},
230+
),
231+
},
232+
),
233+
],
234+
),
235+
),
236+
want=fnv1.Resource(
237+
resource=resource.dict_to_struct(
238+
{
239+
"apiVersion": "resource.k8s.io/v1",
240+
"kind": "ResourceSlice",
241+
"spec": {
242+
"devices": [
243+
{
244+
"name": "gpu",
245+
"attributes": {
246+
"powered": {"bool": True},
247+
"lanes": {"int": 16},
248+
},
249+
},
250+
],
251+
},
252+
}
253+
),
254+
),
255+
),
195256
TestCase(
196257
# managementPolicies defaults to ["*"] and is set to ["*"]
197258
# here. A field the caller sets is one it has an opinion about
@@ -228,6 +289,66 @@ class TestCase:
228289
"-want, +got",
229290
)
230291

292+
def test_model_round_trip(self) -> None:
293+
# A function reads an observed resource (wire names), validates it into
294+
# a model, then writes it back via update. A field that goes in under
295+
# its wire name must come back out under the same wire name. This pins
296+
# the property the by_alias fix exists to guarantee: validation accepts
297+
# the alias, and serialization must emit the alias, not the Python
298+
# attribute name. It does not assert anything about fields the model
299+
# doesn't define (pydantic drops them) or value types (Struct coerces
300+
# numbers to float).
301+
@dataclasses.dataclass
302+
class TestCase:
303+
reason: str
304+
# The resource as it arrives from Crossplane, using wire names.
305+
observed: dict
306+
# The model type to validate the observed resource into.
307+
model: type[pydantic.BaseModel]
308+
309+
cases = [
310+
TestCase(
311+
reason="A model with keyword-aliased fields should round-trip "
312+
"through validation and update with its fields under the same "
313+
"wire names (bool, int) they arrived under.",
314+
observed={
315+
"apiVersion": "resource.k8s.io/v1",
316+
"kind": "ResourceSlice",
317+
"spec": {
318+
"devices": [
319+
{
320+
"name": "gpu",
321+
"attributes": {
322+
"powered": {"bool": True},
323+
"lanes": {"int": 16},
324+
"model": {"string": "h100"},
325+
},
326+
},
327+
],
328+
},
329+
},
330+
model=resourcev1.ResourceSlice,
331+
),
332+
TestCase(
333+
reason="A model with only ordinary fields should round-trip unchanged.",
334+
observed={
335+
"apiVersion": "s3.aws.upbound.io/v1beta2",
336+
"kind": "Bucket",
337+
"spec": {"forProvider": {"region": "us-west-2"}},
338+
},
339+
model=s3v1beta2.Bucket,
340+
),
341+
]
342+
343+
for case in cases:
344+
# Mimic the SDK flow: a function reads an observed resource (wire
345+
# names), validates it into a model, then writes it back out.
346+
m = case.model.model_validate(case.observed)
347+
r = fnv1.Resource()
348+
resource.update(r, m)
349+
got = resource.struct_to_dict(r.resource)
350+
self.assertEqual(case.observed, got, case.reason)
351+
231352
def test_get_condition(self) -> None:
232353
@dataclasses.dataclass
233354
class TestCase:
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# generated by datamodel-codegen:
2+
# filename: <stdin>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# generated by datamodel-codegen:
2+
# filename: <stdin>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# generated by datamodel-codegen:
2+
# filename: <stdin>
3+
4+
from __future__ import annotations
5+
6+
from typing import Literal
7+
8+
from pydantic import BaseModel, Field
9+
10+
from ...apimachinery.pkg.apis.meta import v1
11+
12+
13+
class DeviceAttribute(BaseModel):
14+
bool_: bool | None = Field(None, alias='bool')
15+
"""
16+
BoolValue is a true/false value.
17+
"""
18+
int_: int | None = Field(None, alias='int')
19+
"""
20+
IntValue is a number.
21+
"""
22+
string: str | None = None
23+
"""
24+
StringValue is a string.
25+
"""
26+
version: str | None = None
27+
"""
28+
VersionValue is a semantic version according to semver.org spec 2.0.0.
29+
"""
30+
31+
32+
class Device(BaseModel):
33+
name: str
34+
"""
35+
Name is a unique identifier among all devices managed by the driver.
36+
"""
37+
attributes: dict[str, DeviceAttribute] | None = None
38+
"""
39+
Attributes defines the set of attributes for this device.
40+
"""
41+
42+
43+
class Spec(BaseModel):
44+
devices: list[Device] | None = None
45+
"""
46+
Devices lists the devices in this resource slice.
47+
"""
48+
49+
50+
class ResourceSlice(BaseModel):
51+
apiVersion: Literal['resource.k8s.io/v1'] | None = 'resource.k8s.io/v1'
52+
"""
53+
APIVersion defines the versioned schema of this representation of an object.
54+
"""
55+
kind: Literal['ResourceSlice'] | None = 'ResourceSlice'
56+
"""
57+
Kind is a string value representing the REST resource this object represents.
58+
"""
59+
metadata: v1.ObjectMeta | None = None
60+
"""
61+
Standard object's metadata.
62+
"""
63+
spec: Spec
64+
"""
65+
Contents of the ResourceSlice.
66+
"""

0 commit comments

Comments
 (0)