Skip to content
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

Add a resource.update convenience function #98

Merged
merged 3 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions crossplane/function/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,39 @@
import dataclasses
import datetime

import pydantic
from google.protobuf import struct_pb2 as structpb

import crossplane.function.proto.v1.run_function_pb2 as fnv1

# TODO(negz): Do we really need dict_to_struct and struct_to_dict? They don't do
# much, but are perhaps useful for discoverability/"documentation" purposes.


def update(r: fnv1.Resource, source: dict | structpb.Struct | pydantic.BaseModel):
"""Update a composite or composed resource.

Use update to add or update the supplied resource. If the resource doesn't
exist, it'll be added. If the resource does exist, it'll be updated. The
update method semantics are the same as a dictionary's update method. Fields
that don't exist will be added. Fields that exist will be overwritten.

The source can be a dictionary, a protobuf Struct, or a Pydantic model.
"""
match source:
case pydantic.BaseModel():
r.resource.update(source.model_dump(exclude_defaults=True, warnings=False))
case structpb.Struct():
# TODO(negz): Use struct_to_dict and update to match other semantics?
r.resource.MergeFrom(source)
case dict():
r.resource.update(source)
case _:
t = type(source)
msg = f"Unsupported type: {t}"
raise TypeError(msg)


def dict_to_struct(d: dict) -> structpb.Struct:
"""Create a Struct well-known type from the supplied dict.

Expand Down
10 changes: 8 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ classifiers = [
"Programming Language :: Python :: 3.11",
]

dependencies = ["grpcio==1.*", "grpcio-reflection==1.*", "protobuf==5.27.2", "structlog==24.*"]
dependencies = [
"grpcio==1.*",
"grpcio-reflection==1.*",
"protobuf==5.27.2",
"pydantic==2.*",
Copy link
Member Author

@negz negz Oct 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love introducing a dependency on pydantic just for the type annotation really. I think if it proves to be a pain we could move it out to an optional dependency.

I don't think pydantic has a lot of dependencies though, so it may not be so bad.

"structlog==24.*",
]

dynamic = ["version"]

Expand Down Expand Up @@ -73,7 +79,7 @@ packages = ["crossplane"]

[tool.ruff]
target-version = "py311"
exclude = ["crossplane/function/proto/*"]
exclude = ["crossplane/function/proto/*", "tests/testdata/*"]
lint.select = [
"A",
"ARG",
Expand Down
80 changes: 80 additions & 0 deletions tests/test_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,95 @@
import datetime
import unittest

import pydantic
from google.protobuf import json_format
from google.protobuf import struct_pb2 as structpb

import crossplane.function.proto.v1.run_function_pb2 as fnv1
from crossplane.function import logging, resource

from .testdata.models.io.upbound.aws.s3 import v1beta2


class TestResource(unittest.TestCase):
def setUp(self) -> None:
logging.configure(level=logging.Level.DISABLED)

def test_add(self) -> None:
@dataclasses.dataclass
class TestCase:
reason: str
r: fnv1.Resource
source: dict | structpb.Struct | pydantic.BaseModel
want: fnv1.Resource

cases = [
TestCase(
reason="Updating from a dict should work.",
r=fnv1.Resource(),
source={"apiVersion": "example.org", "kind": "Resource"},
want=fnv1.Resource(
resource=resource.dict_to_struct(
{"apiVersion": "example.org", "kind": "Resource"}
),
),
),
TestCase(
reason="Updating an existing resource from a dict should work.",
r=fnv1.Resource(
resource=resource.dict_to_struct(
{"apiVersion": "example.org", "kind": "Resource"}
),
),
source={
"metadata": {"name": "cool"},
},
want=fnv1.Resource(
resource=resource.dict_to_struct(
{
"apiVersion": "example.org",
"kind": "Resource",
"metadata": {"name": "cool"},
}
),
),
),
TestCase(
reason="Updating from a struct should work.",
r=fnv1.Resource(),
source=resource.dict_to_struct(
{"apiVersion": "example.org", "kind": "Resource"}
),
want=fnv1.Resource(
resource=resource.dict_to_struct(
{"apiVersion": "example.org", "kind": "Resource"}
),
),
),
TestCase(
reason="Updating from a Pydantic model should work.",
r=fnv1.Resource(),
source=v1beta2.Bucket(
spec=v1beta2.Spec(
forProvider=v1beta2.ForProvider(region="us-west-2"),
),
),
want=fnv1.Resource(
resource=resource.dict_to_struct(
{"spec": {"forProvider": {"region": "us-west-2"}}}
),
),
),
]

for case in cases:
resource.update(case.r, case.source)
self.assertEqual(
json_format.MessageToDict(case.want),
json_format.MessageToDict(case.r),
"-want, +got",
)

def test_get_condition(self) -> None:
@dataclasses.dataclass
class TestCase:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# generated by datamodel-codegen:
# filename: <stdin>
# timestamp: 2024-10-04T21:01:52+00:00
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# generated by datamodel-codegen:
# filename: <stdin>
# timestamp: 2024-10-04T21:01:52+00:00
Loading