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.
Files changed (61) hide show
  1. katalyst_engine/__init__.py +6 -0
  2. katalyst_engine/bundle/__init__.py +30 -0
  3. katalyst_engine/bundle/discovery.py +158 -0
  4. katalyst_engine/bundle/loader.py +134 -0
  5. katalyst_engine/bundle/protocol.py +209 -0
  6. katalyst_engine/core/__init__.py +62 -0
  7. katalyst_engine/core/compatibility.py +58 -0
  8. katalyst_engine/core/compositional.py +103 -0
  9. katalyst_engine/core/definitive.py +195 -0
  10. katalyst_engine/core/evolvable.py +89 -0
  11. katalyst_engine/core/identity.py +95 -0
  12. katalyst_engine/core/lifecycle.py +62 -0
  13. katalyst_engine/core/relation.py +151 -0
  14. katalyst_engine/core/version.py +203 -0
  15. katalyst_engine/discovery/__init__.py +20 -0
  16. katalyst_engine/discovery/declaration.py +74 -0
  17. katalyst_engine/discovery/dispatcher.py +83 -0
  18. katalyst_engine/discovery/protocol.py +69 -0
  19. katalyst_engine/events/__init__.py +10 -0
  20. katalyst_engine/events/bus.py +102 -0
  21. katalyst_engine/events/event.py +82 -0
  22. katalyst_engine/extensions/__init__.py +32 -0
  23. katalyst_engine/extensions/capability.py +45 -0
  24. katalyst_engine/extensions/discovery.py +85 -0
  25. katalyst_engine/extensions/effector.py +54 -0
  26. katalyst_engine/extensions/provider.py +33 -0
  27. katalyst_engine/extensions/registry.py +77 -0
  28. katalyst_engine/extensions/trigger.py +64 -0
  29. katalyst_engine/model/__init__.py +25 -0
  30. katalyst_engine/model/manager.py +85 -0
  31. katalyst_engine/model/materializer.py +78 -0
  32. katalyst_engine/model/node.py +49 -0
  33. katalyst_engine/model/query.py +186 -0
  34. katalyst_engine/model/store.py +119 -0
  35. katalyst_engine/py.typed +0 -0
  36. katalyst_engine/replication/__init__.py +30 -0
  37. katalyst_engine/replication/engine.py +104 -0
  38. katalyst_engine/replication/job.py +88 -0
  39. katalyst_engine/replication/transform.py +111 -0
  40. katalyst_engine/resolution/__init__.py +32 -0
  41. katalyst_engine/resolution/conflict.py +91 -0
  42. katalyst_engine/resolution/engine.py +131 -0
  43. katalyst_engine/resolution/strategies.py +122 -0
  44. katalyst_engine/schema/__init__.py +35 -0
  45. katalyst_engine/schema/definition.py +281 -0
  46. katalyst_engine/schema/manager.py +95 -0
  47. katalyst_engine/schema/registry.py +367 -0
  48. katalyst_engine/schema/versioning.py +115 -0
  49. katalyst_engine/snapshot/__init__.py +18 -0
  50. katalyst_engine/snapshot/diff.py +94 -0
  51. katalyst_engine/snapshot/snapshot.py +111 -0
  52. katalyst_engine/source/__init__.py +26 -0
  53. katalyst_engine/source/manifest.py +45 -0
  54. katalyst_engine/source/registry.py +122 -0
  55. katalyst_engine/source/source.py +92 -0
  56. katalyst_engine/toolkit/__init__.py +22 -0
  57. katalyst_engine/toolkit/file_ops.py +194 -0
  58. katalyst_engine/toolkit/rendering.py +58 -0
  59. katalyst_engine-2.0.0.dist-info/METADATA +50 -0
  60. katalyst_engine-2.0.0.dist-info/RECORD +61 -0
  61. 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)