skilltest-pytest 0.5.0__tar.gz → 0.7.0__tar.gz

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.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skilltest-pytest
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: pytest integration for skilltest: auto-collect *.skilltest.yaml cases as pytest tests, built on skilltest-sdk.
5
5
  Author: Nick DeRobertis
6
6
  License-Expression: MIT
7
7
  Requires-Python: >=3.12
8
8
  Requires-Dist: pytest>=8
9
- Requires-Dist: skilltest-sdk==0.5.0
9
+ Requires-Dist: skilltest-sdk==0.7.0
10
10
  Description-Content-Type: text/markdown
11
11
 
12
12
  # skilltest-pytest
@@ -17,10 +17,38 @@ your own deterministic checks. Built on
17
17
  [`skilltest-sdk`](../../sdks/python/README.md) — the SDK's code API is
18
18
  re-exported here, so a pytest suite needs only this one dependency.
19
19
 
20
- ## Two ways to use it
20
+ ## Define the whole case in code (recommended)
21
21
 
22
- **Auto-collected case files.** Name a case `something.skilltest.yaml` and pytest
23
- runs it:
22
+ Build the case — skill, input, evals, an optional simulated user, mocks — right
23
+ in the test. Everything the YAML carries has a typed builder, so the case, its
24
+ mocks, and any deterministic transcript checks live in one place:
25
+
26
+ ```python
27
+ from skilltest_pytest import TestCase, run_skill, boolean, numeric, describe_failures
28
+
29
+ def test_greeter():
30
+ case = TestCase(
31
+ skill="skills/greeter", # resolved relative to the working dir
32
+ input="Greet Dr. Smith, who has an appointment today.",
33
+ evals=[
34
+ boolean("the reply greets Dr. Smith by name"),
35
+ numeric("how warm is the tone", min=0, max=10, threshold=7),
36
+ ],
37
+ )
38
+ report = run_skill(case, platforms=["claude-code"], models=["claude-opus-4-8"])
39
+ assert report.passed, describe_failures(report)
40
+ ```
41
+
42
+ Multi-turn cases add `user(...)`; deterministic call-count checks use `called` /
43
+ `not_called` taking the `stub`/`spy` object itself or a name you gave it (or
44
+ use the mock objects' own assertions — see below). `run_skill` also takes `platforms=`/`models=` to fan a
45
+ case across a matrix.
46
+
47
+ ## Or point at a YAML file
48
+
49
+ `run_skill` accepts a path just as well (`run_skill("cases/greet.yaml")`), and
50
+ **auto-collection** still works: name a case `something.skilltest.yaml` and
51
+ pytest runs it with no test function at all —
24
52
 
25
53
  ```yaml
26
54
  # greet.skilltest.yaml
@@ -31,16 +59,7 @@ evals:
31
59
  criterion: "the reply greets Dr. Smith by name"
32
60
  ```
33
61
 
34
- **As code**, for matrices and deterministic mix-ins:
35
-
36
- ```python
37
- from skilltest_pytest import run_skill, describe_failures, assistant_text
38
-
39
- def test_greeter():
40
- report = run_skill("cases/greet.yaml", platforms=["claude-code"], models=["claude-opus-4-8"])
41
- assert report.passed, describe_failures(report)
42
- assert "Dr. Smith" in assistant_text(report.runs[0].transcript)
43
- ```
62
+ The full field reference for both forms is [`docs/schema.md`](../../docs/schema.md).
44
63
 
45
64
  ## Assert on tool use, and stream
46
65
 
@@ -50,10 +69,16 @@ returns the normalized `tool_call` events a run took (each a `ToolEvent` with
50
69
  test can **short-circuit** on bad behavior:
51
70
 
52
71
  ```python
53
- from skilltest_pytest import run_skill, tool_calls
72
+ from skilltest_pytest import TestCase, run_skill, tool_calls, boolean
73
+
74
+ EDIT_CASE = TestCase(
75
+ skill="skills/editor",
76
+ input="Update the config and commit it.",
77
+ evals=[boolean("the change was committed")],
78
+ )
54
79
 
55
80
  def test_commits_but_never_deletes():
56
- report = run_skill("cases/edit.skilltest.yaml")
81
+ report = run_skill(EDIT_CASE)
57
82
  calls = tool_calls(report.runs[0].transcript)
58
83
  assert any("git commit" in str(c.input) for c in calls)
59
84
  assert not any("rm -rf" in str(c.input) for c in calls)
@@ -65,7 +90,7 @@ from skilltest_pytest import stream_skill
65
90
 
66
91
  def test_makes_no_network_call():
67
92
  async def go():
68
- async for ev in stream_skill("cases/edit.skilltest.yaml"):
93
+ async for ev in stream_skill(EDIT_CASE):
69
94
  assert ev.event.name != "curl", "skill made a network call"
70
95
  asyncio.run(go()) # or use pytest-asyncio and `async def test_...`
71
96
  ```
@@ -6,10 +6,38 @@ your own deterministic checks. Built on
6
6
  [`skilltest-sdk`](../../sdks/python/README.md) — the SDK's code API is
7
7
  re-exported here, so a pytest suite needs only this one dependency.
8
8
 
9
- ## Two ways to use it
9
+ ## Define the whole case in code (recommended)
10
10
 
11
- **Auto-collected case files.** Name a case `something.skilltest.yaml` and pytest
12
- runs it:
11
+ Build the case — skill, input, evals, an optional simulated user, mocks — right
12
+ in the test. Everything the YAML carries has a typed builder, so the case, its
13
+ mocks, and any deterministic transcript checks live in one place:
14
+
15
+ ```python
16
+ from skilltest_pytest import TestCase, run_skill, boolean, numeric, describe_failures
17
+
18
+ def test_greeter():
19
+ case = TestCase(
20
+ skill="skills/greeter", # resolved relative to the working dir
21
+ input="Greet Dr. Smith, who has an appointment today.",
22
+ evals=[
23
+ boolean("the reply greets Dr. Smith by name"),
24
+ numeric("how warm is the tone", min=0, max=10, threshold=7),
25
+ ],
26
+ )
27
+ report = run_skill(case, platforms=["claude-code"], models=["claude-opus-4-8"])
28
+ assert report.passed, describe_failures(report)
29
+ ```
30
+
31
+ Multi-turn cases add `user(...)`; deterministic call-count checks use `called` /
32
+ `not_called` taking the `stub`/`spy` object itself or a name you gave it (or
33
+ use the mock objects' own assertions — see below). `run_skill` also takes `platforms=`/`models=` to fan a
34
+ case across a matrix.
35
+
36
+ ## Or point at a YAML file
37
+
38
+ `run_skill` accepts a path just as well (`run_skill("cases/greet.yaml")`), and
39
+ **auto-collection** still works: name a case `something.skilltest.yaml` and
40
+ pytest runs it with no test function at all —
13
41
 
14
42
  ```yaml
15
43
  # greet.skilltest.yaml
@@ -20,16 +48,7 @@ evals:
20
48
  criterion: "the reply greets Dr. Smith by name"
21
49
  ```
22
50
 
23
- **As code**, for matrices and deterministic mix-ins:
24
-
25
- ```python
26
- from skilltest_pytest import run_skill, describe_failures, assistant_text
27
-
28
- def test_greeter():
29
- report = run_skill("cases/greet.yaml", platforms=["claude-code"], models=["claude-opus-4-8"])
30
- assert report.passed, describe_failures(report)
31
- assert "Dr. Smith" in assistant_text(report.runs[0].transcript)
32
- ```
51
+ The full field reference for both forms is [`docs/schema.md`](../../docs/schema.md).
33
52
 
34
53
  ## Assert on tool use, and stream
35
54
 
@@ -39,10 +58,16 @@ returns the normalized `tool_call` events a run took (each a `ToolEvent` with
39
58
  test can **short-circuit** on bad behavior:
40
59
 
41
60
  ```python
42
- from skilltest_pytest import run_skill, tool_calls
61
+ from skilltest_pytest import TestCase, run_skill, tool_calls, boolean
62
+
63
+ EDIT_CASE = TestCase(
64
+ skill="skills/editor",
65
+ input="Update the config and commit it.",
66
+ evals=[boolean("the change was committed")],
67
+ )
43
68
 
44
69
  def test_commits_but_never_deletes():
45
- report = run_skill("cases/edit.skilltest.yaml")
70
+ report = run_skill(EDIT_CASE)
46
71
  calls = tool_calls(report.runs[0].transcript)
47
72
  assert any("git commit" in str(c.input) for c in calls)
48
73
  assert not any("rm -rf" in str(c.input) for c in calls)
@@ -54,7 +79,7 @@ from skilltest_pytest import stream_skill
54
79
 
55
80
  def test_makes_no_network_call():
56
81
  async def go():
57
- async for ev in stream_skill("cases/edit.skilltest.yaml"):
82
+ async for ev in stream_skill(EDIT_CASE):
58
83
  assert ev.event.name != "curl", "skill made a network call"
59
84
  asyncio.run(go()) # or use pytest-asyncio and `async def test_...`
60
85
  ```
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "skilltest-pytest"
3
- version = "0.5.0"
3
+ version = "0.7.0"
4
4
  description = "pytest integration for skilltest: auto-collect *.skilltest.yaml cases as pytest tests, built on skilltest-sdk."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
7
7
  license = "MIT"
8
8
  authors = [{ name = "Nick DeRobertis" }]
9
9
  dependencies = [
10
- "skilltest-sdk==0.5.0",
10
+ "skilltest-sdk==0.7.0",
11
11
  "pytest>=8",
12
12
  ]
13
13
 
@@ -1,17 +1,23 @@
1
1
  """skilltest-pytest: run AI-skill tests and natural-language evals as pytest.
2
2
 
3
- The pytest integration on top of [`skilltest-sdk`][skilltest_sdk]: drop a
4
- ``*.skilltest.yaml`` next to your other tests and pytest collects it as a test
5
- item. The SDK's code-level API is re-exported here for convenience, so a pytest
6
- suite only needs one dependency:
3
+ The pytest integration on top of [`skilltest-sdk`][skilltest_sdk], whose API is
4
+ re-exported here so a pytest suite needs only this one dependency. Define the
5
+ whole case in code (the recommended form):
7
6
 
8
- from skilltest_pytest import run_skill, validate_skill
7
+ from skilltest_pytest import TestCase, run_skill, boolean, describe_failures
9
8
 
10
9
  def test_greeter():
11
- report = run_skill("cases/greet.yaml")
10
+ case = TestCase(
11
+ skill="skills/greeter",
12
+ input="Greet Dr. Smith.",
13
+ evals=[boolean("the reply greets Dr. Smith by name")],
14
+ )
15
+ report = run_skill(case)
12
16
  assert report.passed, describe_failures(report)
13
- # Mix in a deterministic check on the transcript:
14
- assert "Dr. Smith" in assistant_text(report.runs[0].transcript)
17
+
18
+ Existing YAML cases stay first-class: ``run_skill("cases/greet.yaml")`` runs a
19
+ file, and any ``*.skilltest.yaml`` dropped next to your tests is auto-collected
20
+ as a test item with no code at all.
15
21
  """
16
22
 
17
23
  from __future__ import annotations
@@ -22,18 +28,22 @@ from skilltest_sdk import (
22
28
  BooleanDetail,
23
29
  CallsDetail,
24
30
  CaseRun,
31
+ Eval,
25
32
  EvalOutcome,
26
33
  Matcher,
27
34
  Message,
28
35
  MockCall,
36
+ MockRefEval,
29
37
  NumericDetail,
30
38
  Report,
39
+ SimulatedUser,
31
40
  SkillStream,
32
41
  SkilltestError,
33
42
  SkilltestProviderError,
34
43
  SkilltestUsageError,
35
44
  StreamEvent,
36
45
  Summary,
46
+ TestCase,
37
47
  ToolCall,
38
48
  ToolEvent,
39
49
  ToolMock,
@@ -44,18 +54,23 @@ from skilltest_sdk import (
44
54
  ValidationReport,
45
55
  anything,
46
56
  assistant_text,
57
+ boolean,
58
+ called,
47
59
  contains,
48
60
  deny,
49
61
  describe_failures,
50
62
  failed_evals,
51
63
  failed_runs,
52
64
  matching,
65
+ not_called,
66
+ numeric,
53
67
  rewrite,
54
68
  run_skill,
55
69
  spy,
56
70
  stream_skill,
57
71
  stub,
58
72
  tool_calls,
73
+ user,
59
74
  validate_skill,
60
75
  )
61
76
 
@@ -67,12 +82,15 @@ __all__ = [
67
82
  "BooleanDetail",
68
83
  "CallsDetail",
69
84
  "CaseRun",
85
+ "Eval",
70
86
  "EvalOutcome",
71
87
  "Matcher",
72
88
  "Message",
73
89
  "MockCall",
90
+ "MockRefEval",
74
91
  "NumericDetail",
75
92
  "Report",
93
+ "SimulatedUser",
76
94
  "SkillStream",
77
95
  "SkilltestError",
78
96
  "SkilltestFailure",
@@ -80,6 +98,7 @@ __all__ = [
80
98
  "SkilltestUsageError",
81
99
  "StreamEvent",
82
100
  "Summary",
101
+ "TestCase",
83
102
  "ToolCall",
84
103
  "ToolEvent",
85
104
  "ToolMock",
@@ -90,17 +109,22 @@ __all__ = [
90
109
  "ValidationReport",
91
110
  "anything",
92
111
  "assistant_text",
112
+ "boolean",
113
+ "called",
93
114
  "contains",
94
115
  "deny",
95
116
  "describe_failures",
96
117
  "failed_evals",
97
118
  "failed_runs",
98
119
  "matching",
120
+ "not_called",
121
+ "numeric",
99
122
  "rewrite",
100
123
  "run_skill",
101
124
  "spy",
102
125
  "stream_skill",
103
126
  "stub",
104
127
  "tool_calls",
128
+ "user",
105
129
  "validate_skill",
106
130
  ]
@@ -92,6 +92,72 @@ def test_collected_case_failure_reports_judge_reason(pytester: pytest.Pytester)
92
92
  result.stdout.fnmatch_lines(["*skilltest case failed:*", "*says-goodbye*"])
93
93
 
94
94
 
95
+ def test_code_defined_case_is_reexported_and_runs(fixtures: Path) -> None:
96
+ # The recommended form: build the whole case in code and hand it to
97
+ # run_skill — the case API rides the same one-dependency re-export.
98
+ from skilltest_pytest import TestCase, boolean, run_skill
99
+
100
+ case = TestCase(
101
+ skill=fixtures / "skills" / "greeter",
102
+ input="Greet Dr. Smith, who has an appointment today.",
103
+ evals=[boolean("the reply greets `Dr. Smith` by name")],
104
+ )
105
+ report = run_skill(case)
106
+ assert report.passed, describe_failures(report)
107
+
108
+
109
+ def test_full_code_defined_case_surface_is_reexported(fixtures: Path) -> None:
110
+ # Every case builder rides the one-dependency re-export: a multi-turn case
111
+ # with judge evals, and a mocked case with a deterministic call eval —
112
+ # streamed and buffered — defined entirely in code through the plugin.
113
+ from skilltest_pytest import (
114
+ TestCase,
115
+ boolean,
116
+ called,
117
+ numeric,
118
+ run_skill,
119
+ stream_skill,
120
+ stub,
121
+ user,
122
+ )
123
+
124
+ multi = TestCase(
125
+ skill=fixtures / "skills" / "greeter",
126
+ input="I'd like to confirm my appointment, please.",
127
+ user=user(
128
+ "You are a terse patient.\nsay: Yes, please go ahead.",
129
+ done_when="the conversation has reached turns>=2",
130
+ max_turns=4,
131
+ ),
132
+ evals=[
133
+ boolean("the assistant confirmed the appointment (`confirmed`)"),
134
+ numeric("mentions `confirmed`", min=0, max=10, threshold=5, comparator=">"),
135
+ ],
136
+ )
137
+ report = run_skill(multi)
138
+ assert report.passed, describe_failures(report)
139
+ assert report.runs[0].turns == 2
140
+
141
+ # The eval references the stub by object — no name to keep in sync — and
142
+ # the reference resolves through the plugin's re-export and streaming path.
143
+ push = stub(pattern=r"git push( --force)?\b", output="Everything up-to-date")
144
+ mocked = TestCase(
145
+ skill=fixtures / "skills" / "deployer",
146
+ input="Deploy the app",
147
+ mocks=[push],
148
+ evals=[called(push, times=1)],
149
+ )
150
+ stream = stream_skill(mocked)
151
+
152
+ async def drain() -> None:
153
+ async for _ in stream:
154
+ pass
155
+
156
+ asyncio.run(drain())
157
+ assert stream.report is not None and stream.report.passed
158
+ push.assert_called_once()
159
+
160
+
95
161
  def test_mock_api_is_reexported_and_binds(cases: Path) -> None:
96
162
  # The mock/spy API rides the one-dependency re-export; code-level mocks
97
163
  # intercept and bind through the plugin's SDK exactly as through the SDK.
@@ -189,7 +189,7 @@ wheels = [
189
189
 
190
190
  [[package]]
191
191
  name = "skilltest-pytest"
192
- version = "0.5.0"
192
+ version = "0.7.0"
193
193
  source = { editable = "." }
194
194
  dependencies = [
195
195
  { name = "pytest" },
@@ -216,7 +216,7 @@ dev = [
216
216
 
217
217
  [[package]]
218
218
  name = "skilltest-sdk"
219
- version = "0.5.0"
219
+ version = "0.7.0"
220
220
  source = { editable = "../../sdks/python" }
221
221
  dependencies = [
222
222
  { name = "pydantic" },