katalyst-engine 5.0.1a2__tar.gz → 5.0.1a3__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 (145) hide show
  1. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/PKG-INFO +2 -2
  2. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/pyproject.toml +2 -2
  3. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/bundle/loader.py +14 -0
  4. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/evolution/__init__.py +37 -0
  5. katalyst_engine-5.0.1a3/src/katalyst_engine/evolution/chain.py +213 -0
  6. katalyst_engine-5.0.1a3/src/katalyst_engine/evolution/lock.py +228 -0
  7. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/toolkit/linkml.py +72 -2
  8. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/evolution/conftest.py +0 -7
  9. katalyst_engine-5.0.1a3/tests/evolution/test_chain.py +99 -0
  10. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/evolution/test_diff.py +2 -2
  11. katalyst_engine-5.0.1a3/tests/evolution/test_lock.py +94 -0
  12. katalyst_engine-5.0.1a3/tests/toolkit/test_core_parent.py +171 -0
  13. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/.gitignore +0 -0
  14. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/CHANGELOG.md +0 -0
  15. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/README.md +0 -0
  16. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/__init__.py +0 -0
  17. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/bundle/__init__.py +0 -0
  18. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/bundle/discovery.py +0 -0
  19. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/bundle/path_loader.py +0 -0
  20. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/bundle/policy.py +0 -0
  21. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/bundle/protocol.py +0 -0
  22. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/core/__init__.py +0 -0
  23. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/core/compatibility.py +0 -0
  24. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/core/compositional.py +0 -0
  25. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/core/definitive.py +0 -0
  26. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/core/errors.py +0 -0
  27. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/core/evolvable.py +0 -0
  28. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/core/identity.py +0 -0
  29. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/core/lifecycle.py +0 -0
  30. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/core/matching.py +0 -0
  31. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/core/relation.py +0 -0
  32. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/core/version.py +0 -0
  33. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/discovery/__init__.py +0 -0
  34. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/discovery/declaration.py +0 -0
  35. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/discovery/dispatcher.py +0 -0
  36. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/discovery/protocol.py +0 -0
  37. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/events/__init__.py +0 -0
  38. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/events/bus.py +0 -0
  39. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/events/event.py +0 -0
  40. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/evolution/diff.py +0 -0
  41. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/evolution/gate.py +0 -0
  42. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/evolution/migration.py +0 -0
  43. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/evolution/release.py +0 -0
  44. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/extensions/__init__.py +0 -0
  45. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/extensions/capability.py +0 -0
  46. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/extensions/discovery.py +0 -0
  47. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/extensions/effector.py +0 -0
  48. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/extensions/provider.py +0 -0
  49. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/extensions/registry.py +0 -0
  50. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/extensions/trigger.py +0 -0
  51. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/model/__init__.py +0 -0
  52. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/model/manager.py +0 -0
  53. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/model/materializer.py +0 -0
  54. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/model/node.py +0 -0
  55. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/model/query.py +0 -0
  56. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/model/store.py +0 -0
  57. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/py.typed +0 -0
  58. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/replication/__init__.py +0 -0
  59. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/replication/engine.py +0 -0
  60. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/replication/job.py +0 -0
  61. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/replication/transform.py +0 -0
  62. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/resolution/__init__.py +0 -0
  63. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/resolution/conflict.py +0 -0
  64. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/resolution/engine.py +0 -0
  65. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/resolution/strategies.py +0 -0
  66. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/schema/__init__.py +0 -0
  67. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/schema/conflict.py +0 -0
  68. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/schema/definition.py +0 -0
  69. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/schema/manager.py +0 -0
  70. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/schema/registry.py +0 -0
  71. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/schema/versioning.py +0 -0
  72. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/snapshot/__init__.py +0 -0
  73. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/snapshot/diff.py +0 -0
  74. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/snapshot/snapshot.py +0 -0
  75. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/source/__init__.py +0 -0
  76. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/source/manifest.py +0 -0
  77. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/source/registry.py +0 -0
  78. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/source/source.py +0 -0
  79. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/toolkit/__init__.py +0 -0
  80. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/toolkit/file_ops.py +0 -0
  81. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/src/katalyst_engine/toolkit/rendering.py +0 -0
  82. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/bundle/__init__.py +0 -0
  83. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/bundle/test_discovery.py +0 -0
  84. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/bundle/test_loader.py +0 -0
  85. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/bundle/test_loader_policy.py +0 -0
  86. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/bundle/test_path_loader.py +0 -0
  87. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/bundle/test_protocol.py +0 -0
  88. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/conftest.py +0 -0
  89. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/core/__init__.py +0 -0
  90. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/core/test_compatibility.py +0 -0
  91. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/core/test_compositional.py +0 -0
  92. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/core/test_definitive.py +0 -0
  93. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/core/test_evolvable.py +0 -0
  94. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/core/test_identity.py +0 -0
  95. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/core/test_init.py +0 -0
  96. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/core/test_lifecycle.py +0 -0
  97. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/core/test_matching.py +0 -0
  98. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/core/test_matching_consolidation.py +0 -0
  99. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/core/test_relation.py +0 -0
  100. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/core/test_relation_selectors.py +0 -0
  101. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/core/test_version.py +0 -0
  102. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/discovery/__init__.py +0 -0
  103. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/discovery/test_declaration.py +0 -0
  104. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/discovery/test_dispatcher.py +0 -0
  105. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/events/__init__.py +0 -0
  106. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/events/test_bus.py +0 -0
  107. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/evolution/__init__.py +0 -0
  108. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/evolution/test_gate.py +0 -0
  109. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/evolution/test_migration.py +0 -0
  110. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/evolution/test_release.py +0 -0
  111. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/extensions/__init__.py +0 -0
  112. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/extensions/test_effector.py +0 -0
  113. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/extensions/test_provider.py +0 -0
  114. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/extensions/test_registry.py +0 -0
  115. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/model/__init__.py +0 -0
  116. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/model/test_materializer.py +0 -0
  117. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/model/test_node.py +0 -0
  118. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/model/test_query.py +0 -0
  119. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/model/test_store.py +0 -0
  120. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/replication/__init__.py +0 -0
  121. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/replication/test_engine.py +0 -0
  122. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/resolution/__init__.py +0 -0
  123. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/resolution/test_engine.py +0 -0
  124. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/resolution/test_strategies.py +0 -0
  125. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/schema/__init__.py +0 -0
  126. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/schema/test_definition.py +0 -0
  127. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/schema/test_engine_boundary_guard.py +0 -0
  128. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/schema/test_field_role.py +0 -0
  129. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/schema/test_fieldspec_allowed_values.py +0 -0
  130. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/schema/test_manager.py +0 -0
  131. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/schema/test_plugin_settings.py +0 -0
  132. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/schema/test_registry.py +0 -0
  133. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/schema/test_registry_provenance.py +0 -0
  134. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/schema/test_storage_policy.py +0 -0
  135. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/schema/test_versioning.py +0 -0
  136. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/snapshot/__init__.py +0 -0
  137. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/snapshot/test_diff.py +0 -0
  138. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/snapshot/test_snapshot.py +0 -0
  139. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/source/__init__.py +0 -0
  140. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/source/test_source.py +0 -0
  141. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/test_version.py +0 -0
  142. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/toolkit/__init__.py +0 -0
  143. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/toolkit/test_file_ops.py +0 -0
  144. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/toolkit/test_linkml_bundle.py +0 -0
  145. {katalyst_engine-5.0.1a2 → katalyst_engine-5.0.1a3}/tests/toolkit/test_rendering.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: katalyst-engine
3
- Version: 5.0.1a2
3
+ Version: 5.0.1a3
4
4
  Summary: A distributed declaration framework — primitives for schema-aware, multi-source, evolvable artifact graphs
5
5
  Project-URL: Repository, https://github.com/esimplicityinc/katalyst-taxonomy
6
6
  Author: eSimplicity
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Typing :: Typed
16
16
  Requires-Python: >=3.12
17
- Requires-Dist: katalyst-core<1,>=0.1
17
+ Requires-Dist: katalyst-core<1,>=0.2
18
18
  Requires-Dist: pydantic>=2.0
19
19
  Requires-Dist: pyyaml>=6
20
20
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "katalyst-engine"
3
- version = "5.0.1a2"
3
+ version = "5.0.1a3"
4
4
  description = "A distributed declaration framework — primitives for schema-aware, multi-source, evolvable artifact graphs"
5
5
  readme = "README.md"
6
6
  license = { text = "Proprietary" }
@@ -25,7 +25,7 @@ dependencies = [
25
25
  "pydantic>=2.0",
26
26
  # LinkML is the only schema source: bundles build their definitions from the
27
27
  # LinkML modules katalyst-core opens (toolkit/linkml.py).
28
- "katalyst-core>=0.1,<1",
28
+ "katalyst-core>=0.2,<1",
29
29
  "pyyaml>=6",
30
30
  ]
31
31
 
@@ -107,6 +107,7 @@ class BundleLoader:
107
107
  manifest = manifest.model_copy(update={"entry_point": entry_point})
108
108
 
109
109
  provider = name or discovered_name or fqn
110
+ self._register_core_relationships()
110
111
  with self.manager.registry.providing(provider):
111
112
  bundle.register(self.manager)
112
113
 
@@ -122,6 +123,19 @@ class BundleLoader:
122
123
 
123
124
  return manifest
124
125
 
126
+ def _register_core_relationships(self) -> None:
127
+ """katalyst-core's relationship types (``child_of``), once and unattributed.
128
+
129
+ Every bundle uses them and none defines them, so they belong to no provider and
130
+ never conflict; a view narrowed to some bundles keeps them.
131
+ """
132
+ from katalyst_engine.toolkit.linkml import core_relationships
133
+
134
+ registry = self.manager.registry
135
+ for rel_def in core_relationships():
136
+ if registry.get_relationship(rel_def.describes_kind) is None:
137
+ registry.register_relationship(rel_def)
138
+
125
139
  def discover_and_register(
126
140
  self,
127
141
  discovery: BundleDiscovery | None = None,
@@ -7,8 +7,21 @@ its version bump and its migration.
7
7
  - :mod:`.migration`: the migration file (``migrations/<from>-to-<to>.yaml``) and the
8
8
  pure function that applies its steps to documents in memory.
9
9
  - :mod:`.gate`: the version gate CI runs (``kata core schema check``).
10
+ - :mod:`.lock`: the versions a backend records (its schema lock), against the loaded ones.
11
+ - :mod:`.chain`: the chain of migrations from a recorded version to the loaded one, run
12
+ a step at a time.
10
13
  """
11
14
 
15
+ from katalyst_engine.evolution.chain import (
16
+ ChainPlan,
17
+ MigrationChainError,
18
+ SchemaChain,
19
+ StepOutcome,
20
+ describe_step,
21
+ migration_chain,
22
+ release_migrations,
23
+ run_chains,
24
+ )
12
25
  from katalyst_engine.evolution.diff import (
13
26
  Category,
14
27
  Impact,
@@ -18,6 +31,15 @@ from katalyst_engine.evolution.diff import (
18
31
  diff_modules,
19
32
  )
20
33
  from katalyst_engine.evolution.gate import GateReport, check_release, migration_skeleton
34
+ from katalyst_engine.evolution.lock import (
35
+ LOCK_FILE_NAME,
36
+ BundleStatus,
37
+ SchemaLock,
38
+ SchemaLockError,
39
+ SchemaState,
40
+ SchemaStatus,
41
+ compare_lock,
42
+ )
21
43
  from katalyst_engine.evolution.migration import (
22
44
  CODE_HOOK_GROUP,
23
45
  Document,
@@ -38,25 +60,40 @@ from katalyst_engine.evolution.release import (
38
60
 
39
61
  __all__ = [
40
62
  "CODE_HOOK_GROUP",
63
+ "LOCK_FILE_NAME",
64
+ "BundleStatus",
41
65
  "Category",
66
+ "ChainPlan",
42
67
  "Document",
43
68
  "DocumentSet",
44
69
  "GateReport",
45
70
  "Impact",
46
71
  "Location",
47
72
  "Migration",
73
+ "MigrationChainError",
48
74
  "MigrationFileError",
49
75
  "MigrationStepError",
76
+ "SchemaChain",
50
77
  "SchemaChange",
51
78
  "SchemaDiff",
79
+ "SchemaLock",
80
+ "SchemaLockError",
52
81
  "SchemaRelease",
82
+ "SchemaState",
83
+ "SchemaStatus",
53
84
  "SchemaVersionMismatchError",
85
+ "StepOutcome",
54
86
  "apply_migration",
55
87
  "bundle_release",
56
88
  "check_release",
89
+ "compare_lock",
57
90
  "core_release",
91
+ "describe_step",
58
92
  "diff_modules",
59
93
  "installed_releases",
60
94
  "load_migration",
95
+ "migration_chain",
61
96
  "migration_skeleton",
97
+ "release_migrations",
98
+ "run_chains",
62
99
  ]
@@ -0,0 +1,213 @@
1
+ """Moving documents across releases: the chain of migrations from the version a backend
2
+ is at to the loaded one, and applying it step by step with what each step changed.
3
+
4
+ A schema's migrations live beside its modules (``migrations/<from>-to-<to>.yaml``), one
5
+ per breaking release, each from the release before it. The chain from a version is the
6
+ migration out of its major, then the one out of the next, until the loaded major:
7
+ 8.x to 10.x runs 8 → 9, then 9 → 10. Within a major nothing is migrated.
8
+
9
+ :func:`run_chains` applies the chains of several schemas to one set of documents, in
10
+ order, a step at a time, so a dry run can say what each step did. It is pure: writing
11
+ the result back to a backend is the caller's.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import copy
17
+ import json
18
+ from collections import Counter
19
+ from collections.abc import Callable, Mapping, Sequence
20
+ from dataclasses import dataclass, field
21
+ from typing import Any
22
+
23
+ from katalyst_engine.core.version import Version
24
+ from katalyst_engine.evolution.migration import (
25
+ CodeStep,
26
+ DeleteKind,
27
+ Document,
28
+ DocumentSet,
29
+ DropField,
30
+ MapEnum,
31
+ Migration,
32
+ MoveField,
33
+ MoveFiles,
34
+ RenameField,
35
+ RenameKind,
36
+ RenameRelationship,
37
+ Reparent,
38
+ SetDefault,
39
+ Step,
40
+ apply_migration,
41
+ load_migration,
42
+ )
43
+ from katalyst_engine.evolution.release import SchemaRelease
44
+ from katalyst_engine.schema.definition import NodeDefinition
45
+
46
+ __all__ = [
47
+ "ChainPlan",
48
+ "MigrationChainError",
49
+ "SchemaChain",
50
+ "StepOutcome",
51
+ "describe_step",
52
+ "migration_chain",
53
+ "release_migrations",
54
+ "run_chains",
55
+ ]
56
+
57
+
58
+ class MigrationChainError(ValueError):
59
+ """No chain of migrations leads from a backend's version to the loaded one."""
60
+
61
+
62
+ def release_migrations(release: SchemaRelease) -> tuple[Migration, ...]:
63
+ """Every migration a schema ships, oldest first.
64
+
65
+ Raises:
66
+ MigrationFileError: A migration file does not describe a migration.
67
+ """
68
+ try:
69
+ root = release.migrations_root
70
+ except Exception: # noqa: BLE001 - a schema with no modules has no migrations
71
+ return ()
72
+ if not root.is_dir():
73
+ return ()
74
+ found = [load_migration(path) for path in sorted(root.glob("*.yaml"))]
75
+ return tuple(sorted(found, key=lambda m: m.source))
76
+
77
+
78
+ def migration_chain(
79
+ name: str, migrations: Sequence[Migration], start: Version, target: Version
80
+ ) -> tuple[Migration, ...]:
81
+ """The migrations that take documents written for *start* to *target*'s major.
82
+
83
+ Raises:
84
+ MigrationChainError: *start* is newer than *target*, or a major has no
85
+ migration out of it.
86
+ """
87
+ if start.major > target.major:
88
+ raise MigrationChainError(
89
+ f"{name}: documents at {start} are newer than the loaded {target}"
90
+ )
91
+ chain: list[Migration] = []
92
+ current = start
93
+ while current.major < target.major:
94
+ out = [
95
+ m
96
+ for m in migrations
97
+ if m.source.major == current.major and current.major < m.target.major <= target.major
98
+ ]
99
+ if not out:
100
+ raise MigrationChainError(
101
+ f"{name}: no migration from {current.major}.x towards {target} "
102
+ f"(the schema ships {', '.join(f'{m.from_}-to-{m.to}' for m in migrations) or 'none'})"
103
+ )
104
+ if len(out) > 1:
105
+ listed = ", ".join(f"{m.from_}-to-{m.to}" for m in out)
106
+ raise MigrationChainError(f"{name}: more than one migration from {current}: {listed}")
107
+ chain.append(out[0])
108
+ current = out[0].target
109
+ return tuple(chain)
110
+
111
+
112
+ def _field(kind: str | None, path: str) -> str:
113
+ return f"{kind}.{path}" if kind else path
114
+
115
+
116
+ def describe_step(step: Step) -> str:
117
+ """One line for a step, as a dry run lists it: ``rename_field service.tags → labels``."""
118
+ if isinstance(step, RenameKind):
119
+ return f"rename_kind {step.from_} → {step.to}"
120
+ if isinstance(step, RenameField):
121
+ return f"rename_field {_field(step.kind, step.from_)} → {step.to}"
122
+ if isinstance(step, MoveField):
123
+ return f"move_field {_field(step.kind, step.from_)} → {step.to}"
124
+ if isinstance(step, SetDefault):
125
+ return f"set_default {_field(step.kind, step.field)} = {json.dumps(step.value)}"
126
+ if isinstance(step, DropField):
127
+ return f"drop_field {_field(step.kind, step.field)}"
128
+ if isinstance(step, DeleteKind):
129
+ return f"delete_kind {step.kind}"
130
+ if isinstance(step, MapEnum):
131
+ values = ", ".join(f"{k} → {v}" for k, v in step.values.items())
132
+ return f"map_enum {_field(step.kind, step.field)} {values}"
133
+ if isinstance(step, Reparent):
134
+ rule = (
135
+ f"to {step.to}"
136
+ if step.to is not None
137
+ else (f"from {step.parent_from}" if step.parent_from else f"up {step.up}")
138
+ )
139
+ return f"reparent {step.kind} {rule}"
140
+ if isinstance(step, MoveFiles):
141
+ return f"move_files {step.kind}"
142
+ if isinstance(step, RenameRelationship):
143
+ return f"rename_relationship {step.from_} → {step.to}"
144
+ assert isinstance(step, CodeStep)
145
+ return f"code {step.hook}"
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class SchemaChain:
150
+ """One schema's migrations, from the version a backend is at to the loaded one."""
151
+
152
+ name: str
153
+ start: Version
154
+ target: Version
155
+ migrations: tuple[Migration, ...] = ()
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class StepOutcome:
160
+ """What one step did: how many documents it wrote (new or changed) and removed."""
161
+
162
+ schema: str
163
+ migration: Migration
164
+ step: Step
165
+ written: int
166
+ removed: int
167
+
168
+ @property
169
+ def label(self) -> str:
170
+ return describe_step(self.step)
171
+
172
+
173
+ @dataclass
174
+ class ChainPlan:
175
+ """Documents after every chain, and what each step did."""
176
+
177
+ documents: DocumentSet
178
+ outcomes: list[StepOutcome] = field(default_factory=lambda: list[StepOutcome]())
179
+
180
+
181
+ def _key(doc: Document) -> str:
182
+ return json.dumps([doc.path, doc.data], sort_keys=True, default=str)
183
+
184
+
185
+ def run_chains(
186
+ chains: Sequence[SchemaChain],
187
+ documents: DocumentSet,
188
+ *,
189
+ nodes: Mapping[str, NodeDefinition] | None = None,
190
+ hooks: Mapping[str, Callable[..., Any]] | None = None,
191
+ ) -> ChainPlan:
192
+ """Apply each chain's migrations to *documents*, a step at a time. The input is not
193
+ changed.
194
+
195
+ Raises:
196
+ MigrationStepError: A step cannot be applied.
197
+ """
198
+ current = documents
199
+ outcomes: list[StepOutcome] = []
200
+ for chain in chains:
201
+ for migration in chain.migrations:
202
+ for step in migration.steps:
203
+ single = migration.model_copy(update={"steps": (step,)})
204
+ after = apply_migration(single, current, nodes=nodes, hooks=hooks)
205
+ before_keys = Counter(_key(d) for d in current.documents)
206
+ after_keys = Counter(_key(d) for d in after.documents)
207
+ written = sum((after_keys - before_keys).values())
208
+ removed = max(0, len(current.documents) - len(after.documents))
209
+ outcomes.append(StepOutcome(chain.name, migration, step, written, removed))
210
+ current = after
211
+ if current is documents:
212
+ current = copy.deepcopy(documents)
213
+ return ChainPlan(documents=current, outcomes=outcomes)
@@ -0,0 +1,228 @@
1
+ """The schema lock: the version of each schema a backend's documents were written with.
2
+
3
+ A backend records it beside its documents (a repository in a file, a database in a
4
+ metadata table), and compares it with the schemas it is opened with:
5
+
6
+ - **current**: every recorded major is the loaded major (a minor may differ);
7
+ - **behind**: a recorded major is older than the loaded one; the backend can be read,
8
+ not written, until its documents are migrated;
9
+ - **ahead**: a recorded major is newer than the loaded one; the backend was written by
10
+ a newer release and is not read at all;
11
+ - **untracked**: the backend cannot record versions.
12
+
13
+ A backend with no lock was written before versions were recorded. It is taken to be at
14
+ each schema's *baseline*, the oldest archived release (nothing older is supported), or
15
+ at the loaded version for a schema with no archive. A schema the lock does not name was
16
+ added after it was written, and is current; one the lock names that is not loaded is
17
+ reported, and decides nothing.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Mapping
23
+ from dataclasses import dataclass, field
24
+ from enum import StrEnum
25
+ from typing import Any, cast
26
+
27
+ import yaml
28
+
29
+ from katalyst_engine.core.version import Version
30
+
31
+ __all__ = [
32
+ "LOCK_FILE_NAME",
33
+ "BundleStatus",
34
+ "SchemaLock",
35
+ "SchemaLockError",
36
+ "SchemaState",
37
+ "SchemaStatus",
38
+ "compare_lock",
39
+ ]
40
+
41
+ LOCK_FILE_NAME = "schema.lock"
42
+ """The file a repository keeps its lock in (beside its documents)."""
43
+
44
+ _HEADER = "# Written by kata. Change it with `kata tax migrate`, not by hand.\n"
45
+
46
+
47
+ class SchemaLockError(ValueError):
48
+ """A lock that cannot be read."""
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class SchemaLock:
53
+ """The version of each schema, by schema name."""
54
+
55
+ bundles: Mapping[str, Version] = field(default_factory=lambda: dict[str, Version]())
56
+
57
+ @classmethod
58
+ def parse(cls, text: str) -> SchemaLock:
59
+ """Read a lock file's text (``bundles: {name: version}``).
60
+
61
+ Raises:
62
+ SchemaLockError: It is not a lock.
63
+ """
64
+ try:
65
+ raw: Any = yaml.safe_load(text)
66
+ except yaml.YAMLError as exc:
67
+ raise SchemaLockError(f"not YAML: {exc}") from exc
68
+ if not isinstance(raw, dict) or not isinstance(
69
+ cast("dict[str, Any]", raw).get("bundles"), dict
70
+ ):
71
+ raise SchemaLockError("a schema lock is a mapping with `bundles: {name: version}`")
72
+ bundles: dict[str, Version] = {}
73
+ for name, version in cast("dict[str, Any]", cast("dict[str, Any]", raw)["bundles"]).items():
74
+ try:
75
+ bundles[str(name)] = Version.parse(str(version))
76
+ except ValueError as exc:
77
+ raise SchemaLockError(f"{name}: {version!r} is not a version") from exc
78
+ return cls(bundles=bundles)
79
+
80
+ def render(self) -> str:
81
+ """The lock file's text: the schemas in name order."""
82
+ body = yaml.safe_dump(
83
+ {"bundles": {name: str(self.bundles[name]) for name in sorted(self.bundles)}},
84
+ sort_keys=False,
85
+ default_flow_style=False,
86
+ )
87
+ return _HEADER + body
88
+
89
+ def with_versions(self, versions: Mapping[str, Version]) -> SchemaLock:
90
+ """This lock with *versions* recorded (the schemas it names and *versions* does
91
+ not are kept)."""
92
+ return SchemaLock(bundles={**self.bundles, **versions})
93
+
94
+ def as_strings(self) -> dict[str, str]:
95
+ return {name: str(self.bundles[name]) for name in sorted(self.bundles)}
96
+
97
+
98
+ class SchemaState(StrEnum):
99
+ """How a backend's recorded versions compare with the loaded schemas."""
100
+
101
+ CURRENT = "current"
102
+ BEHIND = "behind"
103
+ AHEAD = "ahead"
104
+ UNTRACKED = "untracked"
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class BundleStatus:
109
+ """One schema: what is loaded, what the backend is at, and how they compare."""
110
+
111
+ name: str
112
+ loaded: Version | None
113
+ """The loaded version; ``None``: the lock names a schema that is not loaded."""
114
+
115
+ recorded: Version | None
116
+ """What the backend is at: recorded, or (:attr:`assumed`) taken to be."""
117
+
118
+ assumed: bool = False
119
+ """The lock does not say: :attr:`recorded` is the baseline (no lock), or the loaded
120
+ version (a schema added after the lock was written)."""
121
+
122
+ @property
123
+ def state(self) -> SchemaState | None:
124
+ """``None`` for a schema that is not loaded."""
125
+ if self.loaded is None:
126
+ return None
127
+ if self.recorded is None or self.recorded.major == self.loaded.major:
128
+ return SchemaState.CURRENT
129
+ return SchemaState.BEHIND if self.recorded.major < self.loaded.major else SchemaState.AHEAD
130
+
131
+ def to_dict(self) -> dict[str, Any]:
132
+ return {
133
+ "name": self.name,
134
+ "loaded": str(self.loaded) if self.loaded is not None else None,
135
+ "recorded": str(self.recorded) if self.recorded is not None else None,
136
+ "assumed": self.assumed,
137
+ "state": self.state.value if self.state is not None else "not-loaded",
138
+ }
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class SchemaStatus:
143
+ """A backend's schema versions against the loaded schemas."""
144
+
145
+ backend: str
146
+ state: SchemaState
147
+ recorded: bool
148
+ """Whether the backend has a lock (``False``: unrecorded, or untracked)."""
149
+
150
+ bundles: tuple[BundleStatus, ...] = ()
151
+
152
+ @property
153
+ def behind(self) -> tuple[BundleStatus, ...]:
154
+ return tuple(b for b in self.bundles if b.state is SchemaState.BEHIND)
155
+
156
+ @property
157
+ def ahead(self) -> tuple[BundleStatus, ...]:
158
+ return tuple(b for b in self.bundles if b.state is SchemaState.AHEAD)
159
+
160
+ @property
161
+ def writable(self) -> bool:
162
+ """Whether the schema versions allow a write (current or untracked)."""
163
+ return self.state in (SchemaState.CURRENT, SchemaState.UNTRACKED)
164
+
165
+ @property
166
+ def readable(self) -> bool:
167
+ return self.state is not SchemaState.AHEAD
168
+
169
+ def recorded_versions(self) -> dict[str, Version]:
170
+ """What the backend is at (recorded or assumed), for every loaded schema."""
171
+ return {
172
+ b.name: b.recorded
173
+ for b in self.bundles
174
+ if b.loaded is not None and b.recorded is not None
175
+ }
176
+
177
+ def to_dict(self) -> dict[str, Any]:
178
+ return {
179
+ "backend": self.backend,
180
+ "state": self.state.value,
181
+ "recorded": self.recorded,
182
+ "writable": self.writable,
183
+ "bundles": [b.to_dict() for b in self.bundles],
184
+ }
185
+
186
+
187
+ def compare_lock(
188
+ backend: str,
189
+ lock: SchemaLock | None,
190
+ loaded: Mapping[str, Version],
191
+ *,
192
+ baselines: Mapping[str, Version] | None = None,
193
+ tracked: bool = True,
194
+ ) -> SchemaStatus:
195
+ """*backend*'s :class:`SchemaStatus`.
196
+
197
+ Args:
198
+ backend: The backend's name.
199
+ lock: What it records; ``None`` when it records nothing.
200
+ loaded: The loaded schemas' versions, by name.
201
+ baselines: Each schema's oldest archived release, what a backend with no lock is
202
+ taken to be at. A schema without one: its loaded version.
203
+ tracked: Whether the backend can record versions at all.
204
+ """
205
+ if not tracked:
206
+ rows = tuple(
207
+ BundleStatus(name, loaded[name], None, assumed=True) for name in sorted(loaded)
208
+ )
209
+ return SchemaStatus(backend, SchemaState.UNTRACKED, recorded=False, bundles=rows)
210
+ bases = baselines or {}
211
+ rows_list: list[BundleStatus] = []
212
+ for name in sorted(set(loaded) | set(lock.bundles if lock else ())):
213
+ current = loaded.get(name)
214
+ if lock is None:
215
+ rows_list.append(BundleStatus(name, current, bases.get(name, current), assumed=True))
216
+ elif name in lock.bundles:
217
+ rows_list.append(BundleStatus(name, current, lock.bundles[name]))
218
+ else:
219
+ rows_list.append(BundleStatus(name, current, current, assumed=True))
220
+ rows = tuple(rows_list)
221
+ states = {row.state for row in rows}
222
+ if SchemaState.AHEAD in states:
223
+ state = SchemaState.AHEAD
224
+ elif SchemaState.BEHIND in states:
225
+ state = SchemaState.BEHIND
226
+ else:
227
+ state = SchemaState.CURRENT
228
+ return SchemaStatus(backend, state, recorded=lock is not None, bundles=rows)