katalyst-engine 4.1.0__tar.gz → 5.0.1a2__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 (148) hide show
  1. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/.gitignore +11 -0
  2. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/CHANGELOG.md +19 -0
  3. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/PKG-INFO +4 -2
  4. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/pyproject.toml +5 -1
  5. katalyst_engine-5.0.1a2/src/katalyst_engine/__init__.py +15 -0
  6. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/bundle/__init__.py +8 -0
  7. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/bundle/discovery.py +55 -4
  8. katalyst_engine-5.0.1a2/src/katalyst_engine/bundle/loader.py +213 -0
  9. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/bundle/path_loader.py +125 -0
  10. katalyst_engine-5.0.1a2/src/katalyst_engine/bundle/policy.py +144 -0
  11. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/bundle/protocol.py +8 -1
  12. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/core/__init__.py +25 -0
  13. katalyst_engine-5.0.1a2/src/katalyst_engine/core/errors.py +33 -0
  14. katalyst_engine-5.0.1a2/src/katalyst_engine/core/matching.py +234 -0
  15. katalyst_engine-5.0.1a2/src/katalyst_engine/core/relation.py +282 -0
  16. katalyst_engine-5.0.1a2/src/katalyst_engine/evolution/__init__.py +62 -0
  17. katalyst_engine-5.0.1a2/src/katalyst_engine/evolution/diff.py +1246 -0
  18. katalyst_engine-5.0.1a2/src/katalyst_engine/evolution/gate.py +514 -0
  19. katalyst_engine-5.0.1a2/src/katalyst_engine/evolution/migration.py +652 -0
  20. katalyst_engine-5.0.1a2/src/katalyst_engine/evolution/release.py +294 -0
  21. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/extensions/registry.py +18 -3
  22. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/extensions/trigger.py +8 -10
  23. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/model/__init__.py +2 -2
  24. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/model/manager.py +3 -3
  25. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/model/query.py +44 -15
  26. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/replication/job.py +2 -2
  27. katalyst_engine-5.0.1a2/src/katalyst_engine/schema/__init__.py +73 -0
  28. katalyst_engine-5.0.1a2/src/katalyst_engine/schema/conflict.py +77 -0
  29. katalyst_engine-5.0.1a2/src/katalyst_engine/schema/definition.py +751 -0
  30. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/schema/manager.py +9 -2
  31. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/schema/registry.py +234 -10
  32. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/source/registry.py +12 -21
  33. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/toolkit/file_ops.py +5 -6
  34. katalyst_engine-5.0.1a2/src/katalyst_engine/toolkit/linkml.py +454 -0
  35. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/bundle/test_discovery.py +35 -0
  36. katalyst_engine-5.0.1a2/tests/bundle/test_loader_policy.py +355 -0
  37. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/bundle/test_path_loader.py +57 -0
  38. katalyst_engine-5.0.1a2/tests/conftest.py +20 -0
  39. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/core/test_evolvable.py +2 -2
  40. katalyst_engine-5.0.1a2/tests/core/test_matching.py +259 -0
  41. katalyst_engine-5.0.1a2/tests/core/test_matching_consolidation.py +79 -0
  42. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/core/test_relation.py +14 -14
  43. katalyst_engine-5.0.1a2/tests/core/test_relation_selectors.py +345 -0
  44. katalyst_engine-5.0.1a2/tests/evolution/conftest.py +165 -0
  45. katalyst_engine-5.0.1a2/tests/evolution/test_diff.py +219 -0
  46. katalyst_engine-5.0.1a2/tests/evolution/test_gate.py +187 -0
  47. katalyst_engine-5.0.1a2/tests/evolution/test_migration.py +301 -0
  48. katalyst_engine-5.0.1a2/tests/evolution/test_release.py +126 -0
  49. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/extensions/test_registry.py +43 -0
  50. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/model/test_materializer.py +9 -9
  51. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/model/test_query.py +17 -17
  52. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/replication/test_engine.py +5 -5
  53. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/schema/test_definition.py +12 -12
  54. katalyst_engine-5.0.1a2/tests/schema/test_field_role.py +83 -0
  55. katalyst_engine-5.0.1a2/tests/schema/test_fieldspec_allowed_values.py +60 -0
  56. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/schema/test_manager.py +2 -2
  57. katalyst_engine-5.0.1a2/tests/schema/test_plugin_settings.py +57 -0
  58. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/schema/test_registry.py +5 -5
  59. katalyst_engine-5.0.1a2/tests/schema/test_registry_provenance.py +326 -0
  60. katalyst_engine-5.0.1a2/tests/schema/test_storage_policy.py +159 -0
  61. katalyst_engine-5.0.1a2/tests/test_version.py +40 -0
  62. katalyst_engine-5.0.1a2/tests/toolkit/__init__.py +0 -0
  63. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/toolkit/test_file_ops.py +9 -3
  64. katalyst_engine-5.0.1a2/tests/toolkit/test_linkml_bundle.py +165 -0
  65. katalyst_engine-4.1.0/src/katalyst_engine/__init__.py +0 -6
  66. katalyst_engine-4.1.0/src/katalyst_engine/bundle/loader.py +0 -134
  67. katalyst_engine-4.1.0/src/katalyst_engine/core/relation.py +0 -151
  68. katalyst_engine-4.1.0/src/katalyst_engine/schema/__init__.py +0 -35
  69. katalyst_engine-4.1.0/src/katalyst_engine/schema/definition.py +0 -283
  70. katalyst_engine-4.1.0/tests/conftest.py +0 -1
  71. katalyst_engine-4.1.0/tests/schema/test_fieldspec_allowed_values.py +0 -23
  72. katalyst_engine-4.1.0/uv.lock +0 -371
  73. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/README.md +0 -0
  74. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/core/compatibility.py +0 -0
  75. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/core/compositional.py +0 -0
  76. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/core/definitive.py +0 -0
  77. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/core/evolvable.py +0 -0
  78. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/core/identity.py +0 -0
  79. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/core/lifecycle.py +0 -0
  80. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/core/version.py +0 -0
  81. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/discovery/__init__.py +0 -0
  82. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/discovery/declaration.py +0 -0
  83. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/discovery/dispatcher.py +0 -0
  84. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/discovery/protocol.py +0 -0
  85. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/events/__init__.py +0 -0
  86. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/events/bus.py +0 -0
  87. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/events/event.py +0 -0
  88. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/extensions/__init__.py +0 -0
  89. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/extensions/capability.py +0 -0
  90. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/extensions/discovery.py +0 -0
  91. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/extensions/effector.py +0 -0
  92. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/extensions/provider.py +0 -0
  93. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/model/materializer.py +0 -0
  94. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/model/node.py +0 -0
  95. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/model/store.py +0 -0
  96. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/py.typed +0 -0
  97. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/replication/__init__.py +0 -0
  98. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/replication/engine.py +0 -0
  99. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/replication/transform.py +0 -0
  100. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/resolution/__init__.py +0 -0
  101. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/resolution/conflict.py +0 -0
  102. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/resolution/engine.py +0 -0
  103. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/resolution/strategies.py +0 -0
  104. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/schema/versioning.py +0 -0
  105. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/snapshot/__init__.py +0 -0
  106. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/snapshot/diff.py +0 -0
  107. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/snapshot/snapshot.py +0 -0
  108. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/source/__init__.py +0 -0
  109. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/source/manifest.py +0 -0
  110. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/source/source.py +0 -0
  111. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/toolkit/__init__.py +0 -0
  112. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/src/katalyst_engine/toolkit/rendering.py +0 -0
  113. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/bundle/__init__.py +0 -0
  114. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/bundle/test_loader.py +0 -0
  115. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/bundle/test_protocol.py +0 -0
  116. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/core/__init__.py +0 -0
  117. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/core/test_compatibility.py +0 -0
  118. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/core/test_compositional.py +0 -0
  119. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/core/test_definitive.py +0 -0
  120. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/core/test_identity.py +0 -0
  121. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/core/test_init.py +0 -0
  122. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/core/test_lifecycle.py +0 -0
  123. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/core/test_version.py +0 -0
  124. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/discovery/__init__.py +0 -0
  125. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/discovery/test_declaration.py +0 -0
  126. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/discovery/test_dispatcher.py +0 -0
  127. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/events/__init__.py +0 -0
  128. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/events/test_bus.py +0 -0
  129. {katalyst_engine-4.1.0/tests/extensions → katalyst_engine-5.0.1a2/tests/evolution}/__init__.py +0 -0
  130. {katalyst_engine-4.1.0/tests/model → katalyst_engine-5.0.1a2/tests/extensions}/__init__.py +0 -0
  131. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/extensions/test_effector.py +0 -0
  132. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/extensions/test_provider.py +0 -0
  133. {katalyst_engine-4.1.0/tests/replication → katalyst_engine-5.0.1a2/tests/model}/__init__.py +0 -0
  134. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/model/test_node.py +0 -0
  135. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/model/test_store.py +0 -0
  136. {katalyst_engine-4.1.0/tests/resolution → katalyst_engine-5.0.1a2/tests/replication}/__init__.py +0 -0
  137. {katalyst_engine-4.1.0/tests/schema → katalyst_engine-5.0.1a2/tests/resolution}/__init__.py +0 -0
  138. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/resolution/test_engine.py +0 -0
  139. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/resolution/test_strategies.py +0 -0
  140. {katalyst_engine-4.1.0/tests/snapshot → katalyst_engine-5.0.1a2/tests/schema}/__init__.py +0 -0
  141. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/schema/test_engine_boundary_guard.py +0 -0
  142. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/schema/test_versioning.py +0 -0
  143. {katalyst_engine-4.1.0/tests/source → katalyst_engine-5.0.1a2/tests/snapshot}/__init__.py +0 -0
  144. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/snapshot/test_diff.py +0 -0
  145. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/snapshot/test_snapshot.py +0 -0
  146. {katalyst_engine-4.1.0/tests/toolkit → katalyst_engine-5.0.1a2/tests/source}/__init__.py +0 -0
  147. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/source/test_source.py +0 -0
  148. {katalyst_engine-4.1.0 → katalyst_engine-5.0.1a2}/tests/toolkit/test_rendering.py +0 -0
@@ -15,6 +15,9 @@ downloads/
15
15
  eggs/
16
16
  .eggs/
17
17
  lib/
18
+ # Not a Python build directory: Bard's SPA keeps shared helpers (utils.ts, download.ts) in
19
+ # src/lib, and ignoring it left a fresh clone unable to build or test the SPA.
20
+ !plugins/bard/web/src/lib/
18
21
  lib64/
19
22
  parts/
20
23
  sdist/
@@ -95,6 +98,11 @@ Thumbs.db
95
98
  # Browser/Playwright state files
96
99
  browser-state-*.json
97
100
 
101
+ # Agent harness scratch (run exhaust, never the design bundle)
102
+ .opencode/sessions/
103
+ .opencode/analysis/
104
+ .opencode/package-lock.json
105
+
98
106
  # Build artifacts
99
107
  site/
100
108
  stack-tests/cucumber-report/
@@ -110,3 +118,6 @@ taxonomy/src/katalyst_taxonomy/engine/generated/
110
118
  .HANDOFF-bdd-coverage.md
111
119
  .HANDOFF-*.md
112
120
  docs/superpowers/
121
+
122
+ # Node dependencies — installed, never committed
123
+ node_modules/
@@ -10,6 +10,25 @@ workspace packages. Tags use the `engine-vX.Y.Z` prefix.
10
10
 
11
11
  ---
12
12
 
13
+ ## [Unreleased]
14
+
15
+ ### Changed (breaking)
16
+
17
+ - **`FieldSpec.field_type` must be one of `FIELD_TYPES`** (`string`, `integer`, `number`,
18
+ `boolean`, `list`, `map`, `ref`), exported from `katalyst_engine.schema`. Any other
19
+ value is refused when the field is declared; a consumer that met an unknown type could
20
+ only leave it unchecked. (katalyst-taxonomy#255)
21
+
22
+ ### Added
23
+
24
+ - **`ScaffoldPolicy.variant_annotation`**: the `spec.annotations` key a kind's creation
25
+ template records a node's scaffold variant under (katalyst-forge's `layer`: `layerType`),
26
+ so a runtime reads it from the declaration instead of knowing the name.
27
+ - **`FieldSpec.map_fields`**: for a `map` field, the keys whose types it declares
28
+ (`scope.layer_types` is a `list`). Declaring them on a field that is not a map is
29
+ refused. Taxonomy lint checks them, and `kata tax create/update --set scope.layer_types=x`
30
+ parses by them. (katalyst-taxonomy#254)
31
+
13
32
  ## [4.1.0] -- 2026-06-26
14
33
 
15
34
  ### Added
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: katalyst-engine
3
- Version: 4.1.0
3
+ Version: 5.0.1a2
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,9 @@ 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
18
  Requires-Dist: pydantic>=2.0
19
+ Requires-Dist: pyyaml>=6
18
20
  Description-Content-Type: text/markdown
19
21
 
20
22
  # katalyst-engine
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "katalyst-engine"
3
- version = "4.1.0"
3
+ version = "5.0.1a2"
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" }
@@ -23,6 +23,10 @@ classifiers = [
23
23
  ]
24
24
  dependencies = [
25
25
  "pydantic>=2.0",
26
+ # LinkML is the only schema source: bundles build their definitions from the
27
+ # LinkML modules katalyst-core opens (toolkit/linkml.py).
28
+ "katalyst-core>=0.1,<1",
29
+ "pyyaml>=6",
26
30
  ]
27
31
 
28
32
  [project.urls]
@@ -0,0 +1,15 @@
1
+ """katalyst-engine — A distributed declaration framework.
2
+
3
+ Primitives for schema-aware, multi-source, evolvable artifact graphs.
4
+ """
5
+
6
+ from importlib.metadata import PackageNotFoundError
7
+ from importlib.metadata import version as _dist_version
8
+
9
+ try:
10
+ # Generated from pyproject.toml rather than restated here. The literal this
11
+ # replaced read "0.1.0" — the original placeholder, carried untouched across four
12
+ # major releases, because nothing asserted the two agreed.
13
+ __version__ = _dist_version("katalyst-engine")
14
+ except PackageNotFoundError: # pragma: no cover - source tree without an install
15
+ __version__ = "0.0.0+unknown"
@@ -11,6 +11,11 @@ from katalyst_engine.bundle.discovery import (
11
11
  DiscoveredBundle,
12
12
  )
13
13
  from katalyst_engine.bundle.loader import BundleLoader
14
+ from katalyst_engine.bundle.policy import (
15
+ BundleLoadPolicy,
16
+ BundleVersionConflictError,
17
+ constraint_is_satisfied,
18
+ )
14
19
  from katalyst_engine.bundle.protocol import (
15
20
  Bundle,
16
21
  BundleManifest,
@@ -22,9 +27,12 @@ __all__ = [
22
27
  "BUNDLE_ENTRY_POINT_GROUP",
23
28
  "Bundle",
24
29
  "BundleDiscovery",
30
+ "BundleLoadPolicy",
25
31
  "BundleLoader",
26
32
  "BundleManifest",
33
+ "BundleVersionConflictError",
27
34
  "DiscoveredBundle",
35
+ "constraint_is_satisfied",
28
36
  "get_creation_templates",
29
37
  "get_scaffold_templates",
30
38
  ]
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import importlib.metadata
12
12
  import logging
13
+ from collections.abc import Callable
13
14
  from dataclasses import dataclass, field
14
15
 
15
16
  from katalyst_engine.bundle.protocol import Bundle
@@ -20,6 +21,22 @@ logger = logging.getLogger(__name__)
20
21
  BUNDLE_ENTRY_POINT_GROUP = "katalyst_engine.bundles"
21
22
 
22
23
 
24
+ def _distribution_name(ep: importlib.metadata.EntryPoint) -> str:
25
+ """Distribution that registered *ep*, or ``""`` when it cannot be determined.
26
+
27
+ ``EntryPoint.dist`` is populated by ``entry_points()`` on 3.12 but is not part
28
+ of the type stub's guarantees and is ``None`` for a hand-built EntryPoint, so
29
+ this stays defensive: provenance is nice to have, never worth an exception.
30
+ """
31
+ dist = ep.dist
32
+ if dist is None:
33
+ return ""
34
+ try:
35
+ return dist.name
36
+ except Exception: # noqa: BLE001 - metadata can be malformed on disk
37
+ return ""
38
+
39
+
23
40
  @dataclass(frozen=True, slots=True)
24
41
  class DiscoveredBundle:
25
42
  """Metadata about a discovered (but not yet loaded) bundle.
@@ -37,17 +54,40 @@ class DiscoveredBundle:
37
54
  group: str = BUNDLE_ENTRY_POINT_GROUP
38
55
  """Entry point group this was discovered from."""
39
56
 
57
+ distribution: str = ""
58
+ """Installed distribution that registered the entry point, e.g. ``katalyst-forge``.
59
+
60
+ Distinct from :attr:`name`, which is the entry-point name and is the handle
61
+ configuration uses to *select* a bundle. The distribution is the handle you
62
+ ``pip install`` or uninstall, and a report that gave only one of the two left
63
+ half the questions about a kind's provenance unanswerable (issue #218).
64
+ Empty for bundles that did not arrive over an entry point.
65
+ """
66
+
67
+ loader: Callable[[], Bundle] | None = None
68
+ """Optional pre-resolved factory, used instead of importing ``entry_point``.
69
+
70
+ Bundles that did not come from an entry point — a remote bundle
71
+ path-imported from the lock file, or a test double — are still describable as
72
+ a ``DiscoveredBundle`` this way, which is what lets *one* discovery object
73
+ carry every bundle a process will load (issue #218). ``entry_point`` remains
74
+ meaningful as provenance even when it is not the import path.
75
+ """
76
+
40
77
  def load(self) -> Bundle:
41
- """Load and instantiate the bundle from its entry point.
78
+ """Load and instantiate the bundle.
42
79
 
43
- The entry point must be a callable that returns a Bundle
44
- instance. If it's a class, it will be instantiated with
45
- no arguments.
80
+ When :attr:`loader` is set it is called and its result returned. Otherwise
81
+ the entry point must be a callable that returns a Bundle instance; if it's
82
+ a class, it will be instantiated with no arguments.
46
83
 
47
84
  Raises:
48
85
  ImportError: If the module cannot be imported.
49
86
  TypeError: If the loaded object is not a valid Bundle.
50
87
  """
88
+ if self.loader is not None:
89
+ return self.loader()
90
+
51
91
  module_path, _, attr_name = self.entry_point.rpartition(":")
52
92
  if not attr_name:
53
93
  # Entry point format is "module.path" (no colon)
@@ -111,6 +151,7 @@ class BundleDiscovery:
111
151
  name=ep.name,
112
152
  entry_point=ep.value,
113
153
  group=self.group,
154
+ distribution=_distribution_name(ep),
114
155
  )
115
156
  self._discovered[ep.name] = db
116
157
  logger.debug("Discovered bundle: %s -> %s", ep.name, ep.value)
@@ -156,3 +197,13 @@ class BundleDiscovery:
156
197
  """Remove all discovered bundles and reset scan state."""
157
198
  self._discovered.clear()
158
199
  self._scanned = False
200
+
201
+ def mark_scanned(self) -> None:
202
+ """Declare this discovery authoritative without scanning entry points.
203
+
204
+ A caller that pre-populates a *filtered* set needs "scanned" to mean
205
+ "complete", not "non-empty". Without this, an explicitly empty set would
206
+ trigger a lazy full scan on first access and silently reintroduce every
207
+ bundle the filter had just removed (issue #218).
208
+ """
209
+ self._scanned = True
@@ -0,0 +1,213 @@
1
+ """Bundle loader — discover and register all installed bundles.
2
+
3
+ Provides a high-level ``load_bundles()`` function that discovers
4
+ bundles via entry points, loads them, and registers them with
5
+ a SchemaManager. This is the standard startup path for any
6
+ application using the engine.
7
+
8
+ Loading is governed by a :class:`~katalyst_engine.bundle.policy.BundleLoadPolicy`
9
+ (issue #218): version constraints are checked against
10
+ :attr:`BundleManifest.version` the moment the manifest is read, provenance is
11
+ recorded so the registry can attribute every kind to a bundle, and
12
+ ``BundleManifest.entry_point`` is populated for bundles that arrived over an
13
+ entry point so provenance is reportable.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from dataclasses import dataclass, field
20
+
21
+ from katalyst_engine.bundle.discovery import BundleDiscovery, DiscoveredBundle
22
+ from katalyst_engine.bundle.policy import BundleLoadPolicy
23
+ from katalyst_engine.bundle.protocol import Bundle, BundleManifest
24
+ from katalyst_engine.core.errors import EngineConfigurationError
25
+ from katalyst_engine.schema.manager import SchemaManager
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class BundleLoader:
32
+ """Loads and registers bundles with a SchemaManager.
33
+
34
+ Manages the lifecycle of bundle discovery → loading → registration.
35
+ Tracks which bundles have been loaded and their manifests.
36
+
37
+ Usage::
38
+
39
+ loader = BundleLoader(schema_manager)
40
+ loader.discover_and_register()
41
+ # Or manually:
42
+ loader.register(my_bundle)
43
+ """
44
+
45
+ manager: SchemaManager
46
+ """The SchemaManager to register bundles with."""
47
+
48
+ policy: BundleLoadPolicy = field(default_factory=BundleLoadPolicy)
49
+ """Version constraints and conflict handling. Defaults to unconstrained."""
50
+
51
+ _loaded: dict[str, Bundle] = field(default_factory=lambda: dict[str, Bundle]())
52
+ _manifests: dict[str, BundleManifest] = field(
53
+ default_factory=lambda: dict[str, BundleManifest]()
54
+ )
55
+
56
+ def register(
57
+ self,
58
+ bundle: Bundle,
59
+ *,
60
+ entry_point: str = "",
61
+ discovered_name: str = "",
62
+ ) -> BundleManifest:
63
+ """Register a single bundle with the SchemaManager.
64
+
65
+ The version constraint is checked *before* anything is registered, so a
66
+ pin violation leaves the registry exactly as it was rather than
67
+ half-populated.
68
+
69
+ Args:
70
+ bundle: A Bundle instance to register.
71
+ entry_point: The entry point string this bundle arrived over, if any.
72
+ Recorded on the returned manifest — ``BundleManifest.entry_point``
73
+ has been declared and unpopulated since the protocol was written.
74
+ discovered_name: The discovery key, if different from the manifest
75
+ name. Consulted when matching version constraints so config may
76
+ name a bundle by its entry-point name.
77
+
78
+ Returns:
79
+ The bundle's manifest, with ``entry_point`` filled in when known.
80
+
81
+ Raises:
82
+ ValueError: If a bundle with the same identity is already loaded.
83
+ BundleVersionConflictError: If the configured version constraint is
84
+ not satisfied.
85
+ SchemaVersionMismatchError: If the bundle's LinkML modules do not carry
86
+ its version.
87
+ SchemaConflictError: If another bundle already provides one of this
88
+ bundle's kinds, wings, relationships or directory names.
89
+ """
90
+ manifest = bundle.manifest
91
+ fqn = manifest.identity.fqn
92
+ name = manifest.identity.name
93
+
94
+ if fqn in self._loaded:
95
+ raise ValueError(
96
+ f"Bundle already registered: {fqn!r}. "
97
+ f"Unregister it first or use a different identity."
98
+ )
99
+
100
+ self.policy.check(fqn, name, discovered_name, version=manifest.version)
101
+ if manifest.modules:
102
+ from katalyst_engine.evolution.release import bundle_release
103
+
104
+ bundle_release(bundle).check_version()
105
+
106
+ if entry_point and not manifest.entry_point:
107
+ manifest = manifest.model_copy(update={"entry_point": entry_point})
108
+
109
+ provider = name or discovered_name or fqn
110
+ with self.manager.registry.providing(provider):
111
+ bundle.register(self.manager)
112
+
113
+ self._loaded[fqn] = bundle
114
+ self._manifests[fqn] = manifest
115
+
116
+ logger.info(
117
+ "Registered bundle %r: %d kinds, %d wings",
118
+ fqn,
119
+ len(manifest.provided_kinds),
120
+ len(manifest.wing_names),
121
+ )
122
+
123
+ return manifest
124
+
125
+ def discover_and_register(
126
+ self,
127
+ discovery: BundleDiscovery | None = None,
128
+ *,
129
+ strict: bool | None = None,
130
+ ) -> list[BundleManifest]:
131
+ """Discover bundles via entry points and register them all.
132
+
133
+ Configuration failures — an unsatisfied version pin, two bundles
134
+ claiming one kind — always propagate. They are statements about what the
135
+ operator asked for, and logging them would turn a misconfiguration into a
136
+ silently reduced type system.
137
+
138
+ Args:
139
+ discovery: Optional pre-configured BundleDiscovery. If None, creates
140
+ a new one and scans entry points. Pass a pre-populated discovery
141
+ to load a *filtered* set — this is the seam the taxonomy layer's
142
+ config-aware discovery factory uses.
143
+ strict: Whether a bundle that fails for a non-configuration reason
144
+ (broken import, corrupt package) aborts the whole load. Defaults
145
+ to :attr:`BundleLoadPolicy.strict`.
146
+
147
+ Returns:
148
+ List of manifests for all successfully registered bundles.
149
+
150
+ Raises:
151
+ EngineConfigurationError: On any configuration failure.
152
+ Exception: Under ``strict``, whatever the failing bundle raised,
153
+ chained to a message naming it.
154
+ """
155
+ if discovery is None:
156
+ discovery = BundleDiscovery()
157
+ discovery.scan()
158
+
159
+ fail_fast = self.policy.strict if strict is None else strict
160
+ manifests: list[BundleManifest] = []
161
+
162
+ for discovered in discovery.all():
163
+ try:
164
+ bundle = discovered.load()
165
+ manifest = self.register(
166
+ bundle,
167
+ entry_point=discovered.entry_point,
168
+ discovered_name=discovered.name,
169
+ )
170
+ manifests.append(manifest)
171
+ except EngineConfigurationError:
172
+ raise
173
+ except Exception as exc:
174
+ if fail_fast:
175
+ raise RuntimeError(
176
+ f"Failed to load schema bundle {discovered.name!r}: {exc}"
177
+ ) from exc
178
+ logger.exception("Failed to load bundle %r", discovered.name)
179
+
180
+ return manifests
181
+
182
+ def register_discovered(self, discovered: DiscoveredBundle) -> BundleManifest:
183
+ """Load a single DiscoveredBundle and register it.
184
+
185
+ Args:
186
+ discovered: A discovered bundle to load and register.
187
+
188
+ Returns:
189
+ The bundle's manifest.
190
+ """
191
+ bundle = discovered.load()
192
+ return self.register(
193
+ bundle,
194
+ entry_point=discovered.entry_point,
195
+ discovered_name=discovered.name,
196
+ )
197
+
198
+ def get_manifest(self, fqn: str) -> BundleManifest | None:
199
+ """Look up a loaded bundle's manifest by FQN."""
200
+ return self._manifests.get(fqn)
201
+
202
+ def all_manifests(self) -> list[BundleManifest]:
203
+ """Return all loaded bundle manifests, sorted by FQN."""
204
+ return sorted(self._manifests.values(), key=lambda m: m.identity.fqn)
205
+
206
+ def is_loaded(self, fqn: str) -> bool:
207
+ """Check if a bundle with the given FQN is loaded."""
208
+ return fqn in self._loaded
209
+
210
+ @property
211
+ def loaded_count(self) -> int:
212
+ """Number of loaded bundles."""
213
+ return len(self._loaded)
@@ -18,10 +18,12 @@ bundle's own code executes:
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ import importlib
21
22
  import importlib.util
22
23
  import sys
23
24
  from dataclasses import dataclass
24
25
  from pathlib import Path
26
+ from types import ModuleType
25
27
 
26
28
  from katalyst_engine.bundle.protocol import Bundle
27
29
 
@@ -139,6 +141,14 @@ def load_bundle_from_path(target: PathImportTarget) -> Bundle:
139
141
  module_file = target.module_file
140
142
  if not module_file.is_file():
141
143
  raise BundleLoadError(f"Bundle module file not found: {module_file}")
144
+ if module_file.suffix in (".yaml", ".yml"):
145
+ # A LinkML schema bundle: nothing to import, nothing to run.
146
+ from katalyst_engine.toolkit.linkml import LinkMLBundleError, LinkMLFileBundle
147
+
148
+ try:
149
+ return LinkMLFileBundle(module_file)
150
+ except LinkMLBundleError as exc:
151
+ raise BundleLoadError(str(exc)) from exc
142
152
 
143
153
  # Derive a stable, unique module name from the file path.
144
154
  module_name = f"_katalyst_remote_bundle_{abs(hash(str(module_file)))}"
@@ -178,3 +188,118 @@ def load_bundle_from_path(target: PathImportTarget) -> Bundle:
178
188
  finally:
179
189
  if added_to_path and str(package_root) in sys.path:
180
190
  sys.path.remove(str(package_root))
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Importing a package under its real name (code plugins)
195
+ # ---------------------------------------------------------------------------
196
+
197
+
198
+ class PackageShadowError(ValueError):
199
+ """A package path-imported by name would shadow one importable from elsewhere."""
200
+
201
+
202
+ #: Top-level package name -> the identity (root + content digest) it was imported with.
203
+ _PACKAGES: dict[str, str] = {}
204
+
205
+
206
+ def _drop_package(package: str) -> None:
207
+ for name in [n for n in sys.modules if n == package or n.startswith(f"{package}.")]:
208
+ del sys.modules[name]
209
+ _PACKAGES.pop(package, None)
210
+
211
+
212
+ def _drop_bytecode(package_dir: Path) -> None:
213
+ import shutil
214
+
215
+ if package_dir.is_dir():
216
+ for cache in package_dir.rglob("__pycache__"):
217
+ shutil.rmtree(cache, ignore_errors=True)
218
+
219
+
220
+ def _importable_elsewhere(package: str, root: Path) -> str | None:
221
+ existing = sys.modules.get(package)
222
+ if existing is not None:
223
+ return str(getattr(existing, "__file__", None) or repr(existing))
224
+ try:
225
+ spec = importlib.util.find_spec(package)
226
+ except (ImportError, ValueError):
227
+ return None
228
+ if spec is None:
229
+ return None
230
+ origin = spec.origin or next(iter(spec.submodule_search_locations or ()), None)
231
+ if origin is None:
232
+ return "a namespace package"
233
+ try:
234
+ if Path(origin).resolve().is_relative_to(root.resolve()):
235
+ return None
236
+ except OSError:
237
+ pass
238
+ return origin
239
+
240
+
241
+ def import_module_from_root(root: Path, module_path: str, *, identity: str = "") -> ModuleType:
242
+ """Import dotted *module_path* from *root* under its **real** name; ``sys.path`` untouched.
243
+
244
+ Unlike :func:`load_bundle_from_path`, which imports one file under a mangled name,
245
+ this registers the top-level package from *root* in ``sys.modules`` with
246
+ ``submodule_search_locations`` set, so the package's own absolute and relative
247
+ imports of its submodules resolve from *root*. Used for code plugins, which are
248
+ ordinary packages.
249
+
250
+ Args:
251
+ root: The directory that plays ``sys.path`` for the package.
252
+ module_path: ``pkg`` or ``pkg.sub.module``.
253
+ identity: What this import is of (root plus content digest). Importing the same
254
+ package with a different identity forgets the previous modules first, so a
255
+ changed tree is what runs.
256
+
257
+ Raises:
258
+ PackageShadowError: The package is importable from somewhere else (an installed
259
+ distribution, the standard library) — registering it would hijack it.
260
+ BaseException: Whatever the package's own code raises; its modules are
261
+ forgotten first.
262
+ """
263
+ package = module_path.split(".", 1)[0]
264
+ identity = identity or str(root)
265
+ if _PACKAGES.get(package) not in (None, identity):
266
+ _drop_package(package)
267
+ # Bytecode is validated by source mtime (whole seconds) and size, so content
268
+ # changed within the same second at the same size would run stale code.
269
+ _drop_bytecode(root / package)
270
+ importlib.invalidate_caches()
271
+ if package not in _PACKAGES:
272
+ elsewhere = _importable_elsewhere(package, root)
273
+ if elsewhere is not None:
274
+ raise PackageShadowError(
275
+ f"Package {package!r} is already importable from {elsewhere}; a package "
276
+ "path-imported from a source may not shadow an existing module."
277
+ )
278
+ package_dir = root / package
279
+ init = package_dir / "__init__.py"
280
+ if init.is_file():
281
+ spec = importlib.util.spec_from_file_location(
282
+ package, init, submodule_search_locations=[str(package_dir)]
283
+ )
284
+ else:
285
+ spec = importlib.util.spec_from_file_location(package, root / f"{package}.py")
286
+ if spec is None or spec.loader is None:
287
+ raise BundleLoadError(f"Could not create an import spec for {package!r} under {root}")
288
+ module = importlib.util.module_from_spec(spec)
289
+ sys.modules[package] = module
290
+ try:
291
+ spec.loader.exec_module(module)
292
+ except BaseException:
293
+ _drop_package(package)
294
+ raise
295
+ _PACKAGES[package] = identity
296
+ try:
297
+ return importlib.import_module(module_path)
298
+ except BaseException:
299
+ _drop_package(package)
300
+ raise
301
+
302
+
303
+ def path_imported_package(package: str) -> bool:
304
+ """Whether *package* was imported by :func:`import_module_from_root` in this process."""
305
+ return package in _PACKAGES