crossplane-function-sdk-python 0.11.0__tar.gz → 0.12.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/.github/workflows/ci.yml +7 -7
  2. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/PKG-INFO +3 -3
  3. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/crossplane/function/__version__.py +1 -1
  4. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/crossplane/function/resource.py +76 -2
  5. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/crossplane/function/response.py +38 -0
  6. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/pyproject.toml +2 -2
  7. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/tests/test_resource.py +135 -1
  8. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/tests/test_response.py +66 -0
  9. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  10. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  11. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  12. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/.gitignore +0 -0
  13. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/CODEOWNERS +0 -0
  14. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/LICENSE +0 -0
  15. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/OWNERS.md +0 -0
  16. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/README.md +0 -0
  17. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/crossplane/function/logging.py +0 -0
  18. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/crossplane/function/proto/v1/run_function.proto +0 -0
  19. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/crossplane/function/proto/v1/run_function_pb2.py +0 -0
  20. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/crossplane/function/proto/v1/run_function_pb2.pyi +0 -0
  21. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/crossplane/function/proto/v1/run_function_pb2_grpc.py +0 -0
  22. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/crossplane/function/proto/v1beta1/run_function.proto +0 -0
  23. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/crossplane/function/proto/v1beta1/run_function_pb2.py +0 -0
  24. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/crossplane/function/proto/v1beta1/run_function_pb2.pyi +0 -0
  25. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/crossplane/function/proto/v1beta1/run_function_pb2_grpc.py +0 -0
  26. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/crossplane/function/py.typed +0 -0
  27. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/crossplane/function/request.py +0 -0
  28. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/crossplane/function/runtime.py +0 -0
  29. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/renovate.json +0 -0
  30. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/tests/test_request.py +0 -0
  31. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/tests/test_runtime.py +0 -0
  32. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/tests/testdata/models/io/k8s/apimachinery/pkg/apis/__init__.py +0 -0
  33. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/tests/testdata/models/io/k8s/apimachinery/pkg/apis/meta/__init__.py +0 -0
  34. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/tests/testdata/models/io/k8s/apimachinery/pkg/apis/meta/v1.py +0 -0
  35. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/tests/testdata/models/io/upbound/aws/s3/__init__.py +0 -0
  36. {crossplane_function_sdk_python-0.11.0 → crossplane_function_sdk_python-0.12.0}/tests/testdata/models/io/upbound/aws/s3/v1beta2.py +0 -0
@@ -28,7 +28,7 @@ concurrency:
28
28
  env:
29
29
  # Common versions
30
30
  PYTHON_VERSION: '3.11'
31
- HATCH_VERSION: '1.12.0'
31
+ HATCH_VERSION: '1.16.5'
32
32
 
33
33
  # The PyPi project version to push. The default is v0.0.0+gitdate-gitsha.
34
34
  PYPI_VERSION: ${{ inputs.version }}
@@ -98,7 +98,7 @@ jobs:
98
98
  run: hatch build
99
99
 
100
100
  - name: Upload Sdist and Wheel to GitHub
101
- uses: actions/upload-artifact@v6
101
+ uses: actions/upload-artifact@v7
102
102
  with:
103
103
  name: dist
104
104
  path: "dist/*"
@@ -114,13 +114,13 @@ jobs:
114
114
  runs-on: ubuntu-24.04
115
115
  steps:
116
116
  - name: Download Sdist and Wheel from GitHub
117
- uses: actions/download-artifact@v7
117
+ uses: actions/download-artifact@v8
118
118
  with:
119
119
  name: dist
120
120
  path: "dist"
121
121
 
122
122
  - name: Publish to PyPI
123
- uses: pypa/gh-action-pypi-publish@v1.13.0
123
+ uses: pypa/gh-action-pypi-publish@v1.14.0
124
124
  with:
125
125
  # Note that this is currently being pushed to the 'crossplane' PyPI
126
126
  # user (not org). See @negz if you need access - PyPI requires 2FA to
@@ -150,13 +150,13 @@ jobs:
150
150
  run: hatch run docs:pdoc -d google crossplane/function -o docs
151
151
 
152
152
  - name: Setup Pages
153
- uses: actions/configure-pages@v5
153
+ uses: actions/configure-pages@v6
154
154
 
155
155
  - name: Upload artifact
156
- uses: actions/upload-pages-artifact@v4
156
+ uses: actions/upload-pages-artifact@v5
157
157
  with:
158
158
  path: docs
159
159
 
160
160
  - name: Deploy to GitHub Pages
161
161
  id: deployment
162
- uses: actions/deploy-pages@v4
162
+ uses: actions/deploy-pages@v5
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crossplane-function-sdk-python
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: The Python SDK for Crossplane composition functions
5
5
  Project-URL: Documentation, https://github.com/crossplane/function-sdk-python#readme
6
6
  Project-URL: Issues, https://github.com/crossplane/function-sdk-python/issues
@@ -14,8 +14,8 @@ Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Typing :: Typed
15
15
  Requires-Python: >=3.11
16
16
  Requires-Dist: grpcio-reflection==1.*
17
- Requires-Dist: grpcio==1.76.0
18
- Requires-Dist: protobuf==6.33.5
17
+ Requires-Dist: grpcio==1.80.0
18
+ Requires-Dist: protobuf==7.35.0
19
19
  Requires-Dist: pydantic==2.*
20
20
  Requires-Dist: structlog==25.*
21
21
  Description-Content-Type: text/markdown
@@ -15,4 +15,4 @@
15
15
  """The version of function-sdk-python."""
16
16
 
17
17
  # This is set at build time, using "hatch version"
18
- __version__ = "0.11.0"
18
+ __version__ = "0.12.0"
@@ -16,6 +16,7 @@
16
16
 
17
17
  import dataclasses
18
18
  import datetime
19
+ import hashlib
19
20
 
20
21
  import pydantic
21
22
  from google.protobuf import json_format
@@ -59,6 +60,25 @@ def update(r: fnv1.Resource, source: dict | structpb.Struct | pydantic.BaseModel
59
60
  raise TypeError(msg)
60
61
 
61
62
 
63
+ def update_status(
64
+ r: fnv1.Resource,
65
+ status: dict | pydantic.BaseModel,
66
+ ) -> None:
67
+ """Update a resource's status.
68
+
69
+ Args:
70
+ r: A composite or composed resource to update.
71
+ status: The status to set, as a dictionary or Pydantic model.
72
+
73
+ Sets ``r.resource.status`` from the supplied status. When the status
74
+ is a Pydantic model, fields set to their default value are excluded,
75
+ matching the behavior of :func:`update`.
76
+ """
77
+ if isinstance(status, pydantic.BaseModel):
78
+ status = status.model_dump(exclude_defaults=True, warnings=False)
79
+ update(r, {"status": status})
80
+
81
+
62
82
  def dict_to_struct(d: dict) -> structpb.Struct:
63
83
  """Create a Struct well-known type from the supplied dict.
64
84
 
@@ -99,11 +119,17 @@ class Condition:
99
119
  last_transition_time: datetime.time | None = None
100
120
 
101
121
 
102
- def get_condition(resource: structpb.Struct, typ: str) -> Condition:
122
+ def get_condition(
123
+ resource: structpb.Struct | fnv1.Resource | None,
124
+ typ: str,
125
+ ) -> Condition:
103
126
  """Get the supplied status condition of the supplied resource.
104
127
 
105
128
  Args:
106
- resource: A Crossplane resource.
129
+ resource: A Crossplane resource. Can be a protobuf Struct (the raw
130
+ resource), an fnv1.Resource wrapper, or None. When an
131
+ fnv1.Resource is supplied, the Struct is extracted automatically.
132
+ When None is supplied, an unknown condition is returned.
107
133
  typ: The type of status condition to get (e.g. Ready).
108
134
 
109
135
  Returns:
@@ -111,9 +137,23 @@ def get_condition(resource: structpb.Struct, typ: str) -> Condition:
111
137
 
112
138
  A status condition is always returned. If the status condition isn't present
113
139
  in the supplied resource, a condition with status "Unknown" is returned.
140
+
141
+ Accepting fnv1.Resource and None makes it safe to pass the result of a
142
+ protobuf map ``.get()`` call directly. This avoids auto-vivification, which
143
+ silently inserts a default entry when using bracket access on a missing
144
+ key::
145
+
146
+ # Safe — .get() returns None without mutating the map.
147
+ c = get_condition(req.observed.resources.get("bucket"), "Ready")
148
+
149
+ # Unsafe — bracket access auto-vivifies an empty Resource.
150
+ c = get_condition(req.observed.resources["bucket"].resource, "Ready")
114
151
  """
115
152
  unknown = Condition(typ=typ, status="Unknown")
116
153
 
154
+ if isinstance(resource, fnv1.Resource):
155
+ resource = resource.resource
156
+
117
157
  if not resource or "status" not in resource:
118
158
  return unknown
119
159
 
@@ -140,3 +180,37 @@ def get_condition(resource: structpb.Struct, typ: str) -> Condition:
140
180
  return condition
141
181
 
142
182
  return unknown
183
+
184
+
185
+ _DNS_LABEL_MAX = 63
186
+ _HASH_LEN = 5
187
+
188
+
189
+ def child_name(*parts: str, sep: str = "-") -> str:
190
+ """Build a deterministic, DNS-label-safe name for a child resource.
191
+
192
+ Args:
193
+ *parts: Name components to join (e.g. parent name, suffix).
194
+ sep: Separator between parts. Defaults to "-".
195
+
196
+ Returns:
197
+ A name that is at most 63 characters long.
198
+
199
+ Composition functions often derive child resource names from a parent
200
+ name and a discriminator. The resulting name must be a valid DNS label
201
+ (at most 63 characters). This function joins the parts, appends a
202
+ deterministic 5-character hash suffix for uniqueness, and truncates
203
+ the prefix to fit within the limit.
204
+
205
+ The hash suffix is always appended, even for short names, so that
206
+ names are visually consistent regardless of length::
207
+
208
+ child_name("my-xr", "bucket") # "my-xr-bucket-a1b2c"
209
+ child_name("my-very-long-xr-name",
210
+ "with-a-very-long-suffix") # truncated to 63 chars
211
+ """
212
+ full = sep.join(parts)
213
+ h = hashlib.sha256(full.encode()).hexdigest()[:_HASH_LEN]
214
+ max_prefix = _DNS_LABEL_MAX - _HASH_LEN - 1
215
+ prefix = full[:max_prefix].rstrip(sep)
216
+ return f"{prefix}{sep}{h}"
@@ -81,6 +81,44 @@ def fatal(rsp: fnv1.RunFunctionResponse, message: str) -> None:
81
81
  )
82
82
 
83
83
 
84
+ _STATUS_MAP = {
85
+ "True": fnv1.STATUS_CONDITION_TRUE,
86
+ "False": fnv1.STATUS_CONDITION_FALSE,
87
+ "Unknown": fnv1.STATUS_CONDITION_UNKNOWN,
88
+ }
89
+
90
+
91
+ def set_conditions(
92
+ rsp: fnv1.RunFunctionResponse,
93
+ *conditions: resource.Condition,
94
+ ) -> None:
95
+ """Set one or more conditions on the composite resource (XR).
96
+
97
+ Args:
98
+ rsp: The RunFunctionResponse to update.
99
+ *conditions: The conditions to set.
100
+
101
+ Each condition is appended to ``rsp.conditions``. Crossplane uses the
102
+ conditions returned by a function to set custom status conditions on
103
+ the composite resource.
104
+
105
+ The ``last_transition_time`` field of each condition is ignored.
106
+ Crossplane sets the transition time itself.
107
+
108
+ Do not set the ``Ready`` condition type. Crossplane manages it based
109
+ on resource readiness.
110
+ """
111
+ for condition in conditions:
112
+ c = fnv1.Condition(
113
+ type=condition.typ,
114
+ status=_STATUS_MAP.get(condition.status, fnv1.STATUS_CONDITION_UNKNOWN),
115
+ reason=condition.reason or "",
116
+ )
117
+ if condition.message:
118
+ c.message = condition.message
119
+ rsp.conditions.append(c)
120
+
121
+
84
122
  def set_output(rsp: fnv1.RunFunctionResponse, output: dict | structpb.Struct) -> None:
85
123
  """Set the output field in a RunFunctionResponse for operation functions.
86
124
 
@@ -18,9 +18,9 @@ classifiers = [
18
18
  ]
19
19
 
20
20
  dependencies = [
21
- "grpcio==1.76.0",
21
+ "grpcio==1.80.0",
22
22
  "grpcio-reflection==1.*",
23
- "protobuf==6.33.5", # Must be compatible with grpcio-tools.
23
+ "protobuf==7.35.0", # Must be compatible with grpcio-tools.
24
24
  "pydantic==2.*",
25
25
  "structlog==25.*",
26
26
  ]
@@ -29,6 +29,56 @@ class TestResource(unittest.TestCase):
29
29
  def setUp(self) -> None:
30
30
  logging.configure(level=logging.Level.DISABLED)
31
31
 
32
+ def test_update_status(self) -> None:
33
+ @dataclasses.dataclass
34
+ class TestCase:
35
+ reason: str
36
+ r: fnv1.Resource
37
+ status: dict | pydantic.BaseModel
38
+ want: dict
39
+
40
+ cases = [
41
+ TestCase(
42
+ reason="Setting status from a dict should work.",
43
+ r=fnv1.Resource(
44
+ resource=resource.dict_to_struct(
45
+ {"apiVersion": "example.org", "kind": "XR"}
46
+ ),
47
+ ),
48
+ status={"ready": True},
49
+ want={
50
+ "apiVersion": "example.org",
51
+ "kind": "XR",
52
+ "status": {"ready": True},
53
+ },
54
+ ),
55
+ TestCase(
56
+ reason="Setting status from a Pydantic model should work.",
57
+ r=fnv1.Resource(
58
+ resource=resource.dict_to_struct(
59
+ {"apiVersion": "example.org", "kind": "XR"}
60
+ ),
61
+ ),
62
+ status=v1beta2.ForProvider(region="us-west-2"),
63
+ want={
64
+ "apiVersion": "example.org",
65
+ "kind": "XR",
66
+ "status": {"region": "us-west-2"},
67
+ },
68
+ ),
69
+ TestCase(
70
+ reason="Setting status on an empty resource should work.",
71
+ r=fnv1.Resource(),
72
+ status={"replicas": 3},
73
+ want={"status": {"replicas": 3}},
74
+ ),
75
+ ]
76
+
77
+ for case in cases:
78
+ resource.update_status(case.r, case.status)
79
+ got = resource.struct_to_dict(case.r.resource)
80
+ self.assertEqual(case.want, got, case.reason)
81
+
32
82
  def test_add(self) -> None:
33
83
  @dataclasses.dataclass
34
84
  class TestCase:
@@ -112,11 +162,17 @@ class TestResource(unittest.TestCase):
112
162
  @dataclasses.dataclass
113
163
  class TestCase:
114
164
  reason: str
115
- res: structpb.Struct
165
+ res: structpb.Struct | fnv1.Resource | None
116
166
  typ: str
117
167
  want: resource.Condition
118
168
 
119
169
  cases = [
170
+ TestCase(
171
+ reason="Return an unknown condition if the resource is None.",
172
+ res=None,
173
+ typ="Ready",
174
+ want=resource.Condition(typ="Ready", status="Unknown"),
175
+ ),
120
176
  TestCase(
121
177
  reason="Return an unknown condition if the resource has no status.",
122
178
  res=resource.dict_to_struct({}),
@@ -197,6 +253,31 @@ class TestResource(unittest.TestCase):
197
253
  ),
198
254
  ),
199
255
  ),
256
+ TestCase(
257
+ reason="Unwrap an fnv1.Resource to get the condition from its Struct.",
258
+ res=fnv1.Resource(
259
+ resource=resource.dict_to_struct(
260
+ {
261
+ "status": {
262
+ "conditions": [
263
+ {
264
+ "type": "Ready",
265
+ "status": "True",
266
+ }
267
+ ]
268
+ }
269
+ }
270
+ ),
271
+ ),
272
+ typ="Ready",
273
+ want=resource.Condition(typ="Ready", status="True"),
274
+ ),
275
+ TestCase(
276
+ reason="Return an unknown condition from an empty fnv1.Resource.",
277
+ res=fnv1.Resource(),
278
+ typ="Ready",
279
+ want=resource.Condition(typ="Ready", status="Unknown"),
280
+ ),
200
281
  ]
201
282
 
202
283
  for case in cases:
@@ -324,6 +405,59 @@ class TestResource(unittest.TestCase):
324
405
  got = resource.struct_to_dict(case.s)
325
406
  self.assertEqual(case.want, got, "-want, +got")
326
407
 
408
+ def test_child_name(self) -> None:
409
+ @dataclasses.dataclass
410
+ class TestCase:
411
+ reason: str
412
+ parts: list[str]
413
+ want: str
414
+
415
+ cases = [
416
+ TestCase(
417
+ reason="A short name should be joined with a hash suffix.",
418
+ parts=["my-xr", "bucket"],
419
+ want="my-xr-bucket-05ecb",
420
+ ),
421
+ TestCase(
422
+ reason="A single part should get a hash suffix.",
423
+ parts=["my-xr"],
424
+ want="my-xr-9d53f",
425
+ ),
426
+ TestCase(
427
+ reason="A long name should be truncated to fit within 63 characters.",
428
+ parts=["a" * 40, "b" * 40],
429
+ want="a" * 40 + "-" + "b" * 16 + "-" + "f5e42",
430
+ ),
431
+ TestCase(
432
+ reason="A name that would end with a trailing separator "
433
+ "after truncation should have the separator stripped.",
434
+ parts=["a" * 56 + "-", "x"],
435
+ # Without stripping, this would be "aaa..a--<hash>".
436
+ # The trailing separator from the truncation is stripped.
437
+ want="a" * 56 + "-" + "995eb",
438
+ ),
439
+ TestCase(
440
+ reason="The same inputs should always produce the same name.",
441
+ parts=["parent", "child"],
442
+ want="parent-child-2f0c9",
443
+ ),
444
+ ]
445
+
446
+ for case in cases:
447
+ got = resource.child_name(*case.parts)
448
+ self.assertEqual(case.want, got, case.reason)
449
+ self.assertLessEqual(len(got), 63, case.reason)
450
+
451
+ def test_child_name_deterministic(self) -> None:
452
+ a = resource.child_name("parent", "child")
453
+ b = resource.child_name("parent", "child")
454
+ self.assertEqual(a, b)
455
+
456
+ def test_child_name_unique(self) -> None:
457
+ a = resource.child_name("parent", "child-a")
458
+ b = resource.child_name("parent", "child-b")
459
+ self.assertNotEqual(a, b)
460
+
327
461
 
328
462
  if __name__ == "__main__":
329
463
  unittest.main()
@@ -71,6 +71,72 @@ class TestResponse(unittest.TestCase):
71
71
  "-want, +got",
72
72
  )
73
73
 
74
+ def test_set_conditions(self) -> None:
75
+ @dataclasses.dataclass
76
+ class TestCase:
77
+ reason: str
78
+ conditions: list[resource.Condition]
79
+ want_types: list[str]
80
+ want_statuses: list[fnv1.Status.ValueType]
81
+ want_reasons: list[str]
82
+ want_messages: list[str]
83
+
84
+ cases = [
85
+ TestCase(
86
+ reason="A single True condition should work.",
87
+ conditions=[
88
+ resource.Condition(
89
+ typ="DatabaseReady",
90
+ status="True",
91
+ reason="Available",
92
+ message="The database is ready",
93
+ ),
94
+ ],
95
+ want_types=["DatabaseReady"],
96
+ want_statuses=[fnv1.STATUS_CONDITION_TRUE],
97
+ want_reasons=["Available"],
98
+ want_messages=["The database is ready"],
99
+ ),
100
+ TestCase(
101
+ reason="Multiple conditions should all be appended.",
102
+ conditions=[
103
+ resource.Condition(
104
+ typ="DatabaseReady",
105
+ status="True",
106
+ reason="Available",
107
+ ),
108
+ resource.Condition(
109
+ typ="CacheReady",
110
+ status="False",
111
+ reason="Creating",
112
+ ),
113
+ resource.Condition(
114
+ typ="NetworkReady",
115
+ status="Unknown",
116
+ ),
117
+ ],
118
+ want_types=["DatabaseReady", "CacheReady", "NetworkReady"],
119
+ want_statuses=[
120
+ fnv1.STATUS_CONDITION_TRUE,
121
+ fnv1.STATUS_CONDITION_FALSE,
122
+ fnv1.STATUS_CONDITION_UNKNOWN,
123
+ ],
124
+ want_reasons=["Available", "Creating", ""],
125
+ want_messages=["", "", ""],
126
+ ),
127
+ ]
128
+
129
+ for case in cases:
130
+ rsp = fnv1.RunFunctionResponse()
131
+ response.set_conditions(rsp, *case.conditions)
132
+
133
+ self.assertEqual(len(case.conditions), len(rsp.conditions), case.reason)
134
+ for i, got in enumerate(rsp.conditions):
135
+ self.assertEqual(case.want_types[i], got.type, case.reason)
136
+ self.assertEqual(case.want_statuses[i], got.status, case.reason)
137
+ self.assertEqual(case.want_reasons[i], got.reason, case.reason)
138
+ self.assertEqual(case.want_messages[i], got.message, case.reason)
139
+
74
140
  def test_set_output(self) -> None:
75
141
  @dataclasses.dataclass
76
142
  class TestCase: