cognite-neat 1.0.4__py3-none-any.whl → 1.0.6__py3-none-any.whl

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.
cognite/neat/_config.py CHANGED
@@ -14,8 +14,15 @@ if sys.version_info >= (3, 11):
14
14
  else:
15
15
  import tomli # type: ignore
16
16
 
17
+ # Public profiles
17
18
  PredefinedProfile: TypeAlias = Literal["legacy-additive", "legacy-rebuild", "deep-additive", "deep-rebuild"]
18
19
 
20
+ # Private profiles only
21
+ _PrivateProfiles: TypeAlias = Literal["no-validation-additive", "no-validation-rebuild"]
22
+
23
+ # All profiles (union of public and private)
24
+ _AllProfiles: TypeAlias = PredefinedProfile | _PrivateProfiles
25
+
19
26
 
20
27
  class ConfigModel(BaseModel):
21
28
  model_config = ConfigDict(populate_by_name=True, validate_assignment=True)
@@ -25,6 +32,7 @@ class ValidationConfig(ConfigModel):
25
32
  """Validation configuration."""
26
33
 
27
34
  exclude: list[str] = Field(default_factory=list)
35
+ override: bool = Field(False, description="If enabled, all validators are skipped.")
28
36
 
29
37
  def can_run_validator(self, code: str, issue_type: type) -> bool:
30
38
  """
@@ -40,7 +48,7 @@ class ValidationConfig(ConfigModel):
40
48
 
41
49
  is_excluded = self._is_excluded(code, self.exclude)
42
50
 
43
- if issue_type in [ModelSyntaxError, ConsistencyError] and is_excluded:
51
+ if issue_type in [ModelSyntaxError, ConsistencyError] and is_excluded and not self.override:
44
52
  print(f"Validator {code} was excluded however it is a critical validator and will still run.")
45
53
  return True
46
54
  else:
@@ -141,7 +149,7 @@ class NeatConfig(ConfigModel):
141
149
  return available_profiles[profile]
142
150
 
143
151
 
144
- def internal_profiles() -> dict[PredefinedProfile, NeatConfig]:
152
+ def internal_profiles() -> dict[_AllProfiles, NeatConfig]:
145
153
  """Get internal NeatConfig profile by name."""
146
154
  return {
147
155
  "legacy-additive": NeatConfig(
@@ -180,6 +188,16 @@ def internal_profiles() -> dict[PredefinedProfile, NeatConfig]:
180
188
  modeling=ModelingConfig(mode="rebuild"),
181
189
  validation=ValidationConfig(exclude=[]),
182
190
  ),
191
+ "no-validation-rebuild": NeatConfig(
192
+ profile="no-validation-rebuild",
193
+ modeling=ModelingConfig(mode="rebuild"),
194
+ validation=ValidationConfig(exclude=["*"], override=True),
195
+ ),
196
+ "no-validation-additive": NeatConfig(
197
+ profile="no-validation-additive",
198
+ modeling=ModelingConfig(mode="additive"),
199
+ validation=ValidationConfig(exclude=["*"], override=True),
200
+ ),
183
201
  }
184
202
 
185
203
 
@@ -2,6 +2,7 @@ import itertools
2
2
  import sys
3
3
  from abc import ABC, abstractmethod
4
4
  from collections import UserList, defaultdict
5
+ from collections.abc import Hashable, Sequence
5
6
  from datetime import datetime
6
7
  from enum import Enum
7
8
  from typing import Any, Generic, Literal, TypeAlias, cast
@@ -379,15 +380,88 @@ class ResourceDeploymentPlanList(UserList[ResourceDeploymentPlan]):
379
380
  return type(self)(forced_plans)
380
381
 
381
382
 
382
- class ChangeResult(BaseDeployObject, Generic[T_ResourceId, T_DataModelResource]):
383
+ class ChangeResult(BaseDeployObject, Generic[T_ResourceId, T_DataModelResource], ABC):
383
384
  endpoint: DataModelEndpoint
384
385
  change: ResourceChange[T_ResourceId, T_DataModelResource]
385
- message: SuccessResponseItems[T_ResourceId] | FailedResponseItems[T_ResourceId] | FailedRequestItems[T_ResourceId]
386
386
 
387
+ @property
388
+ @abstractmethod
389
+ def message(self) -> str:
390
+ """Human-readable message about the change result."""
391
+ ...
387
392
 
388
- class ChangedFieldResult(BaseDeployObject, Generic[T_Reference]):
393
+ @property
394
+ @abstractmethod
395
+ def is_success(self) -> bool:
396
+ """Whether the change was successful."""
397
+ ...
398
+
399
+
400
+ class HTTPChangeResult(ChangeResult[T_ResourceId, T_DataModelResource]):
401
+ http_message: (
402
+ SuccessResponseItems[T_ResourceId] | FailedResponseItems[T_ResourceId] | FailedRequestItems[T_ResourceId]
403
+ )
404
+
405
+ @property
406
+ def message(self) -> str:
407
+ if isinstance(self.http_message, SuccessResponse):
408
+ return "Success"
409
+ elif isinstance(self.http_message, FailedResponseItems):
410
+ error = self.http_message.error
411
+ return f"Failed: {error.code} | {error.message}"
412
+ elif isinstance(self.http_message, FailedRequestItems):
413
+ return f"Request Failed: {self.http_message.message}"
414
+ else:
415
+ return "Unknown result"
416
+
417
+ @property
418
+ def is_success(self) -> bool:
419
+ return isinstance(self.http_message, SuccessResponse)
420
+
421
+
422
+ class MultiHTTPChangeResult(ChangeResult[T_ResourceId, T_DataModelResource]):
423
+ http_messages: list[
424
+ SuccessResponseItems[T_ResourceId] | FailedResponseItems[T_ResourceId] | FailedRequestItems[T_ResourceId]
425
+ ]
426
+
427
+ @property
428
+ def message(self) -> str:
429
+ error_messages: list[str] = []
430
+ for msg in self.http_messages:
431
+ if isinstance(msg, SuccessResponse):
432
+ continue
433
+ elif isinstance(msg, FailedResponseItems):
434
+ error = msg.error
435
+ error_messages.append(f"Failed: {error.code} | {error.message}")
436
+ elif isinstance(msg, FailedRequestItems):
437
+ error_messages.append(f"Request Failed: {msg.message}")
438
+ if not error_messages:
439
+ return "Success"
440
+ return "; ".join(error_messages)
441
+
442
+ @property
443
+ def is_success(self) -> bool:
444
+ return all(isinstance(msg, SuccessResponse) for msg in self.http_messages)
445
+
446
+
447
+ class NoOpChangeResult(ChangeResult[T_ResourceId, T_DataModelResource]):
448
+ """A change result representing a no-op, e.g., when a change was skipped or unchanged."""
449
+
450
+ reason: str
451
+
452
+ @property
453
+ def message(self) -> str:
454
+ return self.reason
455
+
456
+ @property
457
+ def is_success(self) -> bool:
458
+ return True
459
+
460
+
461
+ class ChangedFieldResult(BaseDeployObject, Generic[T_ResourceId, T_Reference]):
462
+ resource_id: T_ResourceId
389
463
  field_change: FieldChange
390
- message: SuccessResponseItems[T_Reference] | FailedResponseItems[T_Reference] | FailedRequestItems[T_Reference]
464
+ http_message: SuccessResponseItems[T_Reference] | FailedResponseItems[T_Reference] | FailedRequestItems[T_Reference]
391
465
 
392
466
 
393
467
  class AppliedChanges(BaseDeployObject):
@@ -399,26 +473,55 @@ class AppliedChanges(BaseDeployObject):
399
473
  This is needed as these changes are done with a separate API call per change.
400
474
  """
401
475
 
402
- created: list[ChangeResult] = Field(default_factory=list)
403
- updated: list[ChangeResult] = Field(default_factory=list)
404
- deletions: list[ChangeResult] = Field(default_factory=list)
405
- unchanged: list[ResourceChange] = Field(default_factory=list)
406
- skipped: list[ResourceChange] = Field(default_factory=list)
476
+ created: list[HTTPChangeResult] = Field(default_factory=list)
477
+ updated: list[HTTPChangeResult] = Field(default_factory=list)
478
+ deletions: list[HTTPChangeResult] = Field(default_factory=list)
479
+ unchanged: list[NoOpChangeResult] = Field(default_factory=list)
480
+ skipped: list[NoOpChangeResult] = Field(default_factory=list)
407
481
  changed_fields: list[ChangedFieldResult] = Field(default_factory=list)
408
482
 
409
483
  @property
410
484
  def is_success(self) -> bool:
411
485
  return all(
412
486
  # MyPy fails to understand that ChangeFieldResult.message has the same structure as ChangeResult.message
413
- isinstance(change.message, SuccessResponse) # type: ignore[attr-defined]
487
+ isinstance(change.http_message, SuccessResponse) # type: ignore[attr-defined]
414
488
  for change in itertools.chain(self.created, self.updated, self.deletions, self.changed_fields)
415
489
  )
416
490
 
491
+ @property
492
+ def merged_updated(self) -> Sequence[ChangeResult]:
493
+ """Merges the changed field into the updated changes."""
494
+ if not self.changed_fields:
495
+ return self.updated
496
+ changed_fields_by_id: dict[Hashable, list[ChangedFieldResult]] = defaultdict(list)
497
+ for changed_field in self.changed_fields:
498
+ changed_fields_by_id[changed_field.resource_id].append(changed_field)
499
+ merged_changes: list[ChangeResult] = []
500
+ for update in self.updated:
501
+ if update.change.resource_id not in changed_fields_by_id:
502
+ merged_changes.append(update)
503
+ continue
504
+
505
+ field_changes = changed_fields_by_id[update.change.resource_id]
506
+ merged_change = update.change.model_copy(
507
+ update={"changes": update.change.changes + [fc.field_change for fc in field_changes]}
508
+ )
509
+
510
+ # MyPy wants an annotation were we want this to be generic.
511
+ merged_result = MultiHTTPChangeResult( # type: ignore[var-annotated]
512
+ endpoint=update.endpoint,
513
+ change=merged_change,
514
+ http_messages=[update.http_message] + [fc.http_message for fc in field_changes],
515
+ )
516
+ merged_changes.append(merged_result)
517
+
518
+ return merged_changes
519
+
417
520
  def as_recovery_plan(self) -> list[ResourceDeploymentPlan]:
418
521
  """Generate a recovery plan based on the applied changes."""
419
522
  recovery_plan: dict[DataModelEndpoint, ResourceDeploymentPlan] = {}
420
523
  for change_result in itertools.chain(self.created, self.updated, self.deletions):
421
- if not isinstance(change_result.message, SuccessResponse):
524
+ if not isinstance(change_result.http_message, SuccessResponse):
422
525
  continue # Skip failed changes.
423
526
  change = change_result.change
424
527
  if change.change_type == "create":
@@ -520,10 +623,10 @@ class DeploymentResult(BaseDeployObject):
520
623
  if self.responses:
521
624
  counts: dict[str, int] = defaultdict(int)
522
625
  for change in itertools.chain(self.responses.created, self.responses.updated, self.responses.deletions):
523
- suffix = type(change.message).__name__.removesuffix("[TypeVar]").removesuffix("[~T_ResourceId]")
626
+ suffix = type(change.http_message).__name__.removesuffix("[TypeVar]").removesuffix("[~T_ResourceId]")
524
627
  # For example: containers.created.successResponseItems
525
628
  prefix = f"{change.endpoint}.{change.change.change_type}.{suffix}"
526
- counts[prefix] += len(change.message.ids)
629
+ counts[prefix] += len(change.http_message.ids)
527
630
 
528
631
  output.update(counts)
529
632
  return output
@@ -35,12 +35,13 @@ from .data_classes import (
35
35
  AddedField,
36
36
  AppliedChanges,
37
37
  ChangedFieldResult,
38
- ChangeResult,
39
38
  ContainerDeploymentPlan,
40
39
  DataModelEndpoint,
41
40
  DeploymentResult,
42
41
  FieldChange,
43
42
  FieldChanges,
43
+ HTTPChangeResult,
44
+ NoOpChangeResult,
44
45
  RemovedField,
45
46
  ResourceChange,
46
47
  ResourceDeploymentPlan,
@@ -289,24 +290,58 @@ class SchemaDeployer(OnSuccessResultProducer):
289
290
  AppliedChanges: The result of applying the changes.
290
291
  """
291
292
  applied_changes = AppliedChanges()
293
+ # If any HTTP request fails, the skip_message will be set and subsequent operations will be skipped
294
+ failure_message: str | None = None
292
295
  for resource in reversed(plan):
293
- deletions = self._delete_items(resource)
294
- applied_changes.deletions.extend(deletions)
296
+ if failure_message is None:
297
+ deletions = self._delete_items(resource)
298
+ applied_changes.deletions.extend(deletions)
299
+ if any(not deletion.is_success for deletion in deletions):
300
+ failure_message = f"Skipping due to {resource.endpoint} deletions failing."
301
+ else:
302
+ applied_changes.skipped.extend(
303
+ [
304
+ NoOpChangeResult(endpoint=resource.endpoint, change=change, reason=failure_message)
305
+ for change in resource.to_delete
306
+ ]
307
+ )
295
308
 
296
309
  for resource in plan:
297
- if isinstance(resource, ContainerDeploymentPlan):
298
- applied_changes.changed_fields.extend(self._remove_container_constraints(resource))
299
- applied_changes.changed_fields.extend(self._remove_container_indexes(resource))
300
-
301
- creations, updated = self._upsert_items(resource)
302
- applied_changes.created.extend(creations)
303
- applied_changes.updated.extend(updated)
310
+ if failure_message is None:
311
+ if isinstance(resource, ContainerDeploymentPlan):
312
+ # Note that we continue to deploy even if removing constraints/indexes fail,
313
+ # as the creation/update of views and data models will still succeed.
314
+ applied_changes.changed_fields.extend(self._remove_container_constraints(resource))
315
+ applied_changes.changed_fields.extend(self._remove_container_indexes(resource))
316
+
317
+ creations, updated = self._upsert_items(resource)
318
+ applied_changes.created.extend(creations)
319
+ applied_changes.updated.extend(updated)
320
+ if any(not change.is_success for change in creations + updated):
321
+ failure_message = f"Skipping due to {resource.endpoint} upsert failing."
322
+ else:
323
+ applied_changes.skipped.extend(
324
+ [
325
+ NoOpChangeResult(endpoint=resource.endpoint, change=change, reason=failure_message)
326
+ for change in resource.to_upsert
327
+ ]
328
+ )
304
329
 
305
- applied_changes.unchanged.extend(resource.unchanged)
306
- applied_changes.skipped.extend(resource.skip)
330
+ applied_changes.unchanged.extend(
331
+ [
332
+ NoOpChangeResult(endpoint=resource.endpoint, change=change, reason="No changes detected.")
333
+ for change in resource.unchanged
334
+ ]
335
+ )
336
+ applied_changes.skipped.extend(
337
+ [
338
+ NoOpChangeResult(endpoint=resource.endpoint, change=change, reason=change.message or "Unknown")
339
+ for change in resource.skip
340
+ ]
341
+ )
307
342
  return applied_changes
308
343
 
309
- def _delete_items(self, resource: ResourceDeploymentPlan) -> list[ChangeResult]:
344
+ def _delete_items(self, resource: ResourceDeploymentPlan) -> list[HTTPChangeResult]:
310
345
  to_delete_by_id = {change.resource_id: change for change in resource.to_delete}
311
346
  if not to_delete_by_id:
312
347
  return []
@@ -319,7 +354,7 @@ class SchemaDeployer(OnSuccessResultProducer):
319
354
  )
320
355
  return self._process_resource_responses(responses, to_delete_by_id, resource.endpoint)
321
356
 
322
- def _upsert_items(self, resource: ResourceDeploymentPlan) -> tuple[list[ChangeResult], list[ChangeResult]]:
357
+ def _upsert_items(self, resource: ResourceDeploymentPlan) -> tuple[list[HTTPChangeResult], list[HTTPChangeResult]]:
323
358
  to_upsert = [
324
359
  resource_change.new_value for resource_change in resource.to_upsert if resource_change.new_value is not None
325
360
  ]
@@ -375,14 +410,14 @@ class SchemaDeployer(OnSuccessResultProducer):
375
410
  @classmethod
376
411
  def _process_resource_responses(
377
412
  cls, responses: APIResponse, change_by_id: dict[T_ResourceId, ResourceChange], endpoint: DataModelEndpoint
378
- ) -> list[ChangeResult]:
379
- results: list[ChangeResult] = []
413
+ ) -> list[HTTPChangeResult]:
414
+ results: list[HTTPChangeResult] = []
380
415
  for response in responses:
381
416
  if isinstance(response, SuccessResponseItems | FailedResponseItems | FailedRequestItems):
382
417
  for id in response.ids:
383
418
  if id not in change_by_id:
384
419
  continue
385
- results.append(ChangeResult(change=change_by_id[id], message=response, endpoint=endpoint))
420
+ results.append(HTTPChangeResult(change=change_by_id[id], http_message=response, endpoint=endpoint))
386
421
  else:
387
422
  # This should never happen as we do a ItemsRequest should always return ItemMessage responses
388
423
  raise ValueError("Bug in Neat. Got an unexpected response type.")
@@ -398,7 +433,9 @@ class SchemaDeployer(OnSuccessResultProducer):
398
433
  for id in response.ids:
399
434
  if id not in change_by_id:
400
435
  continue
401
- results.append(ChangedFieldResult(field_change=change_by_id[id], message=response))
436
+ results.append(
437
+ ChangedFieldResult(resource_id=id, field_change=change_by_id[id], http_message=response)
438
+ )
402
439
  else:
403
440
  # This should never happen as we do a ItemsRequest should always return ItemMessage responses
404
441
  raise RuntimeError("Bug in Neat. Got an unexpected response type.")
cognite/neat/_version.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "1.0.4"
1
+ __version__ = "1.0.6"
2
2
  __engine__ = "^2.0.4"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cognite-neat
3
- Version: 1.0.4
3
+ Version: 1.0.6
4
4
  Summary: Knowledge graph transformation
5
5
  Project-URL: Documentation, https://cognite-neat.readthedocs-hosted.com/
6
6
  Project-URL: Homepage, https://cognite-neat.readthedocs-hosted.com/
@@ -1,8 +1,8 @@
1
1
  cognite/neat/__init__.py,sha256=u5EhnGuNS2GKydU6lVFCNrBpHBBKUnCDAE63Cqk__eo,244
2
- cognite/neat/_config.py,sha256=NXObeA-860LV40KlY4orsqjMGACa0jKRz2UE5L9kH6U,8401
2
+ cognite/neat/_config.py,sha256=MzQiZer0Wyk6VEtfDtl_tTF9KYAjYu2Q8jE7RugMJuM,9201
3
3
  cognite/neat/_exceptions.py,sha256=ox-5hXpee4UJlPE7HpuEHV2C96aLbLKo-BhPDoOAzhA,1650
4
4
  cognite/neat/_issues.py,sha256=wH1mnkrpBsHUkQMGUHFLUIQWQlfJ_qMfdF7q0d9wNhY,1871
5
- cognite/neat/_version.py,sha256=PG0hTrgdtokkHUdUcZQhG_UiE_vFIC9YHCENYSPb91w,44
5
+ cognite/neat/_version.py,sha256=mbpsAx7QFBCcnMi7Q3BurnEcb8MiqmNPURjepyY33lk,44
6
6
  cognite/neat/legacy.py,sha256=eI2ecxOV8ilGHyLZlN54ve_abtoK34oXognkFv3yvF0,219
7
7
  cognite/neat/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  cognite/neat/_client/__init__.py,sha256=75Bh7eGhaN4sOt3ZcRzHl7pXaheu1z27kmTHeaI05vo,114
@@ -26,8 +26,8 @@ cognite/neat/_data_model/deployer/_differ_container.py,sha256=mcy7PhUOfnvAxnZWNo
26
26
  cognite/neat/_data_model/deployer/_differ_data_model.py,sha256=iA7Xp-7NRvzZJXLLpJaLebkKKpv_VCBKPX6f-RU9wBk,1864
27
27
  cognite/neat/_data_model/deployer/_differ_space.py,sha256=J_AaqiseLpwQsOkKc7gmho4U2oSWAGVeEdQNepZiWw0,343
28
28
  cognite/neat/_data_model/deployer/_differ_view.py,sha256=g1xHwsoxFUaTOTtQa19nntKF3rxFzc2FxpKKFAUN_NE,11412
29
- cognite/neat/_data_model/deployer/data_classes.py,sha256=NTvLUYb_HbpzByKe_ZPvb-arOOs_YVfTjk3LzGpEHs0,23082
30
- cognite/neat/_data_model/deployer/deployer.py,sha256=9ZFf9QAZEnyqmX3b4zSkdf5t3A1wnU6cYGDNut_KKuQ,18390
29
+ cognite/neat/_data_model/deployer/data_classes.py,sha256=Rz3-6zX6_BJNdoiPmJCJYmKXPi5AwWPoAEvqQ-8qTI4,26781
30
+ cognite/neat/_data_model/deployer/deployer.py,sha256=8W6Zy8MVLaUWRqNlNrP-A1HUJlOMdzpDsyPqClxfyrU,20288
31
31
  cognite/neat/_data_model/exporters/__init__.py,sha256=AskjmB_0Vqib4kN84bWt8-M8nO42QypFf-l-E8oA5W8,482
32
32
  cognite/neat/_data_model/exporters/_api_exporter.py,sha256=G9cqezy_SH8VdhW4o862qBHh_QcbzfP6O1Yyjvdpeog,1579
33
33
  cognite/neat/_data_model/exporters/_base.py,sha256=rG_qAU5i5Hh5hUMep2UmDFFZID4x3LEenL6Z5C6N8GQ,646
@@ -312,7 +312,7 @@ cognite/neat/_v0/session/_to.py,sha256=AnsRSDDdfFyYwSgi0Z-904X7WdLtPfLlR0x1xsu_j
312
312
  cognite/neat/_v0/session/_wizard.py,sha256=baPJgXAAF3d1bn4nbIzon1gWfJOeS5T43UXRDJEnD3c,1490
313
313
  cognite/neat/_v0/session/exceptions.py,sha256=jv52D-SjxGfgqaHR8vnpzo0SOJETIuwbyffSWAxSDJw,3495
314
314
  cognite/neat/_v0/session/_state/README.md,sha256=o6N7EL98lgyWffw8IoEUf2KG5uSKveD5__TW45YzVjA,902
315
- cognite_neat-1.0.4.dist-info/METADATA,sha256=5n7OsUlUVMaXmo5m9O4VQsSsWleV2xcjomJdMjyMBTE,6030
316
- cognite_neat-1.0.4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
317
- cognite_neat-1.0.4.dist-info/licenses/LICENSE,sha256=W8VmvFia4WHa3Gqxq1Ygrq85McUNqIGDVgtdvzT-XqA,11351
318
- cognite_neat-1.0.4.dist-info/RECORD,,
315
+ cognite_neat-1.0.6.dist-info/METADATA,sha256=UTHB6fm94ONhx2QoDJWgmvdXE0oIyZmHvrAzycCNBYU,6030
316
+ cognite_neat-1.0.6.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
317
+ cognite_neat-1.0.6.dist-info/licenses/LICENSE,sha256=W8VmvFia4WHa3Gqxq1Ygrq85McUNqIGDVgtdvzT-XqA,11351
318
+ cognite_neat-1.0.6.dist-info/RECORD,,