pytest-inspect-evals 0.1.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,10 @@
1
+ """pytest plugin with shared test gates, fixtures and helpers for Inspect AI evaluations"""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ # The version is set once, in pyproject.toml; this reads it back from the
6
+ # installed package metadata.
7
+ try:
8
+ __version__ = version("pytest-inspect-evals")
9
+ except PackageNotFoundError: # a source tree that was never installed
10
+ __version__ = "unknown"
@@ -0,0 +1,69 @@
1
+ """Hugging Face handling for tests marked huggingface."""
2
+
3
+ import os
4
+ from importlib.util import find_spec
5
+
6
+ import pytest
7
+
8
+
9
+ def hf_disable_tokenizer_parallelism() -> None:
10
+ """Disable HF tokenizers parallelism to avoid segfaults."""
11
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
12
+
13
+
14
+ def hf_configure_logging() -> None:
15
+ """Raise datasets and huggingface_hub logging to INFO when datasets is installed."""
16
+ if find_spec("datasets") is None:
17
+ return
18
+ import datasets
19
+ import huggingface_hub
20
+
21
+ datasets.logging.set_verbosity_info()
22
+ huggingface_hub.logging.set_verbosity_info()
23
+
24
+
25
+ def hf_apply_collection_markers(items: list[pytest.Item]) -> None:
26
+ hf_items = [item for item in items if item.get_closest_marker("huggingface") is not None]
27
+
28
+ if not hf_items:
29
+ return
30
+
31
+ if not os.environ.get("HF_TOKEN", "").strip():
32
+ for item in hf_items:
33
+ item.add_marker(pytest.mark.skip(reason=f"HF_TOKEN not set ({item.name})"))
34
+
35
+ else:
36
+ for item in hf_items:
37
+ item.add_marker(pytest.mark.flaky(reruns=2, reruns_delay=60.0))
38
+
39
+
40
+ def _is_hf_gated_dataset_failure(
41
+ item: pytest.Item, call: pytest.CallInfo, report: pytest.TestReport
42
+ ) -> bool:
43
+ return (
44
+ report.when == "call"
45
+ and report.failed
46
+ and item.get_closest_marker("huggingface") is not None
47
+ and bool(call.excinfo)
48
+ and is_gated_dataset_exception(call.excinfo.value) # type: ignore
49
+ )
50
+
51
+
52
+ def hf_convert_gated_failure_to_skip(
53
+ item: pytest.Item, call: pytest.CallInfo, report: pytest.TestReport
54
+ ) -> None:
55
+ if _is_hf_gated_dataset_failure(item, call, report):
56
+ report.outcome = "skipped"
57
+ line_number = item.reportinfo()[1] or 0
58
+ report.longrepr = (
59
+ str(item.fspath),
60
+ line_number,
61
+ f"Skipped: Gated dataset ({item.name})",
62
+ )
63
+
64
+
65
+ def is_gated_dataset_exception(exc: BaseException) -> bool:
66
+ exc_name = type(exc).__name__
67
+ return exc_name == "GatedRepoError" or (
68
+ exc_name == "DatasetNotFoundError" and "gated dataset" in str(exc).lower()
69
+ )
@@ -0,0 +1,25 @@
1
+ """Skip tests that can't run on Windows."""
2
+
3
+ import sys
4
+
5
+ import pytest
6
+
7
+
8
+ def windows_skip_unsupported_tests(items: list[pytest.Item], platform: str | None = None) -> None:
9
+ """Skip POSIX-only and Docker tests on Windows."""
10
+ if (platform or sys.platform) != "win32":
11
+ return
12
+
13
+ for item in items:
14
+ if item.get_closest_marker("posix_only") is not None:
15
+ item.add_marker(
16
+ pytest.mark.skip(
17
+ reason=f"Skipping {item.name}: test requires POSIX system (not supported on Windows)"
18
+ )
19
+ )
20
+ if item.get_closest_marker("docker") is not None:
21
+ item.add_marker(
22
+ pytest.mark.skip(
23
+ reason=f"Skipping {item.name}: Docker tests are not supported on Windows CI"
24
+ )
25
+ )
@@ -0,0 +1,145 @@
1
+ """Assertions for Inspect task structure and eval results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from inspect_ai import Task
8
+ from inspect_ai import eval as inspect_eval
9
+ from inspect_ai.dataset import MemoryDataset, Sample
10
+ from inspect_ai.log import EvalLog
11
+ from inspect_ai.scorer import Scorer
12
+
13
+ from pytest_inspect_evals.solvers import mock_solver_with_output
14
+
15
+ DisplayType = Literal["full", "conversation", "rich", "plain", "log", "none"]
16
+
17
+
18
+ def assert_task_structure(task: Task, *, sandbox_type: str | None = None) -> None:
19
+ """Assert that a Task has dataset, solver, and scorer.
20
+
21
+ Args:
22
+ task: The Task object to validate.
23
+ sandbox_type: If set, also assert task.sandbox.type matches.
24
+ """
25
+ assert task.dataset is not None, "task.dataset is None"
26
+ assert task.solver is not None, "task.solver is None"
27
+ assert task.scorer is not None, "task.scorer is None"
28
+
29
+ if sandbox_type is not None:
30
+ assert task.sandbox is not None, (
31
+ f"task.sandbox is None, expected sandbox_type='{sandbox_type}'"
32
+ )
33
+ assert task.sandbox.type == sandbox_type, (
34
+ f"Expected sandbox type '{sandbox_type}', got '{task.sandbox.type}'"
35
+ )
36
+
37
+
38
+ def assert_eval_success(
39
+ log: EvalLog,
40
+ *,
41
+ require_scorer: bool = True,
42
+ expected_metric: str | None = None,
43
+ min_samples: int | None = None,
44
+ expected_sample_count: int | None = None,
45
+ ) -> None:
46
+ """Assert that an eval completed successfully with valid results.
47
+
48
+ Args:
49
+ log: The EvalLog from running an evaluation.
50
+ require_scorer: If True (default), assert scores are non-empty.
51
+ Set to False for tasks that intentionally have no scorer.
52
+ expected_metric: If set, assert this metric exists in the first score.
53
+ min_samples: If set, assert at least this many samples.
54
+ expected_sample_count: If set, assert exactly this many samples.
55
+ """
56
+ assert log.status == "success", f"Eval failed with status '{log.status}': {log.error}"
57
+ assert log.results is not None, "log.results is None"
58
+ assert log.results.scores is not None, "log.results.scores is None"
59
+ if require_scorer:
60
+ assert len(log.results.scores) > 0, "log.results.scores is empty"
61
+
62
+ if expected_metric is not None:
63
+ assert expected_metric in log.results.scores[0].metrics, (
64
+ f"Expected metric '{expected_metric}' not found. "
65
+ f"Available: {list(log.results.scores[0].metrics.keys())}"
66
+ )
67
+
68
+ if min_samples is not None:
69
+ assert log.samples is not None, "log.samples is None"
70
+ assert len(log.samples) >= min_samples, (
71
+ f"Expected at least {min_samples} samples, got {len(log.samples)}"
72
+ )
73
+
74
+ if expected_sample_count is not None:
75
+ assert log.samples is not None, "log.samples is None"
76
+ assert len(log.samples) == expected_sample_count, (
77
+ f"Expected {expected_sample_count} samples, got {len(log.samples)}"
78
+ )
79
+
80
+
81
+ def run_single_sample_eval(
82
+ sample: Sample,
83
+ scorer: Scorer,
84
+ solver_output: str,
85
+ *,
86
+ epochs: int = 1,
87
+ display: DisplayType = "none",
88
+ model: str = "mockllm/model",
89
+ ) -> EvalLog:
90
+ """Run an evaluation on a single sample for testing.
91
+
92
+ Args:
93
+ sample: The Sample to evaluate.
94
+ scorer: The scorer to use.
95
+ solver_output: The fixed output the mock solver should produce.
96
+ epochs: Number of epochs to run.
97
+ display: Display mode.
98
+ model: Model identifier.
99
+
100
+ Returns:
101
+ The EvalLog from the evaluation run.
102
+ """
103
+ task = Task(
104
+ dataset=MemoryDataset([sample]),
105
+ solver=[mock_solver_with_output(solver_output)],
106
+ scorer=scorer,
107
+ )
108
+
109
+ [log] = inspect_eval(
110
+ tasks=task,
111
+ model=model,
112
+ epochs=epochs,
113
+ display=display,
114
+ )
115
+
116
+ return log
117
+
118
+
119
+ def get_metric_value(
120
+ log: EvalLog,
121
+ metric_name: str,
122
+ scorer_index: int = 0,
123
+ ) -> int | float:
124
+ """Extract a metric value from an EvalLog.
125
+
126
+ Args:
127
+ log: The EvalLog to extract from.
128
+ metric_name: The name of the metric to extract.
129
+ scorer_index: Index of the scorer in log.results.scores.
130
+
131
+ Returns:
132
+ The metric value.
133
+ """
134
+ assert log.results is not None, "log.results is None"
135
+ assert log.results.scores is not None, "log.results.scores is None"
136
+ assert len(log.results.scores) > scorer_index, (
137
+ f"scorer_index {scorer_index} out of range, only {len(log.results.scores)} scorers"
138
+ )
139
+
140
+ metrics = log.results.scores[scorer_index].metrics
141
+ assert metric_name in metrics, (
142
+ f"Metric '{metric_name}' not found. Available: {list(metrics.keys())}"
143
+ )
144
+
145
+ return metrics[metric_name].value
@@ -0,0 +1,97 @@
1
+ """Opt-in test gates: markers whose tests are skipped unless switched on."""
2
+
3
+ import os
4
+ from collections.abc import Iterable
5
+ from dataclasses import dataclass
6
+
7
+ import pytest
8
+
9
+ TRUTHY = frozenset({"1", "true", "yes", "on"})
10
+
11
+
12
+ def truthy(value: str | None) -> bool:
13
+ """Return whether an env var value switches a gate on."""
14
+ return (value or "").strip().lower() in TRUTHY
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Gate:
19
+ """A marker that is skipped unless an env var, CLI flag or ini option enables it."""
20
+
21
+ marker: str
22
+ cli_flag: str
23
+ env_var: str
24
+ ini_option: str
25
+ description: str
26
+
27
+ @property
28
+ def reason(self) -> str:
29
+ return (
30
+ f"{self.marker} tests disabled (set {self.env_var}=1, pass {self.cli_flag}, "
31
+ f"or set {self.ini_option} = true in the pytest config)"
32
+ )
33
+
34
+
35
+ GATES: tuple[Gate, ...] = (
36
+ Gate(
37
+ "slow",
38
+ "--runslow",
39
+ "RUN_SLOW_TESTS",
40
+ "inspect_evals_slow",
41
+ "marks tests that are slow to run",
42
+ ),
43
+ Gate(
44
+ "dataset_download",
45
+ "--dataset-download",
46
+ "RUN_DATASET_DOWNLOAD_TESTS",
47
+ "inspect_evals_dataset_download",
48
+ "marks tests that download datasets",
49
+ ),
50
+ Gate(
51
+ "k8s",
52
+ "--runk8s",
53
+ "RUN_K8S_TESTS",
54
+ "inspect_evals_k8s",
55
+ "marks tests requiring k8s sandbox support",
56
+ ),
57
+ Gate(
58
+ "gpu",
59
+ "--rungpu",
60
+ "RUN_GPU_TESTS",
61
+ "inspect_evals_gpu",
62
+ "marks tests that need an NVIDIA GPU in the sandbox host (Docker or k8s)",
63
+ ),
64
+ )
65
+
66
+
67
+ def skip_if_marker_present(
68
+ config: pytest.Config,
69
+ items: Iterable[pytest.Item],
70
+ marker: str,
71
+ cli_flag: str,
72
+ env_var: str,
73
+ reason: str,
74
+ *,
75
+ default_enabled: bool = False,
76
+ ini_option: str | None = None,
77
+ ) -> None:
78
+ """Skip tests carrying `marker` unless the gate is switched on.
79
+
80
+ Priority: env var, then CLI flag, then ini option (when given), then
81
+ `default_enabled`. An empty env var counts as unset.
82
+ """
83
+ env_val = os.environ.get(env_var, "").strip()
84
+ if env_val:
85
+ enabled = truthy(env_val)
86
+ elif config.getoption(cli_flag):
87
+ enabled = True
88
+ elif ini_option is not None:
89
+ enabled = bool(config.getini(ini_option))
90
+ else:
91
+ enabled = default_enabled
92
+ if enabled:
93
+ return
94
+ skip_mark = pytest.mark.skip(reason=reason)
95
+ for item in items:
96
+ if item.get_closest_marker(marker) is not None:
97
+ item.add_marker(skip_mark)