katalyst-engine 2.0.0__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.
- katalyst_engine/__init__.py +6 -0
- katalyst_engine/bundle/__init__.py +30 -0
- katalyst_engine/bundle/discovery.py +158 -0
- katalyst_engine/bundle/loader.py +134 -0
- katalyst_engine/bundle/protocol.py +209 -0
- katalyst_engine/core/__init__.py +62 -0
- katalyst_engine/core/compatibility.py +58 -0
- katalyst_engine/core/compositional.py +103 -0
- katalyst_engine/core/definitive.py +195 -0
- katalyst_engine/core/evolvable.py +89 -0
- katalyst_engine/core/identity.py +95 -0
- katalyst_engine/core/lifecycle.py +62 -0
- katalyst_engine/core/relation.py +151 -0
- katalyst_engine/core/version.py +203 -0
- katalyst_engine/discovery/__init__.py +20 -0
- katalyst_engine/discovery/declaration.py +74 -0
- katalyst_engine/discovery/dispatcher.py +83 -0
- katalyst_engine/discovery/protocol.py +69 -0
- katalyst_engine/events/__init__.py +10 -0
- katalyst_engine/events/bus.py +102 -0
- katalyst_engine/events/event.py +82 -0
- katalyst_engine/extensions/__init__.py +32 -0
- katalyst_engine/extensions/capability.py +45 -0
- katalyst_engine/extensions/discovery.py +85 -0
- katalyst_engine/extensions/effector.py +54 -0
- katalyst_engine/extensions/provider.py +33 -0
- katalyst_engine/extensions/registry.py +77 -0
- katalyst_engine/extensions/trigger.py +64 -0
- katalyst_engine/model/__init__.py +25 -0
- katalyst_engine/model/manager.py +85 -0
- katalyst_engine/model/materializer.py +78 -0
- katalyst_engine/model/node.py +49 -0
- katalyst_engine/model/query.py +186 -0
- katalyst_engine/model/store.py +119 -0
- katalyst_engine/py.typed +0 -0
- katalyst_engine/replication/__init__.py +30 -0
- katalyst_engine/replication/engine.py +104 -0
- katalyst_engine/replication/job.py +88 -0
- katalyst_engine/replication/transform.py +111 -0
- katalyst_engine/resolution/__init__.py +32 -0
- katalyst_engine/resolution/conflict.py +91 -0
- katalyst_engine/resolution/engine.py +131 -0
- katalyst_engine/resolution/strategies.py +122 -0
- katalyst_engine/schema/__init__.py +35 -0
- katalyst_engine/schema/definition.py +281 -0
- katalyst_engine/schema/manager.py +95 -0
- katalyst_engine/schema/registry.py +367 -0
- katalyst_engine/schema/versioning.py +115 -0
- katalyst_engine/snapshot/__init__.py +18 -0
- katalyst_engine/snapshot/diff.py +94 -0
- katalyst_engine/snapshot/snapshot.py +111 -0
- katalyst_engine/source/__init__.py +26 -0
- katalyst_engine/source/manifest.py +45 -0
- katalyst_engine/source/registry.py +122 -0
- katalyst_engine/source/source.py +92 -0
- katalyst_engine/toolkit/__init__.py +22 -0
- katalyst_engine/toolkit/file_ops.py +194 -0
- katalyst_engine/toolkit/rendering.py +58 -0
- katalyst_engine-2.0.0.dist-info/METADATA +50 -0
- katalyst_engine-2.0.0.dist-info/RECORD +61 -0
- katalyst_engine-2.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Snapshot — frozen copy of a model at a point in time.
|
|
2
|
+
|
|
3
|
+
A Snapshot is a Compositional that captures the complete state of
|
|
4
|
+
a model, including a timestamp and manifest of what it contains.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from katalyst_engine.core.compositional import Compositional
|
|
17
|
+
from katalyst_engine.core.identity import Identity
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from katalyst_engine.model.node import Node
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SnapshotManifest(BaseModel, frozen=True):
|
|
24
|
+
"""Manifest describing the contents of a snapshot.
|
|
25
|
+
|
|
26
|
+
Summarizes what the snapshot contains without requiring
|
|
27
|
+
the full model to be loaded.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
node_count: int = 0
|
|
31
|
+
"""Number of nodes in the snapshot."""
|
|
32
|
+
|
|
33
|
+
relation_count: int = 0
|
|
34
|
+
"""Number of relations in the snapshot."""
|
|
35
|
+
|
|
36
|
+
kinds: frozenset[str] = frozenset()
|
|
37
|
+
"""Node kinds present in the snapshot."""
|
|
38
|
+
|
|
39
|
+
namespaces: frozenset[str] = frozenset()
|
|
40
|
+
"""Namespaces present in the snapshot."""
|
|
41
|
+
|
|
42
|
+
checksum: str = ""
|
|
43
|
+
"""Content hash for change detection."""
|
|
44
|
+
|
|
45
|
+
node_checksums: dict[str, str] = {}
|
|
46
|
+
"""Per-node content checksums: fqn -> sha256 of spec."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Snapshot(Compositional, frozen=True):
|
|
50
|
+
"""Frozen copy of a model at a specific point in time.
|
|
51
|
+
|
|
52
|
+
Snapshots are Compositionals — their member_refs list the
|
|
53
|
+
identities of all nodes captured. The manifest provides
|
|
54
|
+
summary metadata.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
timestamp: datetime
|
|
58
|
+
"""When this snapshot was taken."""
|
|
59
|
+
|
|
60
|
+
manifest: SnapshotManifest = SnapshotManifest()
|
|
61
|
+
"""Summary of snapshot contents."""
|
|
62
|
+
|
|
63
|
+
source_description: str = ""
|
|
64
|
+
"""Human-readable description of what was captured."""
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def create(
|
|
68
|
+
cls,
|
|
69
|
+
identity: Identity,
|
|
70
|
+
timestamp: datetime,
|
|
71
|
+
member_refs: tuple[Identity, ...],
|
|
72
|
+
manifest: SnapshotManifest | None = None,
|
|
73
|
+
nodes: tuple[Node, ...] | None = None,
|
|
74
|
+
) -> Snapshot:
|
|
75
|
+
"""Convenience factory for creating a snapshot.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
identity: Identity for this snapshot.
|
|
79
|
+
timestamp: When the snapshot was taken.
|
|
80
|
+
member_refs: Identities of captured nodes.
|
|
81
|
+
manifest: Optional pre-computed manifest.
|
|
82
|
+
nodes: Optional Node objects for computing per-node checksums.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
A new Snapshot instance.
|
|
86
|
+
"""
|
|
87
|
+
node_checksums: dict[str, str] = {}
|
|
88
|
+
if nodes is not None:
|
|
89
|
+
node_checksums = {
|
|
90
|
+
node.identity.fqn: hashlib.sha256(
|
|
91
|
+
json.dumps(dict(sorted(node.spec.items())), sort_keys=True).encode()
|
|
92
|
+
).hexdigest()
|
|
93
|
+
for node in nodes
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if manifest is None:
|
|
97
|
+
kinds = frozenset(ref.kind for ref in member_refs)
|
|
98
|
+
namespaces = frozenset(ref.namespace for ref in member_refs)
|
|
99
|
+
manifest = SnapshotManifest(
|
|
100
|
+
node_count=len(member_refs),
|
|
101
|
+
kinds=kinds,
|
|
102
|
+
namespaces=namespaces,
|
|
103
|
+
node_checksums=node_checksums,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return cls(
|
|
107
|
+
identity=identity,
|
|
108
|
+
timestamp=timestamp,
|
|
109
|
+
member_refs=member_refs,
|
|
110
|
+
manifest=manifest,
|
|
111
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Source management — where data lives and what authority it claims.
|
|
2
|
+
|
|
3
|
+
.. note:: FUTURE: Wire into taxonomy when multi-source node discovery is implemented.
|
|
4
|
+
The taxonomy's TaxonomyBackend abstraction maps to Source — each backend
|
|
5
|
+
(filesystem, neo4j, postgres) would register as a Source with canonicity
|
|
6
|
+
claims. Currently unused by taxonomy which has its own sources/ package
|
|
7
|
+
for template management (a different domain).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from katalyst_engine.source.manifest import SourceManifest
|
|
11
|
+
from katalyst_engine.source.registry import SourceRegistry
|
|
12
|
+
from katalyst_engine.source.source import (
|
|
13
|
+
CanonicityClaim,
|
|
14
|
+
Source,
|
|
15
|
+
SourceHealth,
|
|
16
|
+
SourceKind,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CanonicityClaim",
|
|
21
|
+
"Source",
|
|
22
|
+
"SourceHealth",
|
|
23
|
+
"SourceKind",
|
|
24
|
+
"SourceManifest",
|
|
25
|
+
"SourceRegistry",
|
|
26
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Source manifest — declares what a source provides.
|
|
2
|
+
|
|
3
|
+
A SourceManifest is the metadata document a source publishes
|
|
4
|
+
describing what kinds of data it contains, its schema versions,
|
|
5
|
+
and last-known sync state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from katalyst_engine.core.identity import Identity
|
|
15
|
+
from katalyst_engine.core.version import Version
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SourceManifest(BaseModel, frozen=True):
|
|
19
|
+
"""Metadata published by a source about its contents.
|
|
20
|
+
|
|
21
|
+
The manifest is used by the discovery dispatcher to decide
|
|
22
|
+
which discovery protocols to invoke and by the resolution
|
|
23
|
+
engine to understand source freshness.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
source_identity: Identity
|
|
27
|
+
"""Identity of the source this manifest describes."""
|
|
28
|
+
|
|
29
|
+
available_kinds: frozenset[str] = frozenset()
|
|
30
|
+
"""Kinds of declarations this source can provide."""
|
|
31
|
+
|
|
32
|
+
schema_version: Version | None = None
|
|
33
|
+
"""Schema version the source's data conforms to."""
|
|
34
|
+
|
|
35
|
+
last_sync: datetime | None = None
|
|
36
|
+
"""When this source was last successfully synced."""
|
|
37
|
+
|
|
38
|
+
declaration_count: int | None = None
|
|
39
|
+
"""Approximate number of declarations, if known."""
|
|
40
|
+
|
|
41
|
+
checksum: str = ""
|
|
42
|
+
"""Content hash for change detection. Empty if unknown."""
|
|
43
|
+
|
|
44
|
+
labels: dict[str, str] = {}
|
|
45
|
+
"""Additional metadata."""
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Source registry — manages all configured sources.
|
|
2
|
+
|
|
3
|
+
Central index of registered sources. Provides lookup by identity,
|
|
4
|
+
kind, and canonicity queries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from fnmatch import fnmatch
|
|
10
|
+
|
|
11
|
+
from katalyst_engine.core.identity import Identity
|
|
12
|
+
from katalyst_engine.source.manifest import SourceManifest
|
|
13
|
+
from katalyst_engine.source.source import Source, SourceHealth
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SourceRegistry:
|
|
17
|
+
"""Registry of all configured data sources.
|
|
18
|
+
|
|
19
|
+
Sources are registered and looked up by identity. The registry
|
|
20
|
+
also provides queries for finding canonical sources for a given
|
|
21
|
+
kind of data.
|
|
22
|
+
|
|
23
|
+
This is a mutable container — sources can be added and removed
|
|
24
|
+
at runtime as the engine discovers new data locations.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._sources: dict[str, Source] = {} # keyed by fqn
|
|
29
|
+
self._manifests: dict[str, SourceManifest] = {} # keyed by source fqn
|
|
30
|
+
|
|
31
|
+
def register(self, source: Source) -> None:
|
|
32
|
+
"""Register a source. Overwrites if identity already exists."""
|
|
33
|
+
self._sources[source.identity.fqn] = source
|
|
34
|
+
|
|
35
|
+
def unregister(self, identity: Identity) -> bool:
|
|
36
|
+
"""Remove a source. Returns True if it existed."""
|
|
37
|
+
fqn = identity.fqn
|
|
38
|
+
removed = fqn in self._sources
|
|
39
|
+
self._sources.pop(fqn, None)
|
|
40
|
+
self._manifests.pop(fqn, None)
|
|
41
|
+
return removed
|
|
42
|
+
|
|
43
|
+
def get(self, identity: Identity) -> Source | None:
|
|
44
|
+
"""Look up a source by identity."""
|
|
45
|
+
return self._sources.get(identity.fqn)
|
|
46
|
+
|
|
47
|
+
def get_by_fqn(self, fqn: str) -> Source | None:
|
|
48
|
+
"""Look up a source by FQN string."""
|
|
49
|
+
return self._sources.get(fqn)
|
|
50
|
+
|
|
51
|
+
def set_manifest(self, manifest: SourceManifest) -> None:
|
|
52
|
+
"""Update the manifest for a source."""
|
|
53
|
+
self._manifests[manifest.source_identity.fqn] = manifest
|
|
54
|
+
|
|
55
|
+
def get_manifest(self, identity: Identity) -> SourceManifest | None:
|
|
56
|
+
"""Get the manifest for a source."""
|
|
57
|
+
return self._manifests.get(identity.fqn)
|
|
58
|
+
|
|
59
|
+
def all_sources(self) -> list[Source]:
|
|
60
|
+
"""Return all registered sources, ordered by FQN."""
|
|
61
|
+
return sorted(self._sources.values(), key=lambda s: s.identity.fqn)
|
|
62
|
+
|
|
63
|
+
def healthy_sources(self) -> list[Source]:
|
|
64
|
+
"""Return sources in HEALTHY state."""
|
|
65
|
+
return [s for s in self._sources.values() if s.health == SourceHealth.HEALTHY]
|
|
66
|
+
|
|
67
|
+
def sources_for_kind(self, kind: str) -> list[Source]:
|
|
68
|
+
"""Find sources that claim to provide a given kind.
|
|
69
|
+
|
|
70
|
+
Checks canonicity.kind_patterns using fnmatch glob matching.
|
|
71
|
+
Sources with no kind_patterns claim are excluded.
|
|
72
|
+
"""
|
|
73
|
+
results: list[Source] = []
|
|
74
|
+
for source in self._sources.values():
|
|
75
|
+
if not source.canonicity.kind_patterns:
|
|
76
|
+
continue
|
|
77
|
+
for pattern in source.canonicity.kind_patterns:
|
|
78
|
+
if fnmatch(kind, pattern):
|
|
79
|
+
results.append(source)
|
|
80
|
+
break
|
|
81
|
+
return sorted(results, key=lambda s: -s.canonicity.priority)
|
|
82
|
+
|
|
83
|
+
def canonical_source_for(self, kind: str, namespace: str = "") -> Source | None:
|
|
84
|
+
"""Find the highest-priority canonical source for a kind + namespace.
|
|
85
|
+
|
|
86
|
+
Returns the source with the highest canonicity priority that
|
|
87
|
+
matches both the kind and namespace patterns. Returns None if
|
|
88
|
+
no source claims canonicity.
|
|
89
|
+
"""
|
|
90
|
+
best: Source | None = None
|
|
91
|
+
best_priority = -1
|
|
92
|
+
|
|
93
|
+
for source in self._sources.values():
|
|
94
|
+
claim = source.canonicity
|
|
95
|
+
if not claim.kind_patterns:
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
kind_match = any(fnmatch(kind, p) for p in claim.kind_patterns)
|
|
99
|
+
if not kind_match:
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
ns_match = True
|
|
103
|
+
if claim.namespace_patterns:
|
|
104
|
+
ns_match = any(fnmatch(namespace, p) for p in claim.namespace_patterns)
|
|
105
|
+
if not ns_match:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
if claim.priority > best_priority:
|
|
109
|
+
best = source
|
|
110
|
+
best_priority = claim.priority
|
|
111
|
+
|
|
112
|
+
return best
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def count(self) -> int:
|
|
116
|
+
"""Number of registered sources."""
|
|
117
|
+
return len(self._sources)
|
|
118
|
+
|
|
119
|
+
def clear(self) -> None:
|
|
120
|
+
"""Remove all sources and manifests."""
|
|
121
|
+
self._sources.clear()
|
|
122
|
+
self._manifests.clear()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Source definitions — where data lives and what authority it claims.
|
|
2
|
+
|
|
3
|
+
A Source is a registered location from which the engine discovers
|
|
4
|
+
declarations. Each source has a kind (filesystem, database, API),
|
|
5
|
+
a canonicity claim (is it authoritative?), and health state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from katalyst_engine.core.identity import Identity
|
|
15
|
+
from katalyst_engine.core.version import VersionRange
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SourceKind(str, Enum):
|
|
19
|
+
"""Known kinds of data sources."""
|
|
20
|
+
|
|
21
|
+
FILESYSTEM = "filesystem"
|
|
22
|
+
DATABASE = "database"
|
|
23
|
+
API = "api"
|
|
24
|
+
MEMORY = "memory"
|
|
25
|
+
REGISTRY = "registry"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SourceHealth(str, Enum):
|
|
29
|
+
"""Health state of a source."""
|
|
30
|
+
|
|
31
|
+
UNKNOWN = "unknown"
|
|
32
|
+
HEALTHY = "healthy"
|
|
33
|
+
DEGRADED = "degraded"
|
|
34
|
+
UNAVAILABLE = "unavailable"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CanonicityClaim(BaseModel, frozen=True):
|
|
38
|
+
"""What authority a source claims over particular kinds of data.
|
|
39
|
+
|
|
40
|
+
A source that claims canonicity for kind "layer" is asserting that
|
|
41
|
+
its declarations for layers are authoritative. During conflict
|
|
42
|
+
resolution, canonical sources win over non-canonical ones.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
kind_patterns: frozenset[str] = frozenset()
|
|
46
|
+
"""Glob patterns for the kinds this source is canonical for. Empty = no claim."""
|
|
47
|
+
|
|
48
|
+
namespace_patterns: frozenset[str] = frozenset()
|
|
49
|
+
"""Glob patterns for namespaces. Empty = all namespaces."""
|
|
50
|
+
|
|
51
|
+
version_range: VersionRange | None = None
|
|
52
|
+
"""Optional version range this claim applies to."""
|
|
53
|
+
|
|
54
|
+
priority: int = 0
|
|
55
|
+
"""Higher priority wins when two sources claim canonicity for the same data."""
|
|
56
|
+
|
|
57
|
+
exclusive: bool = False
|
|
58
|
+
"""If True, this source is the ONLY authority. Other sources' data is ignored."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Source(BaseModel, frozen=True):
|
|
62
|
+
"""A registered data source in the engine.
|
|
63
|
+
|
|
64
|
+
Sources are identified by an Identity (so they are addressable
|
|
65
|
+
in the same namespace as everything else). They describe where
|
|
66
|
+
data lives, what authority it claims, and how to connect to it.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
identity: Identity
|
|
70
|
+
"""Unique identity of this source."""
|
|
71
|
+
|
|
72
|
+
kind: SourceKind
|
|
73
|
+
"""What type of source this is."""
|
|
74
|
+
|
|
75
|
+
uri: str = ""
|
|
76
|
+
"""Connection URI or path. Interpretation depends on kind."""
|
|
77
|
+
|
|
78
|
+
canonicity: CanonicityClaim = CanonicityClaim()
|
|
79
|
+
"""What authority this source claims."""
|
|
80
|
+
|
|
81
|
+
health: SourceHealth = SourceHealth.UNKNOWN
|
|
82
|
+
"""Current health state."""
|
|
83
|
+
|
|
84
|
+
labels: dict[str, str] = {}
|
|
85
|
+
"""Metadata labels for filtering and selection."""
|
|
86
|
+
|
|
87
|
+
config: dict[str, str] = {}
|
|
88
|
+
"""Source-specific configuration (credentials excluded)."""
|
|
89
|
+
|
|
90
|
+
def with_health(self, health: SourceHealth) -> Source:
|
|
91
|
+
"""Return a new Source with updated health state."""
|
|
92
|
+
return self.model_copy(update={"health": health})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Toolkit — stateless utilities for template rendering and file operations.
|
|
2
|
+
|
|
3
|
+
Provides generic helpers used by bundles, plugins, and adapters.
|
|
4
|
+
No domain knowledge — these are pure utility functions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from katalyst_engine.toolkit.file_ops import (
|
|
8
|
+
copy_file,
|
|
9
|
+
copy_tree_no_overwrite,
|
|
10
|
+
copy_tree_rendered,
|
|
11
|
+
copy_tree_rendered_per_variant,
|
|
12
|
+
)
|
|
13
|
+
from katalyst_engine.toolkit.rendering import render_file, render_tokens
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"copy_file",
|
|
17
|
+
"copy_tree_no_overwrite",
|
|
18
|
+
"copy_tree_rendered",
|
|
19
|
+
"copy_tree_rendered_per_variant",
|
|
20
|
+
"render_file",
|
|
21
|
+
"render_tokens",
|
|
22
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""File tree operations — copy, render, and merge directory trees.
|
|
2
|
+
|
|
3
|
+
Provides generic file operations for scaffolding and template
|
|
4
|
+
expansion. These functions DO perform I/O but are domain-agnostic —
|
|
5
|
+
they know nothing about taxonomy, nodes, or bundles.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Mapping, Sequence
|
|
14
|
+
|
|
15
|
+
from katalyst_engine.toolkit.rendering import render_tokens
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def copy_tree_rendered(
|
|
19
|
+
src: Path,
|
|
20
|
+
dest: Path,
|
|
21
|
+
context: Mapping[str, str],
|
|
22
|
+
*,
|
|
23
|
+
skip_names: frozenset[str] = frozenset(),
|
|
24
|
+
skip_root_file: str = "",
|
|
25
|
+
) -> list[Path]:
|
|
26
|
+
"""Copy a directory tree, rendering ``{{ key }}`` tokens in text files.
|
|
27
|
+
|
|
28
|
+
Walks the *src* directory recursively, rendering token placeholders
|
|
29
|
+
in text file contents using the provided *context*. Binary files
|
|
30
|
+
(that fail UTF-8 decoding) are copied verbatim.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
src: Source directory to copy from.
|
|
34
|
+
dest: Destination directory to copy into.
|
|
35
|
+
context: Token replacement context for ``{{ key }}`` substitution.
|
|
36
|
+
skip_names: Set of directory names to skip at the root level.
|
|
37
|
+
skip_root_file: A single filename to skip at the root level.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
List of paths written to *dest*.
|
|
41
|
+
"""
|
|
42
|
+
written: list[Path] = []
|
|
43
|
+
|
|
44
|
+
def _should_skip(rel: Path) -> bool:
|
|
45
|
+
if rel.parts and rel.parts[0] in skip_names:
|
|
46
|
+
return True
|
|
47
|
+
if skip_root_file and rel == Path(skip_root_file):
|
|
48
|
+
return True
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
written.extend(
|
|
52
|
+
_walk_and_transform(
|
|
53
|
+
src,
|
|
54
|
+
dest,
|
|
55
|
+
content_transform=lambda c: render_tokens(c, context),
|
|
56
|
+
skip_fn=_should_skip,
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
return written
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def copy_tree_rendered_per_variant(
|
|
63
|
+
src: Path,
|
|
64
|
+
dest: Path,
|
|
65
|
+
context: Mapping[str, str],
|
|
66
|
+
variants: Sequence[str],
|
|
67
|
+
variant_key: str = "environment",
|
|
68
|
+
) -> list[Path]:
|
|
69
|
+
"""Copy a directory tree once per variant, adding the variant key to context.
|
|
70
|
+
|
|
71
|
+
For each variant name, copies the entire *src* tree into
|
|
72
|
+
``dest/<variant_name>/``, augmenting the context with
|
|
73
|
+
``{variant_key: variant_name}``.
|
|
74
|
+
|
|
75
|
+
This generalizes the "environment template" pattern — the taxonomy
|
|
76
|
+
passes ``variants=environments, variant_key="environment"``, but
|
|
77
|
+
any variant dimension can be used.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
src: Source template directory.
|
|
81
|
+
dest: Destination parent directory.
|
|
82
|
+
context: Base token replacement context.
|
|
83
|
+
variants: Variant names (e.g., environment names: "dev", "staging", "prod").
|
|
84
|
+
variant_key: Context key for the variant name (default: "environment").
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
List of all paths written.
|
|
88
|
+
"""
|
|
89
|
+
written: list[Path] = []
|
|
90
|
+
for variant_name in variants:
|
|
91
|
+
variant_context = {**context, variant_key: variant_name}
|
|
92
|
+
variant_dir = dest / variant_name
|
|
93
|
+
written.extend(
|
|
94
|
+
_walk_and_transform(
|
|
95
|
+
src,
|
|
96
|
+
variant_dir,
|
|
97
|
+
content_transform=lambda c, ctx=variant_context: render_tokens(c, ctx),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
return written
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def copy_tree_no_overwrite(src: Path, dest: Path) -> list[Path]:
|
|
104
|
+
"""Copy a directory tree without overwriting existing files.
|
|
105
|
+
|
|
106
|
+
Files that already exist at the destination are skipped.
|
|
107
|
+
Directories are created as needed.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
src: Source directory.
|
|
111
|
+
dest: Destination directory.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
List of newly created file paths.
|
|
115
|
+
"""
|
|
116
|
+
written: list[Path] = []
|
|
117
|
+
for src_path in src.rglob("*"):
|
|
118
|
+
rel_path = src_path.relative_to(src)
|
|
119
|
+
dest_path = dest / rel_path
|
|
120
|
+
|
|
121
|
+
if src_path.is_dir():
|
|
122
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
elif not dest_path.exists():
|
|
124
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
shutil.copy2(src_path, dest_path)
|
|
126
|
+
written.append(dest_path)
|
|
127
|
+
|
|
128
|
+
return written
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def copy_file(src: Path, dest: Path) -> None:
|
|
132
|
+
"""Copy a single file, creating parent directories as needed.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
src: Source file path.
|
|
136
|
+
dest: Destination file path.
|
|
137
|
+
"""
|
|
138
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
shutil.copy2(src, dest)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Internal helpers
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _walk_and_transform(
|
|
148
|
+
src: Path,
|
|
149
|
+
dest: Path,
|
|
150
|
+
*,
|
|
151
|
+
content_transform: Callable[[str], str] | None = None,
|
|
152
|
+
path_transform: Callable[[str], str] | None = None,
|
|
153
|
+
skip_fn: Callable[[Path], bool] | None = None,
|
|
154
|
+
) -> list[Path]:
|
|
155
|
+
"""Walk a source tree, optionally transforming paths and content.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
src: Source directory.
|
|
159
|
+
dest: Destination directory.
|
|
160
|
+
content_transform: Callable(str) -> str for file contents.
|
|
161
|
+
path_transform: Callable(str) -> str for relative path strings.
|
|
162
|
+
skip_fn: Callable(Path) -> bool to skip entries.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
List of paths written.
|
|
166
|
+
"""
|
|
167
|
+
written: list[Path] = []
|
|
168
|
+
|
|
169
|
+
for src_path in src.rglob("*"):
|
|
170
|
+
rel = src_path.relative_to(src)
|
|
171
|
+
|
|
172
|
+
if skip_fn is not None and skip_fn(rel):
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
rel_str = str(rel)
|
|
176
|
+
if path_transform is not None:
|
|
177
|
+
rel_str = path_transform(rel_str)
|
|
178
|
+
|
|
179
|
+
dest_path = dest / rel_str
|
|
180
|
+
|
|
181
|
+
if src_path.is_dir():
|
|
182
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
else:
|
|
184
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
try:
|
|
186
|
+
content = src_path.read_text(encoding="utf-8")
|
|
187
|
+
if content_transform is not None:
|
|
188
|
+
content = content_transform(content)
|
|
189
|
+
dest_path.write_text(content, encoding="utf-8")
|
|
190
|
+
except UnicodeDecodeError:
|
|
191
|
+
shutil.copy2(src_path, dest_path)
|
|
192
|
+
written.append(dest_path)
|
|
193
|
+
|
|
194
|
+
return written
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Token rendering for ``{{ key }}`` template substitution.
|
|
2
|
+
|
|
3
|
+
Provides the canonical implementation of the ``{{ key }}`` placeholder
|
|
4
|
+
rendering used throughout the scaffolding and template systems.
|
|
5
|
+
|
|
6
|
+
This is a simple regex-based substitution — NOT Jinja2. Tokens not
|
|
7
|
+
found in the context are left as-is, allowing partial rendering.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Mapping
|
|
15
|
+
|
|
16
|
+
_TOKEN_RE = re.compile(r"\{\{\s*(\w+)\s*\}\}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render_tokens(text: str, context: Mapping[str, str]) -> str:
|
|
20
|
+
"""Replace ``{{ key }}`` tokens in *text* using *context*.
|
|
21
|
+
|
|
22
|
+
Tokens not found in *context* are left as-is.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
text: Template string containing ``{{ key }}`` tokens.
|
|
26
|
+
context: Mapping of token names to replacement values.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The rendered string with tokens replaced.
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
>>> render_tokens("Hello {{ name }}", {"name": "world"})
|
|
33
|
+
'Hello world'
|
|
34
|
+
>>> render_tokens("{{ missing }}", {})
|
|
35
|
+
'{{ missing }}'
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def _replace(match: re.Match[str]) -> str:
|
|
39
|
+
return context.get(match.group(1), match.group(0))
|
|
40
|
+
|
|
41
|
+
return _TOKEN_RE.sub(_replace, text)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def render_file(path: Path, context: Mapping[str, str]) -> str:
|
|
45
|
+
"""Read a file and render its ``{{ key }}`` tokens.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
path: Path to the template file.
|
|
49
|
+
context: Mapping of token names to replacement values.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The rendered file content.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
OSError: If the file cannot be read.
|
|
56
|
+
"""
|
|
57
|
+
content = path.read_text(encoding="utf-8")
|
|
58
|
+
return render_tokens(content, context)
|