inspect-evals-utils 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,43 @@
1
+ """Runtime helpers for Inspect AI evaluations: dataset loading with retries, sample ids, prompts and README generation."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from inspect_evals_utils.cache import INSPECT_EVALS_CACHE_PATH
6
+ from inspect_evals_utils.datasets import load_csv_dataset, load_json_dataset
7
+ from inspect_evals_utils.ids import (
8
+ DuplicateIdError,
9
+ KnownBrokenSampleError,
10
+ create_stable_id,
11
+ drop_known_broken,
12
+ filter_duplicate_ids,
13
+ require_optional_dependency,
14
+ )
15
+ from inspect_evals_utils.messages import message_content_text, reasoning_to_text
16
+ from inspect_evals_utils.prompts import Prompt
17
+ from inspect_evals_utils.shuffle import shuffle_and_seed
18
+
19
+ # The version is set once, in pyproject.toml; this reads it back from the
20
+ # installed package metadata.
21
+ try:
22
+ __version__ = version("inspect-evals-utils")
23
+ except PackageNotFoundError: # a source tree that was never installed
24
+ __version__ = "unknown"
25
+
26
+ # inspect_evals_utils.huggingface is not imported here: it needs the
27
+ # huggingface extra, and the base package must import without it.
28
+ __all__ = [
29
+ "INSPECT_EVALS_CACHE_PATH",
30
+ "DuplicateIdError",
31
+ "KnownBrokenSampleError",
32
+ "Prompt",
33
+ "__version__",
34
+ "create_stable_id",
35
+ "drop_known_broken",
36
+ "filter_duplicate_ids",
37
+ "load_csv_dataset",
38
+ "load_json_dataset",
39
+ "message_content_text",
40
+ "reasoning_to_text",
41
+ "require_optional_dependency",
42
+ "shuffle_and_seed",
43
+ ]
@@ -0,0 +1,33 @@
1
+ """The cache directory shared by inspect_evals and standalone evals.
2
+
3
+ Set INSPECT_EVALS_CACHE_DIR to move it or point it at a pre-populated directory.
4
+ """
5
+
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from platformdirs import user_cache_dir
10
+
11
+
12
+ def cache_path() -> Path:
13
+ """Resolve the cache directory, rejecting a value that depends on the cwd."""
14
+ configured = os.environ.get("INSPECT_EVALS_CACHE_DIR")
15
+ if not configured:
16
+ return Path(user_cache_dir("inspect_evals"))
17
+
18
+ # A leading ~ survives a .env file and a quoted shell assignment, which is
19
+ # how this variable is usually set, so expand it rather than treating it as
20
+ # a directory name.
21
+ path = Path(configured).expanduser()
22
+ if not path.is_absolute():
23
+ raise ValueError(
24
+ f"INSPECT_EVALS_CACHE_DIR must be absolute or start with ~, but is "
25
+ f"{configured!r}. It is read once when inspect_evals_utils is imported, so "
26
+ f"a relative path would point somewhere different depending on where "
27
+ f"the eval was started from."
28
+ )
29
+ return path
30
+
31
+
32
+ # Read once at import, because evals derive their own module-level paths from it.
33
+ INSPECT_EVALS_CACHE_PATH = cache_path()
@@ -0,0 +1,123 @@
1
+ """Helpers for extracting generated code and JSON from Markdown responses."""
2
+
3
+ import ast
4
+ import re
5
+ from collections.abc import Callable
6
+
7
+ _LANGUAGE_TAGS = {
8
+ "python": ("python", "python3", "py"),
9
+ "cpp": ("cpp", "c++", "cc", "cxx", "c"),
10
+ "java": ("java",),
11
+ "javascript": ("javascript", "js"),
12
+ "typescript": ("typescript", "ts"),
13
+ "csharp": ("csharp", "cs", "c#"),
14
+ "php": ("php",),
15
+ "shell": ("bash", "sh", "shell"),
16
+ }
17
+
18
+ # A block ends at a bare fence line, just before any other fence line (which a model
19
+ # may write without closing the block first), at a fence glued to the end of the last
20
+ # code line, or at the end of the text when the fence is never closed. The last case is
21
+ # its own group so that an unclosed unlabelled fence, usually a stray closing fence
22
+ # after unfenced code, can be ignored.
23
+ _CLOSE = r"(?:^```[ \t\r]*$|(?=^```)|(?<=\S)```[ \t\r]*$|(\Z))"
24
+
25
+ _FENCE = re.compile(
26
+ r"^```[ \t]*([^\s`]*)[^\n]*\r?\n(.*?)" + _CLOSE,
27
+ re.DOTALL | re.MULTILINE,
28
+ )
29
+
30
+
31
+ _JSON_FENCE = re.compile(
32
+ r"\A\s*```[ \t]*(?:json)?[ \t]*\r?\n(.*?)\s*```\s*\Z", re.DOTALL | re.IGNORECASE
33
+ )
34
+
35
+
36
+ def _labelled_fence(tags: tuple[str, ...]) -> re.Pattern[str]:
37
+ # A labelled fence may follow prose on the same line, but only when nothing
38
+ # follows the label, so that a fence mentioned in a sentence is not an opener.
39
+ tag = "|".join(re.escape(t) for t in sorted(tags, key=len, reverse=True))
40
+ opener = rf"(?:^```[ \t]*(?:{tag})(?=[\s`])[^\n]*|```[ \t]*(?:{tag})[ \t]*)\r?\n"
41
+ return re.compile(opener + "(.*?)" + _CLOSE, re.DOTALL | re.MULTILINE | re.IGNORECASE)
42
+
43
+
44
+ def extract_code_blocks(completion: str, language: str = "python") -> list[str]:
45
+ """Return the language-labelled code blocks, or else the unlabelled ones.
46
+
47
+ Blocks are in the order they appear. Unlabelled blocks are returned only
48
+ when no block carries one of the language's labels. Labels are matched
49
+ case-insensitively and both LF and CRLF line endings are accepted. Fences
50
+ labelled for another language are not treated as unlabelled code, and empty
51
+ blocks are skipped. A labelled fence that is never closed runs to the end of
52
+ the text.
53
+ """
54
+ tags = _LANGUAGE_TAGS.get(language.lower(), (language.lower(),))
55
+ labelled = [
56
+ match.group(1)
57
+ for match in _labelled_fence(tags).finditer(completion)
58
+ if match.group(1).strip()
59
+ ]
60
+ if labelled:
61
+ return labelled
62
+ return [
63
+ match.group(2)
64
+ for match in _FENCE.finditer(completion)
65
+ if match.group(1) == "" and match.group(2).strip() and match.group(3) is None
66
+ ]
67
+
68
+
69
+ def extract_code_block(
70
+ completion: str,
71
+ language: str = "python",
72
+ accept: Callable[[str], bool] | None = None,
73
+ ) -> str | None:
74
+ """Return the first block from `extract_code_blocks`, or None if there is none.
75
+
76
+ Language-labelled blocks are preferred over unlabelled blocks even when an
77
+ unlabelled block appears earlier in the response.
78
+
79
+ When `accept` is given, the first block it accepts is returned instead. If
80
+ it accepts none, the first block is returned as without it. Pass
81
+ `defines(name)` to take the block that implements the function the task
82
+ asks for, rather than a usage example or a draft that does not parse.
83
+ """
84
+ blocks = extract_code_blocks(completion, language)
85
+
86
+ if accept is not None:
87
+ for block in blocks:
88
+ if accept(block):
89
+ return block
90
+
91
+ return blocks[0] if blocks else None
92
+
93
+
94
+ def strip_json_fence(text: str) -> str:
95
+ """Return the JSON inside one code fence wrapping the whole text, else the text unchanged.
96
+
97
+ The fence may be labelled `json` or unlabelled. Use it before parsing a
98
+ reply that should be bare JSON, such as one requested with a
99
+ `response_schema`: some providers accept the schema without enforcing it and
100
+ return the JSON in a fence. Text with prose around the fence, or more than
101
+ one fence, is returned unchanged, so it still fails validation.
102
+ """
103
+ match = _JSON_FENCE.match(text)
104
+ if match is None or re.search(r"^[ \t]*```", match.group(1), re.MULTILINE):
105
+ return text
106
+ return match.group(1)
107
+
108
+
109
+ def defines(name: str) -> Callable[[str], bool]:
110
+ """Accept Python code that parses and defines `name` at the top level."""
111
+
112
+ def check(code: str) -> bool:
113
+ try:
114
+ tree = ast.parse(code)
115
+ except (SyntaxError, ValueError):
116
+ return False
117
+ return any(
118
+ isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
119
+ and node.name == name
120
+ for node in tree.body
121
+ )
122
+
123
+ return check
@@ -0,0 +1,311 @@
1
+ import hashlib
2
+ import logging
3
+ import os
4
+ from collections.abc import Callable
5
+ from functools import wraps
6
+ from pathlib import Path
7
+ from typing import Any, Concatenate, ParamSpec, cast
8
+ from urllib.parse import urlparse
9
+
10
+ import backoff # type: ignore
11
+ from inspect_ai._util.file import ( # no public API available
12
+ file,
13
+ )
14
+ from inspect_ai.dataset import Dataset, csv_dataset, json_dataset
15
+
16
+ from inspect_evals_utils.cache import INSPECT_EVALS_CACHE_PATH
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ P = ParamSpec("P")
21
+ DatasetLoader = Callable[..., Dataset]
22
+
23
+ SUPPORTED_REMOTE_URL_PROTOCOLS = {"https", "http", "s3", "gs", "gcs", "az", "abfs"}
24
+ LOCAL_PROTOCOL = {"", "c", "d", "e", "file"}
25
+ TRANSIENT_ERRORS = {408, 429, 500, 502, 503, 504}
26
+
27
+
28
+ def is_remote_url(file_path: str) -> bool:
29
+ scheme = urlparse(file_path).scheme
30
+ known_remote = scheme in SUPPORTED_REMOTE_URL_PROTOCOLS
31
+ if known_remote:
32
+ return True
33
+ definitely_local = scheme in LOCAL_PROTOCOL
34
+ if definitely_local:
35
+ return False
36
+ raise ValueError(
37
+ f"Could not parse file path. Found {file_path}"
38
+ "Currently, following protocols are supported:"
39
+ f"{SUPPORTED_REMOTE_URL_PROTOCOLS}"
40
+ )
41
+
42
+
43
+ def _get_extension(url: str) -> str:
44
+ """Get file extension from URL or file path."""
45
+ parsed_url = urlparse(url)
46
+ path = parsed_url.path
47
+ if path and "." in Path(path).name:
48
+ return Path(path).suffix
49
+
50
+ return ""
51
+
52
+
53
+ def get_cached_fname(url: str, tag: str = "") -> str:
54
+ """Generate cache filename with optional tag prefix and URL hash.
55
+
56
+ Args:
57
+ url: The URL to hash
58
+ tag: Optional human-readable tag prefix (empty string for no tag)
59
+
60
+ Returns:
61
+ Filename in format: {tag}_{hash}.{ext} or {hash}.{ext}
62
+ """
63
+ prefix = f"{tag}_" if tag else ""
64
+ hash_part = hashlib.sha256(url.encode("utf-8")).hexdigest()[:32]
65
+ extension = _get_extension(url)
66
+ return f"{prefix}{hash_part}{extension}"
67
+
68
+
69
+ def _get_cached_path(
70
+ eval_name: str,
71
+ url: str,
72
+ tag: str = "",
73
+ ) -> str:
74
+ """Generate fully qualified cache path for a given URL.
75
+
76
+ Args:
77
+ eval_name: Evaluation name (used for cache directory organization)
78
+ url: The remote URL
79
+ tag: Optional human-readable tag for cache filename (empty string for no tag)
80
+
81
+ """
82
+ return str(INSPECT_EVALS_CACHE_PATH / eval_name / get_cached_fname(url, tag))
83
+
84
+
85
+ def is_transient_error(e: Exception) -> bool:
86
+ """Check if error is transient and should be retried.
87
+
88
+ Handles both HTTP errors from requests and network errors from fsspec.
89
+ """
90
+ # Network errors that should be retried
91
+ if isinstance(e, (ConnectionError, TimeoutError, OSError)):
92
+ return True
93
+
94
+ # Note: HTTPError import removed as we no longer use requests,
95
+ # but keeping this check for potential future compatibility
96
+ status_code = getattr(getattr(e, "response", None), "status_code", None)
97
+ if status_code is not None:
98
+ return status_code in TRANSIENT_ERRORS
99
+
100
+ return False
101
+
102
+
103
+ @backoff.on_exception( # type: ignore
104
+ backoff.expo,
105
+ exception=Exception,
106
+ giveup=lambda e: not is_transient_error(e),
107
+ max_tries=5,
108
+ jitter=backoff.full_jitter,
109
+ )
110
+ def _download_remote(
111
+ remote_url: str,
112
+ local_cache_path: str,
113
+ encoding: str = "utf-8",
114
+ fs_options: dict[str, Any] | None = None,
115
+ ) -> None:
116
+ """Downloads from remote_url and puts contents into local_cache_path.
117
+
118
+ Uses fsspec-based file() context manager to support multiple protocols
119
+ (HTTP, S3, GCS, Azure, etc.)
120
+
121
+ Args:
122
+ remote_url: Remote URL (supports http://, s3://, gs://, az://, etc.)
123
+ local_cache_path: Local path to cache the file
124
+ encoding: Text encoding for the remote file (default: utf-8). The cache
125
+ is always written as UTF-8.
126
+ fs_options: Filesystem options (e.g., S3 credentials, anonymous access)
127
+ """
128
+ Path(local_cache_path).parent.mkdir(parents=True, exist_ok=True)
129
+
130
+ fs_opts = fs_options or {}
131
+ with (
132
+ file(remote_url, "r", encoding=encoding, fs_options=fs_opts) as f_remote,
133
+ open(local_cache_path, "w", encoding="utf-8") as f_local,
134
+ ):
135
+ f_local.write(f_remote.read())
136
+
137
+
138
+ def cache_dataset_loader() -> Callable[
139
+ [Callable[Concatenate[str, P], Dataset]], Callable[Concatenate[str, P], Dataset]
140
+ ]:
141
+ """Decorator factory that adds caching functionality to dataset loader functions.
142
+
143
+ The cache directory is inferred from the format_extension parameter.
144
+
145
+ Returns:
146
+ A decorator that wraps dataset loaders with caching logic
147
+ """
148
+
149
+ def decorate(
150
+ f: Callable[Concatenate[str, P], Dataset],
151
+ ) -> Callable[Concatenate[str, P], Dataset]:
152
+ @wraps(f)
153
+ def wrapper(
154
+ file_path: str,
155
+ eval_name: str,
156
+ *args: P.args,
157
+ **kwargs: P.kwargs,
158
+ ) -> Dataset:
159
+ # Extract caching-specific parameters from kwargs
160
+ refresh: bool = bool(kwargs.pop("refresh", False))
161
+ cache_tag: str = str(kwargs.pop("cache_tag", ""))
162
+ encoding: str = kwargs.get("encoding", "utf-8") # type: ignore[assignment]
163
+ fs_options: dict[str, Any] | None = kwargs.get("fs_options") # type: ignore[assignment]
164
+
165
+ if not is_remote_url(file_path):
166
+ logger.info(f"Reading local file {file_path}")
167
+ return f(file_path, *args, **kwargs)
168
+
169
+ # Validate that we can detect extension from remote URL
170
+ extension = _get_extension(file_path)
171
+ if not extension:
172
+ raise ValueError(
173
+ f"Cannot detect file extension from URL: {file_path}. "
174
+ "Please ensure the URL ends with .json, .jsonl, or .csv"
175
+ )
176
+
177
+ cached_path = _get_cached_path(
178
+ eval_name=eval_name,
179
+ url=file_path,
180
+ tag=cache_tag,
181
+ )
182
+
183
+ cache_exists = os.path.exists(cached_path)
184
+ cache_is_empty = cache_exists and os.path.getsize(cached_path) == 0
185
+ if refresh or not cache_exists or cache_is_empty:
186
+ logger.info(f"Downloading remote url {file_path} and writing to {cached_path}")
187
+ _download_remote(file_path, cached_path, encoding, fs_options)
188
+
189
+ # `encoding` describes the remote file; the cached copy is always UTF-8.
190
+ kwargs["encoding"] = "utf-8"
191
+
192
+ logger.info(f"Loading cached file from {cached_path}")
193
+ try:
194
+ return f(cached_path, *args, **kwargs)
195
+ except ValueError:
196
+ # This will catch UnicodeDecodeError and JSONDecodeError,
197
+ # as they are both ValueError subclasses.
198
+ # It will NOT catch _csv.Error, which is raised by csv.reader
199
+ # if using a strict dialect and the file is malformed.
200
+
201
+ if refresh:
202
+ raise
203
+
204
+ # Parsing errors from json/csv readers should not poison future
205
+ # runs. Re-download and retry once; if parsing still fails,
206
+ # surface the second ValueError to the caller.
207
+ logger.warning(
208
+ f"Failed to load cached dataset at {cached_path}; "
209
+ "re-downloading and retrying once."
210
+ )
211
+ _download_remote(file_path, cached_path, encoding, fs_options)
212
+ return f(cached_path, *args, **kwargs)
213
+
214
+ return cast(Callable[Concatenate[str, P], Dataset], wrapper)
215
+
216
+ return decorate
217
+
218
+
219
+ # Type stub functions for mypy
220
+ def load_json_dataset(
221
+ file_path: str,
222
+ eval_name: str,
223
+ sample_fields: Any = None,
224
+ auto_id: bool = False,
225
+ shuffle: bool = False,
226
+ seed: int | None = None,
227
+ limit: int | bool | None = None,
228
+ epoch: int | None = None,
229
+ name: str = "",
230
+ fs_options: dict[str, Any] | None = None,
231
+ *,
232
+ encoding: str = "utf-8",
233
+ refresh: bool = False,
234
+ cache_tag: str | None = None,
235
+ **reader_kwargs: Any,
236
+ ) -> Dataset:
237
+ """Load a JSON or JSONL dataset with caching support for remote URLs.
238
+
239
+ Args:
240
+ file_path: Local path or remote URL to the JSON/JSONL file (extension auto-detected)
241
+ eval_name: Evaluation name (organizes cache directory)
242
+ sample_fields: Field specification or converter function for samples
243
+ auto_id: Automatically generate sample IDs
244
+ shuffle: Shuffle the dataset
245
+ seed: Random seed for shuffling
246
+ limit: Maximum number of samples to load
247
+ epoch: Epoch number for sampling
248
+ name: Dataset name
249
+ fs_options: Filesystem options for remote URLs
250
+ encoding: Text encoding of the source file. Remote files are cached as UTF-8.
251
+ refresh: Force re-download of cached remote files
252
+ cache_tag: Optional human-readable tag for cache filename
253
+ reader_kwargs: Optional JSON reader options.
254
+
255
+ Returns:
256
+ Dataset object
257
+ """
258
+ raise NotImplementedError # replaced by the decorated implementation below
259
+
260
+
261
+ def load_csv_dataset(
262
+ file_path: str,
263
+ eval_name: str,
264
+ sample_fields: Any = None,
265
+ auto_id: bool = False,
266
+ shuffle: bool = False,
267
+ seed: int | None = None,
268
+ limit: int | bool | None = None,
269
+ epoch: int | None = None,
270
+ name: str = "",
271
+ fieldnames: list[str] | None = None,
272
+ dialect: str = "unix",
273
+ delimiter: str | None = None,
274
+ encoding: str = "utf-8",
275
+ fs_options: dict[str, Any] | None = None,
276
+ shuffle_choices: bool | int = False,
277
+ *,
278
+ refresh: bool = False,
279
+ cache_tag: str | None = None,
280
+ ) -> Dataset:
281
+ """Load a CSV dataset with caching support for remote URLs.
282
+
283
+ Args:
284
+ file_path: Local path or remote URL to the CSV file (extension auto-detected)
285
+ eval_name: Evaluation name (organizes cache directory)
286
+ sample_fields: Field specification or converter function for samples
287
+ auto_id: Automatically generate sample IDs
288
+ shuffle: Shuffle the dataset
289
+ seed: Random seed for shuffling
290
+ limit: Maximum number of samples to load
291
+ epoch: Epoch number for sampling
292
+ name: Dataset name
293
+ fieldnames: CSV field names
294
+ dialect: CSV dialect
295
+ delimiter: CSV delimiter character
296
+ encoding: Text encoding of the source file. Remote files are cached as UTF-8.
297
+ fs_options: Filesystem options for remote URLs
298
+ shuffle_choices: Shuffle multiple choice options. An int is used as the
299
+ shuffle seed (as in inspect_ai's ``csv_dataset``); True shuffles unseeded.
300
+ refresh: Force re-download of cached remote files
301
+ cache_tag: Optional human-readable tag for cache filename
302
+
303
+ Returns:
304
+ Dataset object
305
+ """
306
+ raise NotImplementedError # replaced by the decorated implementation below
307
+
308
+
309
+ # Actual decorated implementations
310
+ load_json_dataset = cache_dataset_loader()(json_dataset) # type: ignore[assignment,arg-type] # noqa: F811
311
+ load_csv_dataset = cache_dataset_loader()(csv_dataset) # type: ignore[assignment,arg-type] # noqa: F811