|
22 | 22 |
|
23 | 23 | import crossplane.function.proto.v1.run_function_pb2 as fnv1 |
24 | 24 | from crossplane.function import logging, resource |
| 25 | +from tests.testdata.models.io.k8s.api.resource import v1 as resourcev1 |
25 | 26 | from tests.testdata.models.io.upbound.aws.s3 import v1beta2 as s3v1beta2 |
26 | 27 | from tests.testdata.models.io.upbound.m.aws.iam.accountalias import ( |
27 | 28 | v1beta1 as accountaliasv1beta1, |
@@ -84,6 +85,21 @@ class TestCase: |
84 | 85 | "status": {"region": "us-west-2", "forceDestroy": False}, |
85 | 86 | }, |
86 | 87 | ), |
| 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 | + ), |
87 | 103 | TestCase( |
88 | 104 | reason="Setting status on an empty resource should work.", |
89 | 105 | r=fnv1.Resource(), |
@@ -192,6 +208,51 @@ class TestCase: |
192 | 208 | ), |
193 | 209 | ), |
194 | 210 | ), |
| 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 | + ), |
195 | 256 | TestCase( |
196 | 257 | # managementPolicies defaults to ["*"] and is set to ["*"] |
197 | 258 | # here. A field the caller sets is one it has an opinion about |
@@ -228,6 +289,66 @@ class TestCase: |
228 | 289 | "-want, +got", |
229 | 290 | ) |
230 | 291 |
|
| 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 | + |
231 | 352 | def test_get_condition(self) -> None: |
232 | 353 | @dataclasses.dataclass |
233 | 354 | class TestCase: |
|
0 commit comments