agskillmanager 1.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.
@@ -0,0 +1,187 @@
1
+ """Deterministic dependency resolution and SkillBundle composition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from packaging.specifiers import SpecifierSet
9
+ from packaging.version import Version
10
+
11
+ from skill_manager.errors import (
12
+ SkillConflictError,
13
+ SkillDependencyCycleError,
14
+ SkillDependencyError,
15
+ )
16
+ from skill_manager.models import Skill, SkillDependency, SkillLifecycleState
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Iterable
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class _Requirement:
24
+ parent: Skill
25
+ dependency: SkillDependency
26
+ path: tuple[str, ...]
27
+
28
+
29
+ def resolve_dependencies(roots: Iterable[Skill], catalog: Iterable[Skill]) -> tuple[Skill, ...]:
30
+ """Resolve the transitive dependencies of ``roots`` against the active ``catalog``.
31
+
32
+ The search prefers the highest matching version of each dependency and
33
+ backtracks when a later constraint rules it out. One revision is chosen per
34
+ Skill name, shared by every Skill that depends on it.
35
+
36
+ Returns:
37
+ The selected Skills in dependency-first order, ties broken by name,
38
+ version and Skill ID, so equal inputs always produce equal output.
39
+
40
+ Raises:
41
+ SkillDependencyError: If a dependency has no matching active revision.
42
+ SkillDependencyCycleError: If the selected Skills form a cycle.
43
+ SkillConflictError: If two roots share a name or constraints conflict.
44
+ """
45
+ available = tuple(
46
+ skill for skill in catalog if skill.lifecycle.state is SkillLifecycleState.ACTIVE
47
+ )
48
+ sorted_roots = tuple(
49
+ sorted(
50
+ roots,
51
+ key=lambda item: (item.metadata.name, Version(item.metadata.version), item.skill_id),
52
+ )
53
+ )
54
+ assignments: dict[str, Skill] = {}
55
+ for root in sorted_roots:
56
+ existing = assignments.get(root.metadata.name)
57
+ if existing is not None and existing.skill_id != root.skill_id:
58
+ raise SkillConflictError(
59
+ f"multiple root revisions selected for Skill {root.metadata.name!r}"
60
+ )
61
+ assignments[root.metadata.name] = root
62
+
63
+ def matching(dependency: SkillDependency) -> list[Skill]:
64
+ spec = SpecifierSet("" if dependency.version == "*" else dependency.version)
65
+ matches = [
66
+ skill
67
+ for skill in available
68
+ if skill.metadata.name == dependency.name and Version(skill.metadata.version) in spec
69
+ ]
70
+ return sorted(
71
+ matches,
72
+ key=lambda item: (Version(item.metadata.version), item.skill_id),
73
+ reverse=True,
74
+ )
75
+
76
+ def enqueue_children(
77
+ skill: Skill,
78
+ path: tuple[str, ...],
79
+ ) -> tuple[_Requirement, ...]:
80
+ return tuple(
81
+ _Requirement(skill, dependency, path)
82
+ for dependency in sorted(skill.dependencies, key=lambda item: item.name)
83
+ )
84
+
85
+ def topological_order(
86
+ chosen: dict[str, Skill],
87
+ edges: dict[str, set[str]],
88
+ ) -> tuple[Skill, ...]:
89
+ by_id = {skill.skill_id: skill for skill in chosen.values()}
90
+ visiting: list[str] = []
91
+ visited: set[str] = set()
92
+ result: list[Skill] = []
93
+
94
+ def visit(skill_id: str) -> None:
95
+ if skill_id in visiting:
96
+ start = visiting.index(skill_id)
97
+ cycle_ids = (*visiting[start:], skill_id)
98
+ raise SkillDependencyCycleError(
99
+ tuple(by_id[item].metadata.name for item in cycle_ids)
100
+ )
101
+ if skill_id in visited:
102
+ return
103
+ visiting.append(skill_id)
104
+ skill = by_id[skill_id]
105
+ children = sorted(
106
+ edges.get(skill_id, set()),
107
+ key=lambda item: (
108
+ by_id[item].metadata.name,
109
+ Version(by_id[item].metadata.version),
110
+ item,
111
+ ),
112
+ )
113
+ for child_id in children:
114
+ visit(child_id)
115
+ visiting.pop()
116
+ visited.add(skill_id)
117
+ result.append(skill)
118
+
119
+ for skill in sorted(
120
+ by_id.values(),
121
+ key=lambda item: (
122
+ item.metadata.name,
123
+ Version(item.metadata.version),
124
+ item.skill_id,
125
+ ),
126
+ ):
127
+ visit(skill.skill_id)
128
+ return tuple(result)
129
+
130
+ def search(
131
+ pending: tuple[_Requirement, ...],
132
+ chosen: dict[str, Skill],
133
+ edges: dict[str, set[str]],
134
+ ) -> tuple[Skill, ...]:
135
+ if not pending:
136
+ return topological_order(chosen, edges)
137
+
138
+ requirement, remaining = pending[0], pending[1:]
139
+ dependency = requirement.dependency
140
+ path = (*requirement.path, requirement.parent.metadata.name, dependency.name)
141
+ options = matching(dependency)
142
+ if not options:
143
+ raise SkillDependencyError(
144
+ "missing dependency along path "
145
+ + " -> ".join(path)
146
+ + f" (requires {dependency.version})"
147
+ )
148
+
149
+ selected = chosen.get(dependency.name)
150
+ if selected is not None:
151
+ spec = SpecifierSet("" if dependency.version == "*" else dependency.version)
152
+ if Version(selected.metadata.version) not in spec:
153
+ raise SkillConflictError(
154
+ "conflicting dependency constraints along path " + " -> ".join(path)
155
+ )
156
+ next_edges = {key: set(value) for key, value in edges.items()}
157
+ next_edges.setdefault(requirement.parent.skill_id, set()).add(selected.skill_id)
158
+ return search(remaining, chosen, next_edges)
159
+
160
+ failures: list[SkillDependencyError | SkillConflictError] = []
161
+ for candidate in options:
162
+ next_chosen = dict(chosen)
163
+ next_chosen[dependency.name] = candidate
164
+ next_edges = {key: set(value) for key, value in edges.items()}
165
+ next_edges.setdefault(requirement.parent.skill_id, set()).add(candidate.skill_id)
166
+ next_pending = (
167
+ *enqueue_children(
168
+ candidate,
169
+ (*requirement.path, requirement.parent.metadata.name),
170
+ ),
171
+ *remaining,
172
+ )
173
+ try:
174
+ return search(next_pending, next_chosen, next_edges)
175
+ except (SkillDependencyError, SkillConflictError) as error:
176
+ failures.append(error)
177
+ raise failures[0]
178
+
179
+ pending = tuple(
180
+ requirement for root in sorted_roots for requirement in enqueue_children(root, ())
181
+ )
182
+ initial_edges = {root.skill_id: set() for root in sorted_roots}
183
+ return search(
184
+ pending,
185
+ assignments,
186
+ initial_edges,
187
+ )
@@ -0,0 +1,175 @@
1
+ """Skill source contract and a safe filesystem implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import re
8
+ from abc import ABC, abstractmethod
9
+ from pathlib import Path
10
+ from tempfile import NamedTemporaryFile
11
+ from typing import TYPE_CHECKING
12
+
13
+ from skill_manager.errors import SkillSourceError
14
+ from skill_manager.models import Skill, is_skill_id
15
+ from skill_manager.validation import dump_skill_yaml
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Mapping
19
+
20
+ _SKILL_ID_LINE = re.compile(r"""^skill_id:\s*["']?(sk_[0-9A-HJKMNP-TV-Z]{26})["']?\s*$""", re.M)
21
+
22
+
23
+ def validate_source_id(source_id: str) -> str:
24
+ """Return ``source_id`` when it is a non-empty, single-line identifier of at most 128 chars."""
25
+ if (
26
+ not source_id.strip()
27
+ or len(source_id) > 128
28
+ or any(character in source_id for character in "\r\n\x00")
29
+ ):
30
+ raise ValueError("source_id must be a non-empty safe identifier")
31
+ return source_id
32
+
33
+
34
+ class SkillSource(ABC):
35
+ """Where Skills originate; each source owns its own persistence.
36
+
37
+ A source stores Skill YAML documents. `SkillManager` parses and validates
38
+ them, so a source never interprets YAML itself. Every implementation must
39
+ keep these promises, which `skill_manager.testing.SkillSourceContract`
40
+ verifies:
41
+
42
+ - `load` returns every stored document keyed by a stable locator.
43
+ - `save` creates or overwrites the document of ``skill.skill_id``, and the
44
+ next `load` returns it exactly once.
45
+ - `delete` removes the document and ignores unknown IDs.
46
+ """
47
+
48
+ @property
49
+ @abstractmethod
50
+ def source_id(self) -> str:
51
+ """Return a stable, non-secret source identifier."""
52
+
53
+ @abstractmethod
54
+ async def load(self) -> Mapping[str, str]:
55
+ """Return locator to YAML text, without parsing or executing it."""
56
+
57
+ @abstractmethod
58
+ async def save(self, skill: Skill) -> None:
59
+ """Persist one validated Skill revision, replacing its previous document."""
60
+
61
+ @abstractmethod
62
+ async def delete(self, skill_id: str) -> None:
63
+ """Remove the document of a Skill; unknown IDs are ignored."""
64
+
65
+ async def aclose(self) -> None: # noqa: B027 - optional hook with a no-op default
66
+ """Release resources; the default does nothing."""
67
+
68
+
69
+ class FilesystemSkillSource(SkillSource):
70
+ """Read and atomically write ``*.yaml`` and ``*.yml`` files in one directory.
71
+
72
+ Symbolic links and paths escaping the root are refused. New Skills are
73
+ written to ``<skill_id>.yaml``; a Skill loaded from another file name is
74
+ rewritten in place.
75
+
76
+ Args:
77
+ root: Directory holding the Skill documents; created on first save.
78
+ source_id: Stable identifier for this source.
79
+ """
80
+
81
+ def __init__(self, root: str | Path, *, source_id: str = "filesystem") -> None:
82
+ self._root = Path(root).expanduser().resolve()
83
+ self._source_id = validate_source_id(source_id)
84
+ self._paths: dict[str, Path] = {}
85
+
86
+ @property
87
+ def source_id(self) -> str:
88
+ """Return the configured source identifier."""
89
+ return self._source_id
90
+
91
+ @property
92
+ def root(self) -> Path:
93
+ """Return the resolved source directory."""
94
+ return self._root
95
+
96
+ async def load(self) -> Mapping[str, str]:
97
+ """Read every Skill document in the root directory."""
98
+ try:
99
+ documents, paths = await asyncio.to_thread(self._load_sync)
100
+ except OSError as error:
101
+ raise SkillSourceError(
102
+ f"cannot load filesystem Skill source {self.source_id!r}"
103
+ ) from error
104
+ self._paths = paths
105
+ return documents
106
+
107
+ def _safe(self, path: Path) -> Path:
108
+ if path.is_symlink():
109
+ raise SkillSourceError(f"symbolic links are not allowed in Skill sources: {path.name}")
110
+ resolved = path.resolve()
111
+ if not resolved.is_relative_to(self._root):
112
+ raise SkillSourceError(f"Skill path escapes configured root: {path.name}")
113
+ return resolved
114
+
115
+ def _load_sync(self) -> tuple[dict[str, str], dict[str, Path]]:
116
+ if not self._root.exists():
117
+ return {}, {}
118
+ if not self._root.is_dir():
119
+ raise SkillSourceError(f"Skill source root is not a directory: {self._root}")
120
+ documents: dict[str, str] = {}
121
+ paths: dict[str, Path] = {}
122
+ for path in sorted((*self._root.glob("*.yaml"), *self._root.glob("*.yml"))):
123
+ text = self._safe(path).read_text(encoding="utf-8")
124
+ documents[path.name] = text
125
+ if match := _SKILL_ID_LINE.search(text):
126
+ paths.setdefault(match.group(1), path)
127
+ return documents, paths
128
+
129
+ async def save(self, skill: Skill) -> None:
130
+ """Atomically write the Skill document."""
131
+ destination = self._paths.get(skill.skill_id, self._root / f"{skill.skill_id}.yaml")
132
+ try:
133
+ await asyncio.to_thread(self._save_sync, destination, dump_skill_yaml(skill))
134
+ except OSError as error:
135
+ raise SkillSourceError(f"cannot persist Skill {skill.skill_id!r}") from error
136
+ self._paths[skill.skill_id] = destination
137
+
138
+ def _save_sync(self, destination: Path, serialized: str) -> None:
139
+ self._root.mkdir(parents=True, exist_ok=True)
140
+ if destination.exists():
141
+ self._safe(destination)
142
+ temporary = NamedTemporaryFile( # noqa: SIM115 - cleanup must also cover __enter__ failures
143
+ mode="w",
144
+ encoding="utf-8",
145
+ dir=self._root,
146
+ prefix=".skill-",
147
+ suffix=".tmp",
148
+ delete=False,
149
+ )
150
+ temporary_path = Path(temporary.name)
151
+ try:
152
+ with temporary:
153
+ temporary.write(serialized)
154
+ temporary.flush()
155
+ os.fsync(temporary.fileno())
156
+ temporary_path.replace(destination)
157
+ finally:
158
+ temporary_path.unlink(missing_ok=True)
159
+
160
+ async def delete(self, skill_id: str) -> None:
161
+ """Delete the Skill document, if present."""
162
+ if not is_skill_id(skill_id):
163
+ raise SkillSourceError("invalid Skill ID for filesystem deletion")
164
+ path = self._paths.pop(skill_id, self._root / f"{skill_id}.yaml")
165
+ try:
166
+ await asyncio.to_thread(self._delete_sync, path)
167
+ except OSError as error:
168
+ raise SkillSourceError(f"cannot delete Skill {skill_id!r}") from error
169
+
170
+ def _delete_sync(self, path: Path) -> None:
171
+ if path.exists() or path.is_symlink():
172
+ self._safe(path).unlink()
173
+
174
+
175
+ __all__ = ["FilesystemSkillSource", "SkillSource", "validate_source_id"]