validio-sdk 7.4.2__tar.gz → 8.0.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 (55) hide show
  1. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/PKG-INFO +7 -3
  2. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/pyproject.toml +1 -1
  3. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/_api/api.py +101 -8
  4. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/code/_import.py +6 -2
  5. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/code/plan.py +38 -41
  6. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/_diff.py +86 -46
  7. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/_diffable.py +19 -7
  8. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/_resource.py +156 -76
  9. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/_serde.py +25 -94
  10. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/_server_resources.py +166 -11
  11. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/_util.py +28 -0
  12. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/channels.py +256 -36
  13. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/credentials.py +336 -107
  14. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/filters.py +38 -7
  15. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/notification_rules.py +33 -113
  16. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/replacement.py +24 -0
  17. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/segmentations.py +22 -8
  18. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/sources.py +112 -112
  19. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/tags.py +6 -0
  20. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/tests/test__diff.py +297 -8
  21. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/tests/test__plan.py +22 -13
  22. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/tests/test__resource.py +41 -7
  23. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/tests/test__sql_validation.py +0 -4
  24. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/tests/test_import.py +0 -1
  25. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/thresholds.py +7 -1
  26. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/validators.py +635 -190
  27. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/windows.py +41 -32
  28. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/util.py +5 -0
  29. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/LICENSE +0 -0
  30. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/README_PUBLIC.md +0 -0
  31. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/__init__.py +0 -0
  32. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/_api/__init__.py +0 -0
  33. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/client/__init__.py +0 -0
  34. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/client/client.py +0 -0
  35. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/code/__init__.py +0 -0
  36. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/code/_progress.py +0 -0
  37. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/code/apply.py +0 -0
  38. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/code/scaffold.py +0 -0
  39. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/code/settings.py +0 -0
  40. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/config.py +0 -0
  41. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/dbt.py +0 -0
  42. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/exception.py +0 -0
  43. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/metadata.py +0 -0
  44. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/py.typed +0 -0
  45. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/__init__.py +0 -0
  46. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/_diff_util.py +0 -0
  47. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/_errors.py +0 -0
  48. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/_resource_graph.py +0 -0
  49. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/_update_namespace.py +0 -0
  50. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/enums.py +0 -0
  51. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/tests/__init__.py +0 -0
  52. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/tests/assets/example_manifest.json +0 -0
  53. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/tests/assets/expected_trimmed_manifest.json +0 -0
  54. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/resource/tests/test__dbt.py +0 -0
  55. {validio_sdk-7.4.2 → validio_sdk-8.0.0}/validio_sdk/scalars.py +0 -0
@@ -1,9 +1,9 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: validio-sdk
3
- Version: 7.4.2
3
+ Version: 8.0.0
4
4
  Summary: SDK to interact with the Validio platform
5
- Home-page: https://validio.io/
6
5
  License: Apache-2.0
6
+ License-File: LICENSE
7
7
  Author: Validio
8
8
  Author-email: support@validio.io
9
9
  Requires-Python: >=3.10,<4.0
@@ -11,11 +11,15 @@ Classifier: License :: OSI Approved :: Apache Software License
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.10
13
13
  Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
14
17
  Requires-Dist: camel-converter (>=3.0.0,<4.0.0)
15
18
  Requires-Dist: gql[all] (>=3.5.0,<4.0.0)
16
19
  Requires-Dist: platformdirs (>=3.5.0,<4.0.0)
17
20
  Requires-Dist: rich (==13.9.4)
18
21
  Project-URL: Documentation, https://dev.validio.io/sdk-docs
22
+ Project-URL: Homepage, https://validio.io/
19
23
  Description-Content-Type: text/markdown
20
24
 
21
25
  # validio-sdk
@@ -3,7 +3,7 @@ name = "validio-sdk"
3
3
  # This version does not represent the released version or any tag. For each
4
4
  # release we automatically bump this before building and publishing so this
5
5
  # should be kept at 0.0.1dev1
6
- version = "7.4.2"
6
+ version = "8.0.0"
7
7
  description = "SDK to interact with the Validio platform"
8
8
  authors = ["Validio <support@validio.io>"]
9
9
  license = "Apache-2.0"
@@ -258,6 +258,10 @@ class APIClient:
258
258
  async with self.client as session:
259
259
  return await get_namespaces(session, namespace_id)
260
260
 
261
+ async def get_namespace_role_configs(self) -> list[dict[str, Any]]:
262
+ async with self.client as session:
263
+ return await get_namespace_role_configs(session)
264
+
261
265
  async def create_namespace(
262
266
  self,
263
267
  namespace_id: str,
@@ -418,7 +422,6 @@ async def get_sources(
418
422
  project
419
423
  dataset
420
424
  table
421
- lookbackDays
422
425
  schedule
423
426
  billingProject
424
427
  }
@@ -438,7 +441,6 @@ async def get_sources(
438
441
  catalog
439
442
  database
440
443
  table
441
- lookbackDays
442
444
  schedule
443
445
  }
444
446
  }
@@ -457,7 +459,6 @@ async def get_sources(
457
459
  database
458
460
  schema
459
461
  table
460
- lookbackDays
461
462
  schedule
462
463
  }
463
464
  }
@@ -466,7 +467,6 @@ async def get_sources(
466
467
  database
467
468
  schema
468
469
  table
469
- lookbackDays
470
470
  schedule
471
471
  }
472
472
  }
@@ -475,7 +475,6 @@ async def get_sources(
475
475
  catalog
476
476
  schema
477
477
  table
478
- lookbackDays
479
478
  schedule
480
479
  httpPath
481
480
  }
@@ -515,7 +514,6 @@ async def get_sources(
515
514
  database
516
515
  schema
517
516
  table
518
- lookbackDays
519
517
  schedule
520
518
  }
521
519
  }
@@ -526,7 +524,6 @@ async def get_sources(
526
524
  database
527
525
  schema
528
526
  table
529
- lookbackDays
530
527
  schedule
531
528
  }
532
529
  }
@@ -549,7 +546,6 @@ async def get_sources(
549
546
  config {
550
547
  database
551
548
  table
552
- lookbackDays
553
549
  schedule
554
550
  }
555
551
  }
@@ -975,6 +971,19 @@ async def get_credentials(
975
971
  optBaseUrl: baseUrl
976
972
  }
977
973
  }
974
+ ... on AwsBedrockCredential {
975
+ config {
976
+ model
977
+ region
978
+ optBaseUrl: baseUrl
979
+ auth {
980
+ __typename
981
+ ... on AwsBedrockCredentialAccessKey {
982
+ accessKeyId
983
+ }
984
+ }
985
+ }
986
+ }
978
987
  ... on AwsCredential {
979
988
  config {
980
989
  accessKey
@@ -1312,6 +1321,29 @@ async def get_channels(
1312
1321
  }
1313
1322
  }
1314
1323
  }
1324
+ ... on ServiceNowChannel {
1325
+ config {
1326
+ applicationLinkUrl
1327
+ instanceUrl
1328
+ statusMapping {
1329
+ triage
1330
+ investigating
1331
+ resolved
1332
+ serviceNowActive
1333
+ resolvedCloseCode
1334
+ notAnAnomalyCloseCode
1335
+ }
1336
+ serviceNowAuth: auth {
1337
+ __typename
1338
+ ... on ServiceNowBasicAuth {
1339
+ username
1340
+ }
1341
+ ... on ServiceNowOAuth {
1342
+ clientId
1343
+ }
1344
+ }
1345
+ }
1346
+ }
1315
1347
  }
1316
1348
  """
1317
1349
 
@@ -1360,6 +1392,7 @@ async def get_channels(
1360
1392
  [
1361
1393
  ("msTeamsInteractiveMessageEnabled", "interactiveMessageEnabled"),
1362
1394
  ("emailInteractiveMessageEnabled", "interactiveMessageEnabled"),
1395
+ ("serviceNowAuth", "auth"),
1363
1396
  ],
1364
1397
  )
1365
1398
 
@@ -1952,6 +1985,50 @@ async def get_validators(
1952
1985
  }}
1953
1986
  {reference_source_config}
1954
1987
  }}
1988
+ ... on ReferentialIntegrityValidator {{
1989
+ config {{
1990
+ keyFields {{
1991
+ sourceField
1992
+ referenceField
1993
+ }}
1994
+ valueFields {{
1995
+ sourceField
1996
+ referenceField
1997
+ }}
1998
+ referenceSource {{
1999
+ resourceName
2000
+ }}
2001
+ referenceWindow {{
2002
+ resourceName
2003
+ }}
2004
+ referenceFilter {{
2005
+ resourceName
2006
+ }}
2007
+ initializeWithBackfill
2008
+ threshold {{
2009
+ ...ThresholdDetails
2010
+ }}
2011
+ }}
2012
+ }}
2013
+ ... on ReconciliationValidator {{
2014
+ config {{
2015
+ reconciliationMetric: metric
2016
+ segmentationFieldMapping {{
2017
+ sourceField
2018
+ referenceField
2019
+ }}
2020
+ referenceValidatorLeft {{
2021
+ resourceName
2022
+ }}
2023
+ referenceValidatorRight {{
2024
+ resourceName
2025
+ }}
2026
+ initializeWithBackfill
2027
+ threshold {{
2028
+ ...ThresholdDetails
2029
+ }}
2030
+ }}
2031
+ }}
1955
2032
  ... on SqlValidator {{
1956
2033
  config {{
1957
2034
  query
@@ -2041,6 +2118,7 @@ async def get_validators(
2041
2118
  ("categoricalDistributionMetric", "metric"),
2042
2119
  ("distributionMetric", "metric"),
2043
2120
  ("numericAnomalyMetric", "metric"),
2121
+ ("reconciliationMetric", "metric"),
2044
2122
  ("relativeTimeMetric", "metric"),
2045
2123
  ("relativeVolumeMetric", "metric"),
2046
2124
  ("volumeMetric", "metric"),
@@ -2257,6 +2335,21 @@ async def get_namespaces(
2257
2335
  return await execute(session, query, variable_values=variable_values)
2258
2336
 
2259
2337
 
2338
+ async def get_namespace_role_configs(
2339
+ session: Session,
2340
+ ) -> list[dict[str, Any]]:
2341
+ query = """
2342
+ query {
2343
+ roleConfigs(input: {type: Namespace}) {
2344
+ id
2345
+ name
2346
+ isSystemRole
2347
+ }
2348
+ }
2349
+ """
2350
+ return await execute(session, query)
2351
+
2352
+
2260
2353
  async def create_namespace(
2261
2354
  session: Session,
2262
2355
  namespace_id: str,
@@ -6,7 +6,10 @@ from typing import Any, cast
6
6
  from validio_sdk.exception import ValidioError
7
7
  from validio_sdk.resource._resource import DiffContext, Resource
8
8
  from validio_sdk.resource._serde import IGNORE_CHANGES_FIELD_NAME
9
- from validio_sdk.resource._server_resources import CREDENTIALS_WITH_DEPENDENCIES
9
+ from validio_sdk.resource._util import (
10
+ _CREDENTIALS_WITH_DEPENDENCIES,
11
+ _VALIDATORS_WITH_DEPENDENCIES,
12
+ )
10
13
  from validio_sdk.resource.channels import Channel
11
14
  from validio_sdk.resource.credentials import Credential
12
15
  from validio_sdk.resource.filters import Filter
@@ -320,7 +323,8 @@ def add_resource_decls(
320
323
  # others last.
321
324
  resources.sort(
322
325
  key=lambda p: (
323
- p[1].__class__.__name__ in CREDENTIALS_WITH_DEPENDENCIES,
326
+ p[1].__class__.__name__ in _CREDENTIALS_WITH_DEPENDENCIES,
327
+ p[1].__class__.__name__ in _VALIDATORS_WITH_DEPENDENCIES,
324
328
  p[0],
325
329
  )
326
330
  )
@@ -24,7 +24,6 @@ from validio_sdk.resource._resource import (
24
24
  )
25
25
  from validio_sdk.resource._server_resources import load_resources
26
26
  from validio_sdk.resource._util import SourceSchemaReinference
27
- from validio_sdk.resource.channels import WebhookChannel
28
27
  from validio_sdk.resource.tags import Tag
29
28
 
30
29
 
@@ -228,7 +227,7 @@ def _create_resource_diff_object(
228
227
  show_secrets: bool,
229
228
  rewrites: dict[str, Any] | None = None,
230
229
  secret_fields_changed: dict[str, Any] | None = None,
231
- is_manifest: bool = False,
230
+ manifest: Resource | Diffable | dict | None = None,
232
231
  ) -> dict[str, object]:
233
232
  if rewrites is None:
234
233
  rewrites = {}
@@ -236,13 +235,24 @@ def _create_resource_diff_object(
236
235
  if secret_fields_changed is None:
237
236
  secret_fields_changed = {}
238
237
 
238
+ is_manifest = manifest is None
239
+
239
240
  data = dict(r) if not isinstance(r, dict) else r
241
+ manifest_data = (
242
+ dict(manifest)
243
+ if not isinstance(manifest, dict) and manifest is not None
244
+ else manifest
245
+ )
240
246
 
241
247
  diff_object = {}
242
248
  for k, v in data.items():
243
249
  if k.startswith("_"):
244
250
  continue
245
251
 
252
+ nested_manifest_data = (
253
+ manifest_data.get(k) if isinstance(manifest_data, dict) else None
254
+ )
255
+
246
256
  if k in rewrites:
247
257
  diff_object[k] = rewrites[k]
248
258
  elif isinstance(v, Resource | Diffable | dict):
@@ -251,7 +261,7 @@ def _create_resource_diff_object(
251
261
  v,
252
262
  show_secrets,
253
263
  secret_fields_changed=ch if not isinstance(ch, bool) else {},
254
- is_manifest=is_manifest,
264
+ manifest=nested_manifest_data,
255
265
  )
256
266
  elif hasattr(v, "__dict__") and not isinstance(v, Enum):
257
267
  ch = secret_fields_changed.get(k)
@@ -259,7 +269,7 @@ def _create_resource_diff_object(
259
269
  v.__dict__,
260
270
  show_secrets,
261
271
  secret_fields_changed=ch if not isinstance(ch, bool) else {},
262
- is_manifest=is_manifest,
272
+ manifest=nested_manifest_data,
263
273
  )
264
274
  elif isinstance(v, list):
265
275
  if len(v) == 0 or not isinstance(v[0], Diffable):
@@ -272,7 +282,7 @@ def _create_resource_diff_object(
272
282
  _create_resource_diff_object(
273
283
  item.__dict__,
274
284
  show_secrets,
275
- is_manifest=is_manifest,
285
+ manifest=None,
276
286
  )
277
287
  )
278
288
 
@@ -280,41 +290,28 @@ def _create_resource_diff_object(
280
290
  else:
281
291
  diff_object[k] = v
282
292
 
283
- # Mask any sensitive info if requested.
284
- if not show_secrets and hasattr(r, "_secret_fields"):
285
- secret_fields = r._secret_fields()
286
-
287
- if secret_fields:
288
- for field in secret_fields:
289
- # We want to avoid overriding webhook_url if it is None already
290
- # which may give a misleading impression that it is set to a value.
291
- #
292
- # For any channel that is not the WebhookChannel, we will always
293
- # flag it as changed if any secrets have changed, which is consistent
294
- # with existing behavior.
295
- #
296
- # This workaround until webhook URL is deprecated fully from some
297
- # channels (VR-4047).
298
- flag_webhook_url_as_changed = False
299
- if not isinstance(r, WebhookChannel) and field == "webhook_url":
300
- if hasattr(r, "webhook_url") and not r.webhook_url:
301
- continue
302
- if any(
303
- secret_fields_changed[secret_field]
304
- for secret_field in secret_fields_changed
305
- ):
306
- flag_webhook_url_as_changed = True
307
-
308
- if (
309
- # Special case handling for webhook URL
310
- flag_webhook_url_as_changed
311
- or
312
- # If the field does not exist in the secrets changed dict then
313
- # don't flag it as changed
314
- secret_fields_changed.get(field)
315
- ) and not is_manifest:
316
- diff_object[field] = "REDACTED-PREVIOUS"
317
- else:
318
- diff_object[field] = "REDACTED"
293
+ # Nothing to mask, no secrets
294
+ if not hasattr(r, "_secret_fields"):
295
+ return diff_object
296
+
297
+ for secret_field in r._secret_fields():
298
+ # The field can either be in the map set to False, or absent, to be
299
+ # treated as unchanged.
300
+ field_changed = (
301
+ secret_field in secret_fields_changed
302
+ and secret_fields_changed[secret_field]
303
+ )
304
+
305
+ if is_manifest:
306
+ if not show_secrets:
307
+ diff_object[secret_field] = "REDACTED"
308
+ elif field_changed:
309
+ diff_object[secret_field] = (
310
+ "UNKNOWN" if show_secrets else "REDACTED-PREVIOUS"
311
+ )
312
+ elif not show_secrets:
313
+ diff_object[secret_field] = "REDACTED"
314
+ elif manifest_data and secret_field in manifest_data:
315
+ diff_object[secret_field] = manifest_data[secret_field]
319
316
 
320
317
  return diff_object
@@ -38,6 +38,7 @@ from validio_sdk.resource._util import SourceSchemaReinference, _sanitized_error
38
38
  from validio_sdk.resource.replacement import (
39
39
  CascadeReplacementReason,
40
40
  ImmutableFieldReplacementReason,
41
+ RecreateOnChangeReplacementReason,
41
42
  ReplacementContext,
42
43
  ReplacementReason,
43
44
  )
@@ -217,16 +218,20 @@ def _compute_replacements(
217
218
  graph_diff.replacement_ctx, parent_resource_type
218
219
  )
219
220
 
220
- to_replace = {
221
- name: (resource_update, resource_update.replacement_field)
222
- for name, resource_update in to_update.items()
223
- if resource_update.replacement_field
224
- }
221
+ to_replace: dict[str, ReplacementReason] = {}
222
+ for name, resource_update in to_update.items():
223
+ if resource_update.replacement_field:
224
+ to_replace[name] = ImmutableFieldReplacementReason(
225
+ field_name=resource_update.replacement_field,
226
+ resource_update=resource_update,
227
+ )
228
+ elif resource_update.manifest.recreate_on_change:
229
+ to_replace[name] = RecreateOnChangeReplacementReason(
230
+ resource_update=resource_update,
231
+ )
225
232
 
226
- for name, (resource_update, replacement_field) in to_replace.items():
227
- replacement_ctx[name] = ImmutableFieldReplacementReason(
228
- field_name=replacement_field, resource_update=resource_update
229
- )
233
+ for name, reason in to_replace.items():
234
+ replacement_ctx[name] = reason
230
235
  _visit_resource_to_replace(
231
236
  manifest_ctx=manifest_ctx,
232
237
  server_ctx=server_ctx,
@@ -594,52 +599,87 @@ def diff(
594
599
 
595
600
  # No changes yet. Next, descend into the nested fields. If we find any
596
601
  # changes, then mark _this_ resource as changed.
597
- server_nested_objects = server_object._nested_objects()
598
- for field, manifest in manifest_object._nested_objects().items():
599
- server = server_nested_objects[field]
600
-
601
- # No possible change if both are unset.
602
- if manifest is None and server is None:
603
- continue
604
-
605
- # If exactly one of them is None, then we definitely have a change.
606
- if manifest is None or server is None:
607
- return ResourceUpdate(manifest_resource, server_resource)
602
+ # For immutable nested objects, changes require the resource to be recreated.
603
+ nested_object_sources = [
604
+ (
605
+ manifest_object._mutable_nested_objects(),
606
+ server_object._mutable_nested_objects(),
607
+ False,
608
+ ),
609
+ (
610
+ manifest_object._immutable_nested_objects(),
611
+ server_object._immutable_nested_objects(),
612
+ True,
613
+ ),
614
+ ]
615
+ for manifest_nested, server_nested, is_immutable in nested_object_sources:
616
+ for field, manifest in manifest_nested.items():
617
+ server = server_nested[field]
618
+ replacement_field = field if is_immutable else None
608
619
 
609
- if isinstance(server, list) != isinstance(manifest, list):
610
- return ResourceUpdate(manifest_resource, server_resource)
620
+ # No possible change if both are unset.
621
+ if manifest is None and server is None:
622
+ continue
611
623
 
612
- if isinstance(server, list) and isinstance(manifest, list):
613
- if len(server) != len(manifest):
614
- return ResourceUpdate(manifest_resource, server_resource)
624
+ # If exactly one of them is None, then we definitely have a change.
625
+ if manifest is None or server is None:
626
+ return ResourceUpdate(
627
+ manifest_resource,
628
+ server_resource,
629
+ replacement_field=replacement_field,
630
+ )
615
631
 
616
- for i in range(len(server)):
617
- # Descend into both objects to diff.
618
- collected_diff = diff(
619
- manifest[i],
620
- server[i],
621
- curr_depth + 1,
632
+ if isinstance(server, list) != isinstance(manifest, list):
633
+ return ResourceUpdate(
622
634
  manifest_resource,
623
635
  server_resource,
636
+ replacement_field=replacement_field,
624
637
  )
625
638
 
626
- if collected_diff:
627
- # The returned diff is that of the full resource (not
628
- # just the nested object that had a change). So we can
629
- # exit with it.
630
- return collected_diff
639
+ if isinstance(server, list) and isinstance(manifest, list):
640
+ if len(server) != len(manifest):
641
+ return ResourceUpdate(
642
+ manifest_resource,
643
+ server_resource,
644
+ replacement_field=replacement_field,
645
+ )
646
+
647
+ for i in range(len(server)):
648
+ # Descend into both objects to diff.
649
+ collected_diff = diff(
650
+ manifest[i],
651
+ server[i],
652
+ curr_depth + 1,
653
+ manifest_resource,
654
+ server_resource,
655
+ )
656
+
657
+ if collected_diff:
658
+ # For immutable, flag for replacement. For mutable,
659
+ # return the collected diff directly.
660
+ if is_immutable:
661
+ return ResourceUpdate(
662
+ manifest_resource,
663
+ server_resource,
664
+ replacement_field=replacement_field,
665
+ )
666
+ return collected_diff
631
667
 
632
- continue
668
+ continue
633
669
 
634
- # Descend into both objects to diff.
635
- collected_diff = diff(
636
- manifest, server, curr_depth + 1, manifest_resource, server_resource
637
- )
638
- if collected_diff:
639
- # The returned diff is that of the full resource (not
640
- # just the nested object that had a change). So we can
641
- # exit with it.
642
- return collected_diff
670
+ collected_diff = diff(
671
+ manifest, server, curr_depth + 1, manifest_resource, server_resource
672
+ )
673
+ if collected_diff:
674
+ # For immutable, flag for replacement. For mutable,
675
+ # return the collected diff directly.
676
+ if is_immutable:
677
+ return ResourceUpdate(
678
+ manifest_resource,
679
+ server_resource,
680
+ replacement_field=replacement_field,
681
+ )
682
+ return collected_diff
643
683
 
644
684
  # No update to make
645
685
  return None
@@ -41,7 +41,7 @@ class Diffable(ABC):
41
41
  within a resource be sure to account for how that field should be diffed.
42
42
  Every field reachable from a resource, should be returned by exactly one
43
43
  of the listed APIs here, with the exception of `_replace_on_type_change_fields`
44
- which can overlap with `_nested_objects`.
44
+ which can overlap with `_mutable_nested_objects`.
45
45
  """
46
46
 
47
47
  @abstractmethod
@@ -57,16 +57,27 @@ class Diffable(ABC):
57
57
  return set({})
58
58
 
59
59
  @abstractmethod
60
- def _nested_objects(
60
+ def _mutable_nested_objects(
61
61
  self,
62
62
  ) -> dict[str, "Diffable | list[Diffable] | None"]:
63
- """Returns any nested objects contained within this object.
63
+ """Returns any mutable nested objects contained within this object.
64
64
 
65
- Nested objects will be diff-ed recursively.
65
+ Mutable nested objects will be diff-ed recursively.
66
66
  ...
67
67
  :returns dict[field, Optional[object]].
68
68
  """
69
69
 
70
+ def _immutable_nested_objects(
71
+ self,
72
+ ) -> dict[str, "Diffable | list[Diffable] | None"]:
73
+ """Returns any immutable nested objects contained within this object.
74
+
75
+ Immutable nested objects cannot be updated after creation.
76
+ ...
77
+ :returns dict[field, Optional[object]].
78
+ """
79
+ return {}
80
+
70
81
  def __iter__(self) -> Iterator[Any]:
71
82
  yield from self.__dict__.items()
72
83
 
@@ -83,7 +94,7 @@ class Diffable(ABC):
83
94
  if self._secret_fields():
84
95
  return True
85
96
 
86
- for nested in self._nested_objects().values():
97
+ for nested in self._mutable_nested_objects().values():
87
98
  if isinstance(nested, list):
88
99
  for n in nested:
89
100
  if n._has_secret_fields(curr_depth=curr_depth + 1):
@@ -98,7 +109,8 @@ class Diffable(ABC):
98
109
  return {
99
110
  *self._immutable_fields(),
100
111
  *self._mutable_fields(),
101
- *self._nested_objects().keys(),
112
+ *self._mutable_nested_objects().keys(),
113
+ *self._immutable_nested_objects().keys(),
102
114
  *self._ignored_fields(),
103
115
  }
104
116
 
@@ -239,7 +251,7 @@ class ApiSecretChangeNestedResource(Diffable):
239
251
  data[NODE_TYPE_FIELD_NAME] = self.__class__.__name__
240
252
  return data
241
253
 
242
- def _nested_objects(
254
+ def _mutable_nested_objects(
243
255
  self,
244
256
  ) -> dict[str, "Diffable | list[Diffable] | None"]:
245
257
  return {}