agentskills-testing 0.4.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,60 @@
1
+ """Conformance suite and test doubles for Agent Skills providers.
2
+
3
+ Two things live here:
4
+
5
+ * :class:`ProviderConformanceSuite` — subclass it, supply a ``provider``
6
+ fixture, and pytest runs the whole provider contract against your
7
+ implementation.
8
+ * :class:`InMemorySkillProvider` — a real, spec-compliant provider
9
+ backed by a dict, for tests that need a skill but not a disk.
10
+
11
+ See https://github.com/pratikxpanda/agentskills-sdk for the guide.
12
+ """
13
+
14
+ from agentskills_testing.conformance import (
15
+ ASSET_BYTES,
16
+ ASSET_NAME,
17
+ CONTRACT,
18
+ MISSING_ID,
19
+ MISSING_RESOURCE,
20
+ REFERENCE_BYTES,
21
+ REFERENCE_NAME,
22
+ SCRIPT_BYTES,
23
+ SCRIPT_NAME,
24
+ SKILL_ID,
25
+ TRAVERSAL_IDENTIFIERS,
26
+ ContentLimitConformanceSuite,
27
+ ProviderConformanceSuite,
28
+ )
29
+ from agentskills_testing.memory import (
30
+ DEFAULT_BODY,
31
+ DEFAULT_DESCRIPTION,
32
+ DEFAULT_SKILL_ID,
33
+ InMemorySkill,
34
+ InMemorySkillProvider,
35
+ build_skill,
36
+ render_skill_md,
37
+ )
38
+
39
+ __all__ = [
40
+ "ASSET_BYTES",
41
+ "ASSET_NAME",
42
+ "CONTRACT",
43
+ "DEFAULT_BODY",
44
+ "DEFAULT_DESCRIPTION",
45
+ "DEFAULT_SKILL_ID",
46
+ "MISSING_ID",
47
+ "MISSING_RESOURCE",
48
+ "REFERENCE_BYTES",
49
+ "REFERENCE_NAME",
50
+ "SCRIPT_BYTES",
51
+ "SCRIPT_NAME",
52
+ "SKILL_ID",
53
+ "TRAVERSAL_IDENTIFIERS",
54
+ "ContentLimitConformanceSuite",
55
+ "InMemorySkill",
56
+ "InMemorySkillProvider",
57
+ "ProviderConformanceSuite",
58
+ "build_skill",
59
+ "render_skill_md",
60
+ ]
@@ -0,0 +1,326 @@
1
+ """The provider conformance suite.
2
+
3
+ ``SkillProvider`` is an ABC, which enforces that five methods exist and
4
+ nothing about what they do. The requirements that actually matter are
5
+ the ones an ABC cannot express: that an unknown ID raises the documented
6
+ exception rather than returning ``None``, that a traversal-shaped
7
+ identifier is refused rather than resolved, and that a provider
8
+ advertising resource listing or discovery can actually deliver it.
9
+
10
+ Subclass :class:`ProviderConformanceSuite`, supply a ``provider``
11
+ fixture populated per :data:`CONTRACT`, and pytest collects the whole
12
+ suite against your implementation.
13
+
14
+ Every test is marked ``asyncio`` explicitly so the suite runs under
15
+ ``pytest-asyncio`` in either strict or auto mode — a kit that only works
16
+ under one project's configuration is not a kit.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+
23
+ import pytest
24
+
25
+ from agentskills_core import (
26
+ RESOURCE_KINDS,
27
+ AgentSkillsError,
28
+ DiscoveryNotSupportedError,
29
+ ResourceListingNotSupportedError,
30
+ ResourceNotFoundError,
31
+ SkillNotFoundError,
32
+ SkillProvider,
33
+ )
34
+
35
+ #: The skill a conforming ``provider`` fixture must expose.
36
+ SKILL_ID = "conformance-skill"
37
+
38
+ #: The reference document that skill must contain, and its exact bytes.
39
+ REFERENCE_NAME = "notes.md"
40
+ REFERENCE_BYTES = b"# Notes\n\nA reference document.\n"
41
+
42
+ #: The script that skill must contain, and its exact bytes.
43
+ SCRIPT_NAME = "run.sh"
44
+ SCRIPT_BYTES = b"#!/bin/sh\necho conformance\n"
45
+
46
+ #: The asset that skill must contain, and its exact bytes.
47
+ ASSET_NAME = "diagram.svg"
48
+ ASSET_BYTES = b"<svg></svg>\n"
49
+
50
+ #: An ID no conforming fixture may define.
51
+ MISSING_ID = "no-such-skill-anywhere"
52
+
53
+ #: A resource name no conforming fixture may define.
54
+ MISSING_RESOURCE = "no-such-resource.txt"
55
+
56
+ CONTRACT = f"""\
57
+ The `provider` fixture must expose exactly one skill:
58
+
59
+ id {SKILL_ID}
60
+ metadata name == {SKILL_ID!r}, plus a non-empty description
61
+ body non-empty markdown
62
+ references/ {REFERENCE_NAME} containing {REFERENCE_BYTES!r}
63
+ scripts/ {SCRIPT_NAME} containing {SCRIPT_BYTES!r}
64
+ assets/ {ASSET_NAME} containing {ASSET_BYTES!r}
65
+
66
+ It must not define a skill called {MISSING_ID!r} or any resource called
67
+ {MISSING_RESOURCE!r}. A provider that sets `supports_discovery` must
68
+ report exactly one skill, {SKILL_ID!r}.
69
+ """
70
+
71
+ #: Identifiers a provider must refuse rather than resolve. Each is a
72
+ #: real technique: parent traversal, an absolute path, a Windows path, a
73
+ #: percent-encoded traversal that a URL-building backend may decode, and
74
+ #: a NUL byte that truncates a C string.
75
+ TRAVERSAL_IDENTIFIERS = (
76
+ "../etc/passwd",
77
+ "..",
78
+ "a/../../b",
79
+ "/etc/passwd",
80
+ "..\\..\\windows\\system32",
81
+ "%2e%2e%2fetc%2fpasswd",
82
+ "ok\x00.md",
83
+ )
84
+
85
+ _RESOURCE_GETTERS = {
86
+ "references": "get_reference",
87
+ "scripts": "get_script",
88
+ "assets": "get_asset",
89
+ }
90
+
91
+ _EXPECTED_RESOURCES = {
92
+ "references": (REFERENCE_NAME, REFERENCE_BYTES),
93
+ "scripts": (SCRIPT_NAME, SCRIPT_BYTES),
94
+ "assets": (ASSET_NAME, ASSET_BYTES),
95
+ }
96
+
97
+
98
+ class ProviderConformanceSuite:
99
+ """Assertions every :class:`~agentskills_core.SkillProvider` must satisfy.
100
+
101
+ Subclass it and provide a ``provider`` fixture::
102
+
103
+ class TestMyProvider(ProviderConformanceSuite):
104
+ @pytest.fixture
105
+ def provider(self):
106
+ return MyProvider(...)
107
+
108
+ The fixture must be populated per :data:`CONTRACT`. Nothing here is
109
+ opt-out: a provider that cannot pass these is not a provider, it is
110
+ a class with the right method names.
111
+ """
112
+
113
+ @pytest.fixture
114
+ def provider(self) -> SkillProvider:
115
+ """Return the provider under test, populated per :data:`CONTRACT`."""
116
+ raise NotImplementedError(f"Override the 'provider' fixture.\n\n{CONTRACT}")
117
+
118
+ # -- Identity -------------------------------------------------------
119
+
120
+ def test_is_a_skill_provider(self, provider: SkillProvider) -> None:
121
+ assert isinstance(provider, SkillProvider)
122
+
123
+ # -- Metadata and body ---------------------------------------------
124
+
125
+ @pytest.mark.asyncio
126
+ async def test_metadata_has_the_required_fields(self, provider: SkillProvider) -> None:
127
+ metadata = await provider.get_metadata(SKILL_ID)
128
+
129
+ assert isinstance(metadata, dict)
130
+ assert metadata.get("name") == SKILL_ID
131
+ assert isinstance(metadata.get("description"), str)
132
+ assert metadata["description"].strip()
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_metadata_excludes_the_body(self, provider: SkillProvider) -> None:
136
+ metadata = await provider.get_metadata(SKILL_ID)
137
+ body = await provider.get_body(SKILL_ID)
138
+
139
+ assert body.strip()
140
+ # Progressive disclosure is the whole point: a metadata call that
141
+ # carries the body has already spent the tokens it exists to save.
142
+ assert all(value != body for value in metadata.values())
143
+
144
+ @pytest.mark.asyncio
145
+ async def test_metadata_is_not_shared_mutable_state(self, provider: SkillProvider) -> None:
146
+ first = await provider.get_metadata(SKILL_ID)
147
+ first["description"] = "mutated by a caller"
148
+
149
+ second = await provider.get_metadata(SKILL_ID)
150
+
151
+ assert second["description"] != "mutated by a caller"
152
+
153
+ @pytest.mark.asyncio
154
+ async def test_repeated_reads_agree(self, provider: SkillProvider) -> None:
155
+ # A provider that streams without buffering passes once and then
156
+ # returns empty. Caching bugs surface the same way.
157
+ assert await provider.get_body(SKILL_ID) == await provider.get_body(SKILL_ID)
158
+ assert await provider.get_metadata(SKILL_ID) == await provider.get_metadata(SKILL_ID)
159
+
160
+ @pytest.mark.asyncio
161
+ async def test_unknown_skill_raises_skill_not_found(self, provider: SkillProvider) -> None:
162
+ with pytest.raises(SkillNotFoundError):
163
+ await provider.get_metadata(MISSING_ID)
164
+ with pytest.raises(SkillNotFoundError):
165
+ await provider.get_body(MISSING_ID)
166
+
167
+ # -- Resources ------------------------------------------------------
168
+
169
+ @pytest.mark.parametrize("kind", RESOURCE_KINDS)
170
+ @pytest.mark.asyncio
171
+ async def test_resource_returns_exact_bytes(self, provider: SkillProvider, kind: str) -> None:
172
+ name, expected = _EXPECTED_RESOURCES[kind]
173
+
174
+ data = await getattr(provider, _RESOURCE_GETTERS[kind])(SKILL_ID, name)
175
+
176
+ # bytes, not str: a resource may be a PNG, and decoding it on the
177
+ # way out makes that unreachable.
178
+ assert isinstance(data, bytes)
179
+ assert data == expected
180
+
181
+ @pytest.mark.parametrize("kind", RESOURCE_KINDS)
182
+ @pytest.mark.asyncio
183
+ async def test_unknown_resource_raises_resource_not_found(
184
+ self, provider: SkillProvider, kind: str
185
+ ) -> None:
186
+ with pytest.raises(ResourceNotFoundError):
187
+ await getattr(provider, _RESOURCE_GETTERS[kind])(SKILL_ID, MISSING_RESOURCE)
188
+
189
+ @pytest.mark.parametrize("kind", RESOURCE_KINDS)
190
+ @pytest.mark.asyncio
191
+ async def test_resource_of_unknown_skill_is_refused(
192
+ self, provider: SkillProvider, kind: str
193
+ ) -> None:
194
+ name, _ = _EXPECTED_RESOURCES[kind]
195
+
196
+ with pytest.raises((SkillNotFoundError, ResourceNotFoundError)):
197
+ await getattr(provider, _RESOURCE_GETTERS[kind])(MISSING_ID, name)
198
+
199
+ # -- Resource listing ----------------------------------------------
200
+
201
+ @pytest.mark.asyncio
202
+ async def test_listing_matches_the_declared_capability(self, provider: SkillProvider) -> None:
203
+ # The flag is what callers branch on, so a provider whose flag
204
+ # and behaviour disagree breaks them whichever way it lies.
205
+ if provider.supports_resource_listing:
206
+ try:
207
+ listing = await provider.list_resources(SKILL_ID)
208
+ except ResourceListingNotSupportedError as exc:
209
+ pytest.fail(
210
+ f"supports_resource_listing is True but list_resources() refused to list: {exc}"
211
+ )
212
+ assert set(listing) == set(RESOURCE_KINDS)
213
+ for kind in RESOURCE_KINDS:
214
+ assert listing[kind] == sorted(listing[kind])
215
+ expected_name, _ = _EXPECTED_RESOURCES[kind]
216
+ assert expected_name in listing[kind]
217
+ else:
218
+ with pytest.raises(ResourceListingNotSupportedError):
219
+ await provider.list_resources(SKILL_ID)
220
+
221
+ @pytest.mark.asyncio
222
+ async def test_listing_of_unknown_skill_is_refused(self, provider: SkillProvider) -> None:
223
+ if not provider.supports_resource_listing:
224
+ pytest.skip("provider does not support resource listing")
225
+
226
+ with pytest.raises(SkillNotFoundError):
227
+ await provider.list_resources(MISSING_ID)
228
+
229
+ # -- Skill discovery ------------------------------------------------
230
+
231
+ @pytest.mark.asyncio
232
+ async def test_discovery_matches_the_declared_capability(self, provider: SkillProvider) -> None:
233
+ if provider.supports_discovery:
234
+ try:
235
+ skill_ids = await provider.discover()
236
+ except DiscoveryNotSupportedError as exc:
237
+ pytest.fail(f"supports_discovery is True but discover() refused: {exc}")
238
+ assert isinstance(skill_ids, list)
239
+ # The contract fixture holds exactly one skill, so this also
240
+ # pins sortedness and the absence of duplicates.
241
+ assert skill_ids == [SKILL_ID]
242
+ else:
243
+ with pytest.raises(DiscoveryNotSupportedError):
244
+ await provider.discover()
245
+
246
+ @pytest.mark.asyncio
247
+ async def test_discovered_skills_are_readable(self, provider: SkillProvider) -> None:
248
+ if not provider.supports_discovery:
249
+ pytest.skip("provider does not support discovery")
250
+
251
+ # register_all() validates everything discover() returns, so an ID
252
+ # that cannot be read poisons the whole registration.
253
+ for skill_id in await provider.discover():
254
+ assert await provider.get_metadata(skill_id)
255
+
256
+ # -- Security -------------------------------------------------------
257
+
258
+ @pytest.mark.parametrize("identifier", TRAVERSAL_IDENTIFIERS)
259
+ @pytest.mark.asyncio
260
+ async def test_traversal_skill_id_is_refused(
261
+ self, provider: SkillProvider, identifier: str
262
+ ) -> None:
263
+ with pytest.raises(SkillNotFoundError):
264
+ await provider.get_metadata(identifier)
265
+
266
+ @pytest.mark.parametrize("identifier", TRAVERSAL_IDENTIFIERS)
267
+ @pytest.mark.parametrize("kind", RESOURCE_KINDS)
268
+ @pytest.mark.asyncio
269
+ async def test_traversal_resource_name_is_refused(
270
+ self, provider: SkillProvider, kind: str, identifier: str
271
+ ) -> None:
272
+ with pytest.raises(ResourceNotFoundError):
273
+ await getattr(provider, _RESOURCE_GETTERS[kind])(SKILL_ID, identifier)
274
+
275
+ # -- Concurrency ----------------------------------------------------
276
+
277
+ @pytest.mark.asyncio
278
+ async def test_reads_are_concurrency_safe(self, provider: SkillProvider) -> None:
279
+ # Twenty overlapping reads through one provider instance. A
280
+ # provider holding per-call state on self returns the wrong
281
+ # skill's content here and nowhere else.
282
+ bodies = await asyncio.gather(*(provider.get_body(SKILL_ID) for _ in range(20)))
283
+
284
+ assert len(set(bodies)) == 1
285
+
286
+
287
+ class ContentLimitConformanceSuite:
288
+ """Assertions for providers that read bytes they did not author.
289
+
290
+ A size limit is not part of the universal contract — an in-memory
291
+ provider has nothing to bound, and demanding one would be asserting a
292
+ filesystem's constraints against a dict. It *is* required of any
293
+ provider reading from disk, a network, or anything else a caller can
294
+ grow without asking.
295
+
296
+ Supply a ``limited_provider`` fixture whose limit is small enough
297
+ that the fixture's own content exceeds it::
298
+
299
+ class TestMyProviderLimits(ContentLimitConformanceSuite):
300
+ @pytest.fixture
301
+ def limited_provider(self):
302
+ return MyProvider(..., max_bytes=8)
303
+ """
304
+
305
+ @pytest.fixture
306
+ def limited_provider(self) -> SkillProvider:
307
+ """Return a provider whose size limit the contract skill exceeds."""
308
+ raise NotImplementedError(
309
+ "Override the 'limited_provider' fixture with a provider whose "
310
+ "size limit is smaller than the contract skill."
311
+ )
312
+
313
+ @pytest.mark.asyncio
314
+ async def test_oversized_skill_md_is_refused(self, limited_provider: SkillProvider) -> None:
315
+ with pytest.raises(AgentSkillsError):
316
+ await limited_provider.get_metadata(SKILL_ID)
317
+
318
+ @pytest.mark.parametrize("kind", RESOURCE_KINDS)
319
+ @pytest.mark.asyncio
320
+ async def test_oversized_resource_is_refused(
321
+ self, limited_provider: SkillProvider, kind: str
322
+ ) -> None:
323
+ name, _ = _EXPECTED_RESOURCES[kind]
324
+
325
+ with pytest.raises(AgentSkillsError):
326
+ await getattr(limited_provider, _RESOURCE_GETTERS[kind])(SKILL_ID, name)
@@ -0,0 +1,45 @@
1
+ """Pytest fixtures, registered automatically as a plugin.
2
+
3
+ Installing ``agentskills-testing`` makes these available in any test
4
+ without an import or a ``conftest.py`` entry, via the ``pytest11``
5
+ entry point.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+ import pytest_asyncio
12
+
13
+ from agentskills_core import SkillRegistry
14
+ from agentskills_testing.memory import (
15
+ DEFAULT_SKILL_ID,
16
+ InMemorySkill,
17
+ InMemorySkillProvider,
18
+ build_skill,
19
+ )
20
+
21
+
22
+ @pytest.fixture
23
+ def sample_skill() -> InMemorySkill:
24
+ """A single valid skill with one resource of each kind."""
25
+ return build_skill(
26
+ DEFAULT_SKILL_ID,
27
+ version="1.0.0",
28
+ references={"severity-levels.md": b"# Severity\n\nSEV1 is customer-visible.\n"},
29
+ scripts={"page-oncall.sh": b"#!/bin/sh\necho paging\n"},
30
+ assets={"flowchart.mermaid": b"graph TD; A-->B\n"},
31
+ )
32
+
33
+
34
+ @pytest.fixture
35
+ def skill_provider(sample_skill: InMemorySkill) -> InMemorySkillProvider:
36
+ """An :class:`InMemorySkillProvider` holding :func:`sample_skill`."""
37
+ return InMemorySkillProvider({DEFAULT_SKILL_ID: sample_skill})
38
+
39
+
40
+ @pytest_asyncio.fixture
41
+ async def skill_registry(skill_provider: InMemorySkillProvider) -> SkillRegistry:
42
+ """A registry with :func:`skill_provider`'s skill already registered."""
43
+ registry = SkillRegistry()
44
+ await registry.register(DEFAULT_SKILL_ID, skill_provider)
45
+ return registry
@@ -0,0 +1,242 @@
1
+ """In-memory skill provider and content helpers.
2
+
3
+ A test that needs a skill should not need a directory or an HTTP server,
4
+ and it should not need an ``AsyncMock`` either: a mock agrees with
5
+ whatever the test asserts, including the assertions that are wrong.
6
+ :class:`InMemorySkillProvider` is a real provider that passes the same
7
+ conformance suite the filesystem and HTTP providers do.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ import yaml
17
+
18
+ from agentskills_core import (
19
+ RESOURCE_KINDS,
20
+ DiscoveryNotSupportedError,
21
+ ResourceListingNotSupportedError,
22
+ ResourceNotFoundError,
23
+ SkillNotFoundError,
24
+ SkillProvider,
25
+ get_logger,
26
+ )
27
+
28
+ _logger = get_logger(__name__)
29
+
30
+ DEFAULT_SKILL_ID = "incident-response"
31
+ DEFAULT_DESCRIPTION = "Diagnose and mitigate a production incident."
32
+ DEFAULT_BODY = "# Incident Response\n\nPage the on-call engineer, then open a channel.\n"
33
+
34
+ # A provider is addressed by skill ID and resource name, and both reach a
35
+ # backend that may treat them as a path segment. Anything that could
36
+ # escape one is refused before it gets that far.
37
+ _UNSAFE = ("..", "/", "\\", "\x00")
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class InMemorySkill:
42
+ """One skill's content, held in memory.
43
+
44
+ Attributes:
45
+ metadata: Parsed frontmatter. Must carry ``name`` and
46
+ ``description`` to satisfy the specification.
47
+ body: Markdown instructions.
48
+ references: Reference documents by filename.
49
+ scripts: Scripts by filename.
50
+ assets: Assets by filename.
51
+ """
52
+
53
+ metadata: dict[str, Any]
54
+ body: str = DEFAULT_BODY
55
+ references: dict[str, bytes] = field(default_factory=dict)
56
+ scripts: dict[str, bytes] = field(default_factory=dict)
57
+ assets: dict[str, bytes] = field(default_factory=dict)
58
+
59
+ def resources(self, kind: str) -> dict[str, bytes]:
60
+ """Return the resource mapping for *kind*."""
61
+ return {"references": self.references, "scripts": self.scripts, "assets": self.assets}[kind]
62
+
63
+
64
+ def build_skill(
65
+ skill_id: str = DEFAULT_SKILL_ID,
66
+ *,
67
+ description: str = DEFAULT_DESCRIPTION,
68
+ body: str = DEFAULT_BODY,
69
+ version: str | None = None,
70
+ metadata: Mapping[str, Any] | None = None,
71
+ references: Mapping[str, bytes] | None = None,
72
+ scripts: Mapping[str, bytes] | None = None,
73
+ assets: Mapping[str, bytes] | None = None,
74
+ ) -> InMemorySkill:
75
+ """Build a valid :class:`InMemorySkill`.
76
+
77
+ The defaults produce a skill that passes ``validate_skill()``, so a
78
+ test that does not care about content does not have to invent any.
79
+
80
+ Args:
81
+ skill_id: Becomes the ``name`` in the frontmatter.
82
+ description: Frontmatter description.
83
+ body: Markdown instructions.
84
+ version: Optional semver string.
85
+ metadata: Extra frontmatter, merged over the generated fields.
86
+ references: Reference documents by filename.
87
+ scripts: Scripts by filename.
88
+ assets: Assets by filename.
89
+
90
+ Returns:
91
+ A skill ready to hand to :class:`InMemorySkillProvider`.
92
+ """
93
+ frontmatter: dict[str, Any] = {"name": skill_id, "description": description}
94
+ if version is not None:
95
+ frontmatter["version"] = version
96
+ if metadata:
97
+ frontmatter.update(metadata)
98
+
99
+ return InMemorySkill(
100
+ metadata=frontmatter,
101
+ body=body,
102
+ references=dict(references or {}),
103
+ scripts=dict(scripts or {}),
104
+ assets=dict(assets or {}),
105
+ )
106
+
107
+
108
+ def render_skill_md(skill: InMemorySkill) -> str:
109
+ """Render *skill* as the ``SKILL.md`` text a file-backed provider would hold.
110
+
111
+ Useful for populating a temporary directory when a test needs the
112
+ filesystem provider rather than this one.
113
+ """
114
+ frontmatter = yaml.safe_dump(skill.metadata, sort_keys=False).strip()
115
+ return f"---\n{frontmatter}\n---\n\n{skill.body}"
116
+
117
+
118
+ class InMemorySkillProvider(SkillProvider):
119
+ """A spec-compliant :class:`~agentskills_core.SkillProvider` backed by a dict.
120
+
121
+ Args:
122
+ skills: Skills by ID. A plain string value is taken as the body
123
+ of a default skill, so the common case stays short.
124
+ supports_resource_listing: Set ``False`` to emulate a backend
125
+ that cannot enumerate, which is what the HTTP provider does
126
+ without a manifest.
127
+ supports_discovery: Set ``False`` to emulate a backend that
128
+ cannot list the skills it holds.
129
+
130
+ Example::
131
+
132
+ provider = InMemorySkillProvider({"incident-response": build_skill()})
133
+ registry = SkillRegistry()
134
+ await registry.register("incident-response", provider)
135
+ """
136
+
137
+ def __init__(
138
+ self,
139
+ skills: Mapping[str, InMemorySkill | str] | None = None,
140
+ *,
141
+ supports_resource_listing: bool = True,
142
+ supports_discovery: bool = True,
143
+ ) -> None:
144
+ self._skills: dict[str, InMemorySkill] = {}
145
+ self.supports_resource_listing = supports_resource_listing
146
+ self.supports_discovery = supports_discovery
147
+ for skill_id, skill in (skills or {}).items():
148
+ self.add(skill_id, skill)
149
+
150
+ def add(self, skill_id: str, skill: InMemorySkill | str | None = None) -> InMemorySkill:
151
+ """Add or replace a skill.
152
+
153
+ Args:
154
+ skill_id: ID to register the skill under.
155
+ skill: The skill, or a string taken as its body. Omit it for
156
+ a default skill named after *skill_id*.
157
+
158
+ Returns:
159
+ The stored skill.
160
+ """
161
+ if skill is None:
162
+ skill = build_skill(skill_id)
163
+ elif isinstance(skill, str):
164
+ skill = build_skill(skill_id, body=skill)
165
+ self._skills[skill_id] = skill
166
+ _logger.debug("Added in-memory skill %r", skill_id)
167
+ return skill
168
+
169
+ def skill_ids(self) -> list[str]:
170
+ """Return the registered skill IDs, sorted."""
171
+ return sorted(self._skills)
172
+
173
+ async def get_metadata(self, skill_id: str) -> dict[str, Any]:
174
+ """Return a copy of the skill's frontmatter."""
175
+ return dict(self._get(skill_id).metadata)
176
+
177
+ async def get_body(self, skill_id: str) -> str:
178
+ """Return the skill's markdown body."""
179
+ return self._get(skill_id).body
180
+
181
+ async def get_reference(self, skill_id: str, name: str) -> bytes:
182
+ """Return a reference document."""
183
+ return self._resource(skill_id, "references", name)
184
+
185
+ async def get_script(self, skill_id: str, name: str) -> bytes:
186
+ """Return a script."""
187
+ return self._resource(skill_id, "scripts", name)
188
+
189
+ async def get_asset(self, skill_id: str, name: str) -> bytes:
190
+ """Return an asset."""
191
+ return self._resource(skill_id, "assets", name)
192
+
193
+ async def list_resources(self, skill_id: str) -> dict[str, list[str]]:
194
+ """Return the skill's resource names, grouped by kind and sorted.
195
+
196
+ Raises:
197
+ ResourceListingNotSupportedError: If the provider was built
198
+ with ``supports_resource_listing=False``.
199
+ SkillNotFoundError: If the skill is unknown.
200
+ """
201
+ if not self.supports_resource_listing:
202
+ raise ResourceListingNotSupportedError(
203
+ "InMemorySkillProvider was built with resource listing disabled."
204
+ )
205
+ skill = self._get(skill_id)
206
+ return {kind: sorted(skill.resources(kind)) for kind in RESOURCE_KINDS}
207
+
208
+ async def discover(self) -> list[str]:
209
+ """Return the stored skill IDs, sorted.
210
+
211
+ Raises:
212
+ DiscoveryNotSupportedError: If the provider was built with
213
+ ``supports_discovery=False``.
214
+ """
215
+ if not self.supports_discovery:
216
+ raise DiscoveryNotSupportedError(
217
+ "InMemorySkillProvider was built with discovery disabled."
218
+ )
219
+ return self.skill_ids()
220
+
221
+ def _get(self, skill_id: str) -> InMemorySkill:
222
+ _reject_unsafe(skill_id, SkillNotFoundError, "skill_id")
223
+ try:
224
+ return self._skills[skill_id]
225
+ except KeyError:
226
+ raise SkillNotFoundError(f"Skill not found: {skill_id!r}") from None
227
+
228
+ def _resource(self, skill_id: str, kind: str, name: str) -> bytes:
229
+ skill = self._get(skill_id)
230
+ _reject_unsafe(name, ResourceNotFoundError, "resource name")
231
+ try:
232
+ return skill.resources(kind)[name]
233
+ except KeyError:
234
+ raise ResourceNotFoundError(
235
+ f"Resource {name!r} not found in {kind}/ for skill {skill_id!r}"
236
+ ) from None
237
+
238
+
239
+ def _reject_unsafe(value: str, error: type[Exception], label: str) -> None:
240
+ """Refuse an identifier that could escape its container."""
241
+ if not value or any(part in value for part in _UNSAFE):
242
+ raise error(f"Invalid {label}: {value!r}")
File without changes
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentskills-testing
3
+ Version: 0.4.0
4
+ Summary: Conformance suite and test doubles for Agent Skills providers (https://agentskills.io)
5
+ License: MIT
6
+ Author: Pratik Panda
7
+ Requires-Python: >=3.12,<4.0
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Framework :: Pytest
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Topic :: Software Development :: Testing
18
+ Requires-Dist: agentskills-core (>=0.4.0,<1.0)
19
+ Requires-Dist: pytest (>=8.0)
20
+ Requires-Dist: pytest-asyncio (>=0.24)
21
+ Requires-Dist: pyyaml (>=6.0,<7.0)
22
+ Project-URL: Homepage, https://agentskills.io
23
+ Project-URL: Repository, https://github.com/pratikxpanda/agentskills-sdk
24
+ Description-Content-Type: text/markdown
25
+
26
+ # agentskills-testing
27
+
28
+ Conformance suite and test doubles for [Agent Skills](https://agentskills.io) providers.
29
+
30
+ `SkillProvider` is an abstract base class, which enforces that five methods exist
31
+ and nothing whatsoever about what they do. The requirements that actually matter
32
+ are the ones an ABC cannot express: that an unknown skill ID raises
33
+ `SkillNotFoundError` rather than returning `{}`, that `../../etc/passwd` is
34
+ refused rather than resolved, and that a provider advertising resource listing
35
+ can actually list. This package turns those into tests you inherit.
36
+
37
+ ```bash
38
+ pip install agentskills-testing
39
+ ```
40
+
41
+ ## Conformance suite
42
+
43
+ Subclass `ProviderConformanceSuite`, supply a `provider` fixture, and pytest
44
+ collects the entire provider contract against your implementation:
45
+
46
+ ```python
47
+ import pytest
48
+
49
+ from agentskills_testing import ProviderConformanceSuite
50
+
51
+ from my_package import MyProvider
52
+
53
+
54
+ class TestMyProvider(ProviderConformanceSuite):
55
+ @pytest.fixture
56
+ def provider(self):
57
+ return MyProvider(...)
58
+ ```
59
+
60
+ ### Fixture contract
61
+
62
+ The `provider` fixture must expose exactly one skill:
63
+
64
+ | | |
65
+ | --- | --- |
66
+ | id | `conformance-skill` |
67
+ | metadata | `name == "conformance-skill"`, plus a non-empty `description` |
68
+ | body | non-empty markdown |
69
+ | `references/` | `notes.md` containing `b"# Notes\n\nA reference document.\n"` |
70
+ | `scripts/` | `run.sh` containing `b"#!/bin/sh\necho conformance\n"` |
71
+ | `assets/` | `diagram.svg` containing `b"<svg></svg>\n"` |
72
+
73
+ It must not define a skill called `no-such-skill-anywhere`, or any resource
74
+ called `no-such-resource.txt`.
75
+
76
+ Every name and byte string above is exported as a constant (`SKILL_ID`,
77
+ `REFERENCE_NAME`, `REFERENCE_BYTES`, and so on), so a fixture can be built from
78
+ them rather than from copied literals. `CONTRACT` holds the same description as
79
+ a string, and is what the default fixture prints when you forget to override it.
80
+
81
+ ### What it checks
82
+
83
+ - Metadata carries `name` and a non-empty `description`, and does **not** carry
84
+ the body — a metadata call that includes the body has already spent the tokens
85
+ progressive disclosure exists to save.
86
+ - Metadata is not shared mutable state: one caller mutating the returned dict
87
+ must not affect the next.
88
+ - Repeated reads agree. A provider that streams without buffering passes the
89
+ first read and returns empty on the second; caching bugs look the same.
90
+ - Each resource getter returns exact `bytes`, not `str`. A resource may be a
91
+ PNG, and decoding on the way out makes that unreachable.
92
+ - Unknown skills raise `SkillNotFoundError`; unknown resources raise
93
+ `ResourceNotFoundError`.
94
+ - `list_resources()` agrees with `supports_resource_listing`. Callers branch on
95
+ that flag, so a provider whose flag and behaviour disagree breaks them
96
+ whichever way it lies.
97
+ - `discover()` agrees with `supports_discovery`, and everything it reports can
98
+ actually be read. `register_all()` validates the whole list, so one phantom ID
99
+ fails the entire registration.
100
+ - **Traversal identifiers are refused** — parent traversal, absolute paths,
101
+ Windows separators, percent-encoded traversal, and embedded NUL bytes, as both
102
+ skill IDs and resource names. These are not opt-out.
103
+ - Concurrent reads through a single instance return consistent content, which
104
+ catches per-call state stored on `self`.
105
+
106
+ ### Size limits
107
+
108
+ `ContentLimitConformanceSuite` is separate and opt-in. A size limit is not part
109
+ of the universal contract — an in-memory provider has no external source to
110
+ bound, and demanding one would assert a filesystem's constraints against a dict.
111
+ It **is** required of any provider that reads bytes it did not author: from
112
+ disk, from a network, from anywhere a caller can grow without asking.
113
+
114
+ ```python
115
+ from agentskills_testing import ContentLimitConformanceSuite
116
+
117
+
118
+ class TestMyProviderLimits(ContentLimitConformanceSuite):
119
+ @pytest.fixture
120
+ def limited_provider(self):
121
+ return MyProvider(..., max_bytes=8)
122
+ ```
123
+
124
+ The fixture holds the same skill, with a limit small enough that the skill
125
+ exceeds it.
126
+
127
+ ## Test doubles
128
+
129
+ `InMemorySkillProvider` is a real, spec-compliant provider backed by a dict — it
130
+ passes the conformance suite above. Prefer it to an `AsyncMock`: a mock agrees
131
+ with whatever the test asserts, including the assertions that are wrong.
132
+
133
+ ```python
134
+ from agentskills_core import SkillRegistry
135
+ from agentskills_testing import InMemorySkillProvider, build_skill
136
+
137
+ provider = InMemorySkillProvider(
138
+ {
139
+ "incident-response": build_skill(
140
+ "incident-response",
141
+ description="Diagnose and mitigate a production incident.",
142
+ body="# Incident Response\n\nPage the on-call engineer.\n",
143
+ references={"severity-levels.md": b"SEV1 is customer-visible.\n"},
144
+ )
145
+ }
146
+ )
147
+
148
+ registry = SkillRegistry()
149
+ await registry.register_all(provider)
150
+ ```
151
+
152
+ A string value is taken as the body, for the common case where the content does
153
+ not matter:
154
+
155
+ ```python
156
+ provider = InMemorySkillProvider({"a": "body of a", "b": "body of b"})
157
+ provider.add("c") # a default skill named "c"
158
+ ```
159
+
160
+ `build_skill()` produces frontmatter that passes `validate_skill()`, so a test
161
+ that does not care about metadata does not have to invent any.
162
+ `render_skill_md(skill)` renders one back to `SKILL.md` text, which is how you
163
+ populate a temporary directory for the filesystem provider.
164
+
165
+ To emulate a backend that cannot enumerate — a static HTTP host without a
166
+ manifest, for instance — pass `supports_resource_listing=False`, or
167
+ `supports_discovery=False`, or both.
168
+
169
+ ## Fixtures
170
+
171
+ Installing the package registers a pytest plugin, so these are available with no
172
+ import and no `conftest.py` entry:
173
+
174
+ | Fixture | What you get |
175
+ | --- | --- |
176
+ | `sample_skill` | An `InMemorySkill` with one reference, one script, and one asset |
177
+ | `skill_provider` | An `InMemorySkillProvider` serving `sample_skill` |
178
+ | `skill_registry` | A `SkillRegistry` with that skill already registered |
179
+
180
+ ```python
181
+ async def test_my_agent(skill_registry):
182
+ catalog = await skill_registry.get_skills_catalog()
183
+ assert "incident-response" in catalog
184
+ ```
185
+
186
+ ## License
187
+
188
+ MIT — see [LICENSE](https://github.com/pratikxpanda/agentskills-sdk/blob/main/LICENSE).
189
+
190
+ Part of the [Agent Skills SDK](https://github.com/pratikxpanda/agentskills-sdk).
191
+
@@ -0,0 +1,9 @@
1
+ agentskills_testing/__init__.py,sha256=fGjXcSFSFAU_TOrRpHXdoVs6UWmRApJx1R9Y3QvQrjw,1433
2
+ agentskills_testing/conformance.py,sha256=gnxgFnlIlaLisdq6LUxPksaSooD1mvBEfdFLgBnRdsE,12968
3
+ agentskills_testing/fixtures.py,sha256=xoYu1RuCC4fRv4Zsi5WCLEDpmyqUuU4Lnvw3jKHvquM,1401
4
+ agentskills_testing/memory.py,sha256=bwZUHRjY_0r2VgGh5a9cXIAjx_jX-RLj-qvKHj_1Xgs,8800
5
+ agentskills_testing/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ agentskills_testing-0.4.0.dist-info/METADATA,sha256=CR-kTsh38fsuOI0BJ9ZQFquL3sedAANXdPQGFOxmF5g,7292
7
+ agentskills_testing-0.4.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
8
+ agentskills_testing-0.4.0.dist-info/entry_points.txt,sha256=kXFwN-oLaNClC3yOwIEjYIaM1fRi6KgIfFGVV8w1yx4,61
9
+ agentskills_testing-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.4.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [pytest11]
2
+ agentskills_testing=agentskills_testing.fixtures
3
+