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,362 @@
1
+ """Conformance tests for `SkillSource` and `SkillRegistry` implementations.
2
+
3
+ Subclass a contract in a test module and implement its factory; your test
4
+ runner collects one test per promise of the contract:
5
+
6
+ ```python
7
+ from skill_manager.testing import SkillRegistryContract
8
+
9
+
10
+ class TestMyRegistry(SkillRegistryContract):
11
+ async def make_registry(self):
12
+ registry = MyRegistry(dsn)
13
+ await registry.truncate() # every test starts from an empty catalog
14
+ return registry
15
+ ```
16
+
17
+ Each test calls ``aclose`` on the object it made. The tests are plain
18
+ synchronous methods that raise `AssertionError` with a descriptive message, so
19
+ they run under pytest, or under unittest when the subclass also inherits
20
+ ``unittest.TestCase``. Neither pytest nor an async plugin is required. Set
21
+ ``loop_factory`` to choose the event loop, for example
22
+ ``asyncio.SelectorEventLoop`` for psycopg on Windows.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ from abc import ABC, abstractmethod
29
+ from typing import TYPE_CHECKING, Any, ClassVar
30
+
31
+ from skill_manager.errors import SkillConflictError, SkillNotFoundError
32
+ from skill_manager.models import (
33
+ Skill,
34
+ SkillLifecycle,
35
+ SkillLifecycleState,
36
+ SkillMetadata,
37
+ )
38
+ from skill_manager.validation import parse_skill_yaml
39
+
40
+ if TYPE_CHECKING:
41
+ from collections.abc import Awaitable, Callable, Coroutine
42
+
43
+ from skill_manager.registry import SkillRegistry
44
+ from skill_manager.sources import SkillSource
45
+
46
+ __all__ = ["SkillRegistryContract", "SkillSourceContract"]
47
+
48
+
49
+ def _skill(
50
+ name: str = "contract-skill",
51
+ version: str = "1.0.0",
52
+ *,
53
+ state: SkillLifecycleState = SkillLifecycleState.ACTIVE,
54
+ ) -> Skill:
55
+ return Skill(
56
+ metadata=SkillMetadata(name=name, version=version, description="Contract Skill."),
57
+ instructions=("Follow the contract.",),
58
+ lifecycle=SkillLifecycle(state=state),
59
+ )
60
+
61
+
62
+ def _check(condition: bool, message: str) -> None:
63
+ if not condition:
64
+ raise AssertionError(message)
65
+
66
+
67
+ async def _raises(error: type[Exception], operation: Awaitable[Any], message: str) -> None:
68
+ try:
69
+ await operation
70
+ except error:
71
+ return
72
+ raise AssertionError(message)
73
+
74
+
75
+ class _Contract:
76
+ loop_factory: ClassVar[Callable[[], asyncio.AbstractEventLoop] | None] = None
77
+
78
+ def _run(self, coroutine: Coroutine[Any, Any, None]) -> None:
79
+ asyncio.run(coroutine, loop_factory=self.loop_factory)
80
+
81
+
82
+ class SkillSourceContract(_Contract, ABC):
83
+ """Promises every `SkillSource` keeps."""
84
+
85
+ @abstractmethod
86
+ async def make_source(self) -> SkillSource:
87
+ """Return a new, empty source."""
88
+
89
+ def _with_source(self, test: Callable[[SkillSource], Awaitable[None]]) -> None:
90
+ async def run() -> None:
91
+ source = await self.make_source()
92
+ try:
93
+ await test(source)
94
+ finally:
95
+ await source.aclose()
96
+
97
+ self._run(run())
98
+
99
+ def test_source_id_is_non_empty(self) -> None:
100
+ """The source has a non-blank identifier."""
101
+
102
+ async def test(source: SkillSource) -> None:
103
+ _check(bool(source.source_id.strip()), "source_id must not be empty")
104
+
105
+ self._with_source(test)
106
+
107
+ def test_empty_source_loads_nothing(self) -> None:
108
+ """A new source loads no documents."""
109
+
110
+ async def test(source: SkillSource) -> None:
111
+ _check(not await source.load(), "a new source must load no documents")
112
+
113
+ self._with_source(test)
114
+
115
+ def test_saved_skill_round_trips(self) -> None:
116
+ """A saved Skill loads back as an equal Skill."""
117
+
118
+ async def test(source: SkillSource) -> None:
119
+ skill = _skill()
120
+ await source.save(skill)
121
+ documents = list((await source.load()).values())
122
+ _check(len(documents) == 1, "load must return exactly the saved document")
123
+ _check(
124
+ await parse_skill_yaml(documents[0]) == skill,
125
+ "the loaded document must parse back to the saved Skill",
126
+ )
127
+
128
+ self._with_source(test)
129
+
130
+ def test_save_replaces_the_previous_document(self) -> None:
131
+ """Saving a Skill again replaces its document."""
132
+
133
+ async def test(source: SkillSource) -> None:
134
+ pending = _skill(state=SkillLifecycleState.PENDING_APPROVAL)
135
+ active = pending.model_copy(update={"lifecycle": SkillLifecycle()})
136
+ await source.save(pending)
137
+ await source.save(active)
138
+ documents = list((await source.load()).values())
139
+ _check(len(documents) == 1, "saving the same Skill twice must keep one document")
140
+ _check(
141
+ await parse_skill_yaml(documents[0]) == active,
142
+ "the latest save must win",
143
+ )
144
+
145
+ self._with_source(test)
146
+
147
+ def test_delete_removes_the_document(self) -> None:
148
+ """Delete removes only the given Skill."""
149
+
150
+ async def test(source: SkillSource) -> None:
151
+ kept, deleted = _skill("kept-skill"), _skill("deleted-skill")
152
+ await source.save(kept)
153
+ await source.save(deleted)
154
+ await source.delete(deleted.skill_id)
155
+ documents = [await parse_skill_yaml(text) for text in (await source.load()).values()]
156
+ _check(documents == [kept], "delete must remove only the given Skill")
157
+
158
+ self._with_source(test)
159
+
160
+ def test_delete_ignores_unknown_ids(self) -> None:
161
+ """Deleting an unknown Skill is a no-op."""
162
+
163
+ async def test(source: SkillSource) -> None:
164
+ await source.delete(_skill().skill_id)
165
+
166
+ self._with_source(test)
167
+
168
+
169
+ class SkillRegistryContract(_Contract, ABC):
170
+ """Promises every `SkillRegistry` keeps."""
171
+
172
+ @abstractmethod
173
+ async def make_registry(self) -> SkillRegistry:
174
+ """Return a new, empty registry."""
175
+
176
+ def _with_registry(self, test: Callable[[SkillRegistry], Awaitable[None]]) -> None:
177
+ async def run() -> None:
178
+ registry = await self.make_registry()
179
+ try:
180
+ await test(registry)
181
+ finally:
182
+ await registry.aclose()
183
+
184
+ self._run(run())
185
+
186
+ def test_put_then_get(self) -> None:
187
+ """A stored Skill is returned by ``get``."""
188
+
189
+ async def test(registry: SkillRegistry) -> None:
190
+ skill = _skill()
191
+ await registry.put(skill)
192
+ _check(await registry.get(skill.skill_id) == skill, "get must return the stored Skill")
193
+
194
+ self._with_registry(test)
195
+
196
+ def test_get_unknown_raises_not_found(self) -> None:
197
+ """``get`` raises `SkillNotFoundError` for an unknown ID."""
198
+
199
+ async def test(registry: SkillRegistry) -> None:
200
+ await _raises(
201
+ SkillNotFoundError,
202
+ registry.get(_skill().skill_id),
203
+ "get must raise SkillNotFoundError for an unknown ID",
204
+ )
205
+
206
+ self._with_registry(test)
207
+
208
+ def test_identical_put_is_idempotent(self) -> None:
209
+ """Storing the same Skill twice keeps one copy."""
210
+
211
+ async def test(registry: SkillRegistry) -> None:
212
+ skill = _skill()
213
+ await registry.put(skill)
214
+ await registry.put(skill)
215
+ _check(len(await registry.all()) == 1, "an identical put must not duplicate")
216
+
217
+ self._with_registry(test)
218
+
219
+ def test_revision_is_immutable(self) -> None:
220
+ """A stored revision cannot change content."""
221
+
222
+ async def test(registry: SkillRegistry) -> None:
223
+ skill = _skill()
224
+ await registry.put(skill)
225
+ changed = skill.model_copy(update={"instructions": ("Changed.",)})
226
+ await _raises(
227
+ SkillConflictError,
228
+ registry.put(changed),
229
+ "rewriting a revision with different content must conflict",
230
+ )
231
+
232
+ self._with_registry(test)
233
+
234
+ def test_stale_revision_conflicts(self) -> None:
235
+ """An older revision cannot replace a newer one."""
236
+
237
+ async def test(registry: SkillRegistry) -> None:
238
+ skill = _skill()
239
+ await registry.put(skill.model_copy(update={"revision": 2}))
240
+ await _raises(
241
+ SkillConflictError, registry.put(skill), "an older revision must conflict"
242
+ )
243
+
244
+ self._with_registry(test)
245
+
246
+ def test_one_active_revision_per_name_and_version(self) -> None:
247
+ """Only one revision per name and version is ACTIVE."""
248
+
249
+ async def test(registry: SkillRegistry) -> None:
250
+ await registry.put(_skill())
251
+ await _raises(
252
+ SkillConflictError,
253
+ registry.put(_skill()),
254
+ "a second ACTIVE revision of the same name and version must conflict",
255
+ )
256
+ await _raises(
257
+ SkillConflictError,
258
+ registry.check_put(_skill()),
259
+ "check_put must report the same conflict",
260
+ )
261
+ await registry.put(_skill(state=SkillLifecycleState.PENDING_APPROVAL))
262
+
263
+ self._with_registry(test)
264
+
265
+ def test_check_put_does_not_write(self) -> None:
266
+ """``check_put`` validates without storing."""
267
+
268
+ async def test(registry: SkillRegistry) -> None:
269
+ await registry.check_put(_skill())
270
+ _check(not await registry.all(), "check_put must not store the Skill")
271
+
272
+ self._with_registry(test)
273
+
274
+ def test_by_name_returns_highest_active_version(self) -> None:
275
+ """``by_name`` resolves the highest ACTIVE version."""
276
+
277
+ async def test(registry: SkillRegistry) -> None:
278
+ old, new = _skill(version="1.2.0"), _skill(version="1.10.0")
279
+ await registry.put(old)
280
+ await registry.put(new)
281
+ await registry.put(_skill(version="2.0.0", state=SkillLifecycleState.REJECTED))
282
+ _check(await registry.by_name(old.metadata.name) == new, "by_name must pick 1.10.0")
283
+ _check(
284
+ await registry.by_name(old.metadata.name, "1.2.0") == old,
285
+ "by_name with a version must return that version",
286
+ )
287
+ await _raises(
288
+ SkillNotFoundError,
289
+ registry.by_name(old.metadata.name, "2.0.0"),
290
+ "by_name must ignore inactive revisions",
291
+ )
292
+
293
+ self._with_registry(test)
294
+
295
+ def test_all_filters_by_state(self) -> None:
296
+ """``all`` returns every Skill, optionally filtered by state."""
297
+
298
+ async def test(registry: SkillRegistry) -> None:
299
+ active = _skill("active-skill")
300
+ pending = _skill("pending-skill", state=SkillLifecycleState.PENDING_APPROVAL)
301
+ await registry.put(active)
302
+ await registry.put(pending)
303
+ everything = {skill.skill_id: skill for skill in await registry.all()}
304
+ _check(
305
+ everything == {active.skill_id: active, pending.skill_id: pending},
306
+ "all must return everything",
307
+ )
308
+ _check(
309
+ await registry.all(states={SkillLifecycleState.PENDING_APPROVAL}) == (pending,),
310
+ "all must filter by state",
311
+ )
312
+
313
+ self._with_registry(test)
314
+
315
+ def test_remove(self) -> None:
316
+ """``remove`` deletes and returns the Skill."""
317
+
318
+ async def test(registry: SkillRegistry) -> None:
319
+ skill = _skill()
320
+ await registry.put(skill)
321
+ _check(await registry.remove(skill.skill_id) == skill, "remove must return the Skill")
322
+ await _raises(
323
+ SkillNotFoundError,
324
+ registry.remove(skill.skill_id),
325
+ "remove must raise SkillNotFoundError for an unknown ID",
326
+ )
327
+
328
+ self._with_registry(test)
329
+
330
+ def test_replace_lifecycle(self) -> None:
331
+ """``replace_lifecycle`` persists a lifecycle transition."""
332
+
333
+ async def test(registry: SkillRegistry) -> None:
334
+ pending = _skill(state=SkillLifecycleState.PENDING_APPROVAL)
335
+ await registry.put(pending)
336
+ active = pending.model_copy(update={"lifecycle": SkillLifecycle()})
337
+ await registry.replace_lifecycle(active)
338
+ _check(await registry.get(pending.skill_id) == active, "the transition must persist")
339
+ await _raises(
340
+ SkillNotFoundError,
341
+ registry.replace_lifecycle(_skill("unknown-skill")),
342
+ "replace_lifecycle must raise SkillNotFoundError for an unknown ID",
343
+ )
344
+
345
+ self._with_registry(test)
346
+
347
+ def test_replace_lifecycle_keeps_one_active_revision(self) -> None:
348
+ """Activation cannot create a second ACTIVE revision."""
349
+
350
+ async def test(registry: SkillRegistry) -> None:
351
+ await registry.put(_skill())
352
+ pending = _skill(state=SkillLifecycleState.PENDING_APPROVAL)
353
+ await registry.put(pending)
354
+ await _raises(
355
+ SkillConflictError,
356
+ registry.replace_lifecycle(
357
+ pending.model_copy(update={"lifecycle": SkillLifecycle()})
358
+ ),
359
+ "activating a second revision must conflict",
360
+ )
361
+
362
+ self._with_registry(test)
@@ -0,0 +1,95 @@
1
+ """Safe YAML parsing and Skill domain validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from abc import ABC, abstractmethod
7
+ from typing import Any
8
+
9
+ import yaml
10
+ from pydantic import ValidationError
11
+
12
+ from skill_manager.errors import SkillValidationError
13
+ from skill_manager.models import Skill, SkillLifecycleState
14
+
15
+
16
+ class SkillValidator(ABC):
17
+ """Extension point for application-specific Skill validation.
18
+
19
+ `SkillManager` validates every Skill it registers or loads. Subclass
20
+ `DefaultSkillValidator` and call ``super().validate`` to keep the built-in
21
+ invariants while adding your own.
22
+
23
+ Example:
24
+ ```python
25
+ class RequireTags(DefaultSkillValidator):
26
+ async def validate(self, skill):
27
+ skill = await super().validate(skill)
28
+ if not skill.metadata.tags:
29
+ raise SkillValidationError("every Skill needs at least one tag")
30
+ return skill
31
+ ```
32
+ """
33
+
34
+ @abstractmethod
35
+ async def validate(self, skill: Skill) -> Skill:
36
+ """Validate and return a normalized Skill.
37
+
38
+ Raises:
39
+ SkillValidationError: If the Skill violates a rule.
40
+ """
41
+
42
+
43
+ class DefaultSkillValidator(SkillValidator):
44
+ """Enforce lifecycle and governance invariants beyond the model schema."""
45
+
46
+ async def validate(self, skill: Skill) -> Skill:
47
+ """Reject an ``ACTIVE`` Skill that carries a rejection event."""
48
+ if (
49
+ skill.lifecycle.state is SkillLifecycleState.ACTIVE
50
+ and skill.governance.rejection_event_id
51
+ ):
52
+ raise SkillValidationError("a rejected Skill revision cannot be active")
53
+ return skill
54
+
55
+
56
+ def _load_mapping(content: str) -> dict[str, Any]:
57
+ try:
58
+ value: Any = yaml.safe_load(content)
59
+ except yaml.YAMLError as error:
60
+ raise SkillValidationError("malformed Skill YAML") from error
61
+ if not isinstance(value, dict):
62
+ raise SkillValidationError("a Skill YAML document must be a mapping")
63
+ return value
64
+
65
+
66
+ async def parse_skill_yaml(content: str, *, max_bytes: int = 1_000_000) -> Skill:
67
+ """Parse one Skill YAML document without executing any of it.
68
+
69
+ Parsing runs in a worker thread so large documents never block the event loop.
70
+
71
+ Args:
72
+ content: The YAML text.
73
+ max_bytes: Largest accepted UTF-8 size.
74
+
75
+ Returns:
76
+ The schema-valid Skill; run a `SkillValidator` afterwards for policy checks.
77
+
78
+ Raises:
79
+ SkillValidationError: If the document is too large, malformed, lacks an
80
+ explicit ``skill_id``, or does not match the Skill schema.
81
+ """
82
+ if len(content.encode()) > max_bytes:
83
+ raise SkillValidationError(f"Skill YAML exceeds the {max_bytes}-byte document limit")
84
+ value = await asyncio.to_thread(_load_mapping, content)
85
+ if "skill_id" not in value:
86
+ raise SkillValidationError("skill_id is required in persisted Skill YAML")
87
+ try:
88
+ return Skill.model_validate(value)
89
+ except ValidationError as error:
90
+ raise SkillValidationError("Skill YAML does not match Skill schema version 1.0") from error
91
+
92
+
93
+ def dump_skill_yaml(skill: Skill) -> str:
94
+ """Serialize a Skill to the canonical YAML document format."""
95
+ return yaml.safe_dump(skill.model_dump(mode="json"), allow_unicode=True, sort_keys=True)