spanlight 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.
spanlight/_context.py ADDED
@@ -0,0 +1,79 @@
1
+ """Propagation of the active span through :mod:`contextvars`.
2
+
3
+ Using a :class:`~contextvars.ContextVar` means the current span follows the
4
+ natural flow of the program: nested function calls see their caller's span,
5
+ and :mod:`asyncio` tasks inherit the span that was active when they were
6
+ created (each task runs in a copy of its creator's context). Threads do not
7
+ inherit context automatically; use :func:`contextvars.copy_context` and
8
+ ``ctx.run(...)`` when handing work to a thread pool.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ from collections.abc import Iterator
15
+ from contextvars import ContextVar, Token
16
+
17
+ from ._span import Span
18
+
19
+ _current_span: ContextVar[Span | None] = ContextVar("spanlight_current_span", default=None)
20
+ _current_prompt_version_id: ContextVar[str | None] = ContextVar(
21
+ "spanlight_current_prompt_version_id", default=None
22
+ )
23
+
24
+
25
+ def get_current_span() -> Span | None:
26
+ """Return the span active in the current context, if any."""
27
+ return _current_span.get()
28
+
29
+
30
+ def get_current_trace_id() -> str | None:
31
+ """Return the trace id of the active span, if any."""
32
+ span = _current_span.get()
33
+ return span.trace_id if span is not None else None
34
+
35
+
36
+ @contextlib.contextmanager
37
+ def activate(span: Span) -> Iterator[Span]:
38
+ """Make ``span`` the current span for the duration of the ``with`` block."""
39
+ token = _current_span.set(span)
40
+ try:
41
+ yield span
42
+ finally:
43
+ _current_span.reset(token)
44
+
45
+
46
+ def set_current_span(span: Span | None) -> Token[Span | None]:
47
+ """Set the current span and return a token for :func:`restore_current_span`."""
48
+ return _current_span.set(span)
49
+
50
+
51
+ def restore_current_span(token: Token[Span | None], previous: Span | None) -> None:
52
+ """Undo :func:`set_current_span`.
53
+
54
+ ``ContextVar.reset`` refuses tokens created in a different context (for
55
+ example a ``with`` block entered in one task and exited in another). In
56
+ that case the previous span is restored by value instead of raising.
57
+ """
58
+ try:
59
+ _current_span.reset(token)
60
+ except ValueError:
61
+ _current_span.set(previous)
62
+
63
+
64
+ def get_current_prompt_version_id() -> str | None:
65
+ """Return the prompt version id spans started now are linked to, if any."""
66
+ return _current_prompt_version_id.get()
67
+
68
+
69
+ def set_current_prompt_version_id(version_id: str | None) -> Token[str | None]:
70
+ """Link spans started from now on to ``version_id``; returns a token to undo it."""
71
+ return _current_prompt_version_id.set(version_id)
72
+
73
+
74
+ def restore_current_prompt_version_id(token: Token[str | None], previous: str | None) -> None:
75
+ """Undo :func:`set_current_prompt_version_id`, by value when the token is foreign."""
76
+ try:
77
+ _current_prompt_version_id.reset(token)
78
+ except ValueError:
79
+ _current_prompt_version_id.set(previous)
@@ -0,0 +1,345 @@
1
+ """Implementation of the ``@observe`` decorator.
2
+
3
+ The decorator supports four kinds of callables, each needing slightly
4
+ different treatment so that the span covers the *real* duration of the work:
5
+
6
+ * plain functions: the span covers the call;
7
+ * coroutine functions: the span covers the awaited coroutine;
8
+ * generator functions: the span stays open until the generator is exhausted,
9
+ closed or fails, and the current-span context is active only while the
10
+ generator body runs (never leaking into the consumer between items);
11
+ * async generator functions: same as generators, for ``async for``.
12
+
13
+ In every case user exceptions propagate unchanged, and failures inside the
14
+ SDK itself are swallowed so they cannot break the decorated function.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import functools
20
+ import inspect
21
+ import logging
22
+ from collections.abc import AsyncGenerator, Callable, Generator
23
+ from dataclasses import dataclass
24
+ from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast
25
+
26
+ from ._context import get_current_span, restore_current_span, set_current_span
27
+ from ._serialize import MAX_ITEMS
28
+ from ._span import Span, SpanKind
29
+
30
+ if TYPE_CHECKING:
31
+ from ._client import Spanlight
32
+
33
+ logger = logging.getLogger("spanlight")
34
+
35
+ P = ParamSpec("P")
36
+ R = TypeVar("R")
37
+
38
+ _IMPLICIT_RECEIVERS = frozenset({"self", "cls"})
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class _ObserveOptions:
43
+ name: str
44
+ kind: SpanKind
45
+ capture_input: bool
46
+ capture_output: bool
47
+
48
+
49
+ def build_observe(
50
+ get_client: Callable[[], Spanlight],
51
+ name: Callable[P, R] | str | None,
52
+ *,
53
+ kind: SpanKind,
54
+ capture_input: bool,
55
+ capture_output: bool,
56
+ ) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
57
+ """Shared implementation behind both ``observe`` entry points.
58
+
59
+ ``name`` is either the span name (``@observe(name="answer")`` or
60
+ ``@observe("answer")``), ``None`` (``@observe()``), or, for the bare
61
+ ``@observe`` form, the decorated function itself.
62
+ """
63
+ span_name = name if isinstance(name, str) else None
64
+
65
+ def decorator(func: Callable[P, R]) -> Callable[P, R]:
66
+ options = _ObserveOptions(
67
+ name=span_name or str(getattr(func, "__name__", "function")),
68
+ kind=kind,
69
+ capture_input=capture_input,
70
+ capture_output=capture_output,
71
+ )
72
+ return _decorate(func, get_client, options)
73
+
74
+ if callable(name):
75
+ return decorator(name)
76
+ return decorator
77
+
78
+
79
+ def _decorate(
80
+ func: Callable[P, R],
81
+ get_client: Callable[[], Spanlight],
82
+ options: _ObserveOptions,
83
+ ) -> Callable[P, R]:
84
+ signature = _safe_signature(func)
85
+
86
+ def start(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Span | None:
87
+ """Start the span for one call, or return ``None`` to run untraced."""
88
+ try:
89
+ client = get_client()
90
+ if not client.enabled:
91
+ return None
92
+ span = client.start_span(options.name, kind=options.kind)
93
+ if options.capture_input:
94
+ span.set_input(_bind_arguments(signature, args, kwargs))
95
+ return span
96
+ except Exception:
97
+ logger.debug("observe: failed to start span %r", options.name, exc_info=True)
98
+ return None
99
+
100
+ if inspect.isasyncgenfunction(func):
101
+ return cast("Callable[P, R]", _wrap_async_generator(func, start, options))
102
+ if inspect.isgeneratorfunction(func):
103
+ return cast("Callable[P, R]", _wrap_generator(func, start, options))
104
+ if inspect.iscoroutinefunction(func):
105
+ return cast("Callable[P, R]", _wrap_coroutine(func, start, options))
106
+ return _wrap_function(func, start, options)
107
+
108
+
109
+ _Starter = Callable[[tuple[Any, ...], dict[str, Any]], Span | None]
110
+
111
+
112
+ def _wrap_function(
113
+ func: Callable[P, R],
114
+ start: _Starter,
115
+ options: _ObserveOptions,
116
+ ) -> Callable[P, R]:
117
+ @functools.wraps(func)
118
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
119
+ span = start(args, kwargs)
120
+ if span is None:
121
+ return func(*args, **kwargs)
122
+
123
+ previous = get_current_span()
124
+ token = set_current_span(span)
125
+ try:
126
+ result = func(*args, **kwargs)
127
+ except BaseException as exc:
128
+ _finish(span, error=exc)
129
+ raise
130
+ finally:
131
+ restore_current_span(token, previous)
132
+ _finish(span, output=result, capture_output=options.capture_output)
133
+ return result
134
+
135
+ return wrapper
136
+
137
+
138
+ def _wrap_coroutine(
139
+ func: Callable[..., Any],
140
+ start: _Starter,
141
+ options: _ObserveOptions,
142
+ ) -> Callable[..., Any]:
143
+ @functools.wraps(func)
144
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
145
+ span = start(args, kwargs)
146
+ if span is None:
147
+ return await func(*args, **kwargs)
148
+
149
+ previous = get_current_span()
150
+ token = set_current_span(span)
151
+ try:
152
+ result = await func(*args, **kwargs)
153
+ except BaseException as exc:
154
+ _finish(span, error=exc)
155
+ raise
156
+ finally:
157
+ restore_current_span(token, previous)
158
+ _finish(span, output=result, capture_output=options.capture_output)
159
+ return result
160
+
161
+ return wrapper
162
+
163
+
164
+ def _wrap_generator(
165
+ func: Callable[..., Generator[Any, Any, Any]],
166
+ start: _Starter,
167
+ options: _ObserveOptions,
168
+ ) -> Callable[..., Generator[Any, Any, Any]]:
169
+ @functools.wraps(func)
170
+ def wrapper(*args: Any, **kwargs: Any) -> Generator[Any, Any, Any]:
171
+ span = start(args, kwargs)
172
+ generator = func(*args, **kwargs)
173
+ if span is None:
174
+ return generator
175
+ return _traced_generator(generator, span, options)
176
+
177
+ return wrapper
178
+
179
+
180
+ def _traced_generator(
181
+ generator: Generator[Any, Any, Any],
182
+ span: Span,
183
+ options: _ObserveOptions,
184
+ ) -> Generator[Any, Any, Any]:
185
+ """Drive ``generator`` step by step with ``span`` active only during each step.
186
+
187
+ ``send()``, ``throw()`` and ``close()`` from the consumer are forwarded
188
+ to the inner generator, so the wrapper is transparent.
189
+ """
190
+ collected = _OutputCollector(enabled=options.capture_output)
191
+ step: Callable[[], Any] = functools.partial(generator.send, None)
192
+ while True:
193
+ previous = get_current_span()
194
+ token = set_current_span(span)
195
+ try:
196
+ item = step()
197
+ except StopIteration as stop:
198
+ _finish(
199
+ span, output=collected.result(stop.value), capture_output=options.capture_output
200
+ )
201
+ return stop.value
202
+ except BaseException as exc:
203
+ _finish(span, error=exc)
204
+ raise
205
+ finally:
206
+ restore_current_span(token, previous)
207
+
208
+ collected.add(item)
209
+ try:
210
+ sent = yield item
211
+ except GeneratorExit:
212
+ try:
213
+ generator.close()
214
+ finally:
215
+ _finish(span, output=collected.result(None), capture_output=options.capture_output)
216
+ raise
217
+ except BaseException as exc:
218
+ step = functools.partial(generator.throw, exc)
219
+ else:
220
+ step = functools.partial(generator.send, sent)
221
+
222
+
223
+ def _wrap_async_generator(
224
+ func: Callable[..., AsyncGenerator[Any, Any]],
225
+ start: _Starter,
226
+ options: _ObserveOptions,
227
+ ) -> Callable[..., AsyncGenerator[Any, Any]]:
228
+ @functools.wraps(func)
229
+ def wrapper(*args: Any, **kwargs: Any) -> AsyncGenerator[Any, Any]:
230
+ span = start(args, kwargs)
231
+ generator = func(*args, **kwargs)
232
+ if span is None:
233
+ return generator
234
+ return _traced_async_generator(generator, span, options)
235
+
236
+ return wrapper
237
+
238
+
239
+ async def _traced_async_generator(
240
+ generator: AsyncGenerator[Any, Any],
241
+ span: Span,
242
+ options: _ObserveOptions,
243
+ ) -> AsyncGenerator[Any, Any]:
244
+ """Async counterpart of :func:`_traced_generator`."""
245
+ collected = _OutputCollector(enabled=options.capture_output)
246
+ step: Callable[[], Any] = functools.partial(generator.asend, None)
247
+ while True:
248
+ previous = get_current_span()
249
+ token = set_current_span(span)
250
+ try:
251
+ item = await step()
252
+ except StopAsyncIteration:
253
+ _finish(span, output=collected.result(None), capture_output=options.capture_output)
254
+ return
255
+ except BaseException as exc:
256
+ _finish(span, error=exc)
257
+ raise
258
+ finally:
259
+ restore_current_span(token, previous)
260
+
261
+ collected.add(item)
262
+ try:
263
+ sent = yield item
264
+ except GeneratorExit:
265
+ try:
266
+ await generator.aclose()
267
+ finally:
268
+ _finish(span, output=collected.result(None), capture_output=options.capture_output)
269
+ raise
270
+ except BaseException as exc:
271
+ step = functools.partial(generator.athrow, exc)
272
+ else:
273
+ step = functools.partial(generator.asend, sent)
274
+
275
+
276
+ class _OutputCollector:
277
+ """Accumulates yielded items (bounded) to use as a generator span's output."""
278
+
279
+ def __init__(self, *, enabled: bool) -> None:
280
+ self._enabled = enabled
281
+ self._items: list[Any] = []
282
+ self.truncated = False
283
+
284
+ def add(self, item: Any) -> None:
285
+ if not self._enabled:
286
+ return
287
+ if len(self._items) < MAX_ITEMS:
288
+ self._items.append(item)
289
+ else:
290
+ self.truncated = True
291
+
292
+ def result(self, return_value: Any) -> Any:
293
+ """Return the items, joined into one string when they are all strings."""
294
+ if not self._enabled:
295
+ return None
296
+ if self._items and all(isinstance(item, str) for item in self._items):
297
+ return "".join(self._items)
298
+ if not self._items and return_value is not None:
299
+ return return_value
300
+ return list(self._items)
301
+
302
+
303
+ def _finish(
304
+ span: Span,
305
+ *,
306
+ output: Any = None,
307
+ capture_output: bool = False,
308
+ error: BaseException | None = None,
309
+ ) -> None:
310
+ """End ``span`` recording either its output or the error. Never raises."""
311
+ try:
312
+ if error is not None:
313
+ if not isinstance(error, GeneratorExit):
314
+ span.record_error(error)
315
+ elif capture_output:
316
+ span.set_output(output)
317
+ span.end()
318
+ except Exception:
319
+ logger.debug("observe: failed to finish span", exc_info=True)
320
+
321
+
322
+ def _safe_signature(func: Callable[..., Any]) -> inspect.Signature | None:
323
+ try:
324
+ return inspect.signature(func)
325
+ except (TypeError, ValueError):
326
+ return None
327
+
328
+
329
+ def _bind_arguments(
330
+ signature: inspect.Signature | None,
331
+ args: tuple[Any, ...],
332
+ kwargs: dict[str, Any],
333
+ ) -> dict[str, Any]:
334
+ """Map call arguments to parameter names, omitting ``self``/``cls``."""
335
+ if signature is None:
336
+ return {"args": list(args), "kwargs": kwargs}
337
+ try:
338
+ bound = signature.bind_partial(*args, **kwargs)
339
+ except TypeError:
340
+ return {"args": list(args), "kwargs": kwargs}
341
+ arguments = dict(bound.arguments)
342
+ parameters = list(signature.parameters)
343
+ if parameters and parameters[0] in _IMPLICIT_RECEIVERS:
344
+ arguments.pop(parameters[0], None)
345
+ return arguments
@@ -0,0 +1,256 @@
1
+ """Run a prompt over a dataset on the server and gate on the scores.
2
+
3
+ Use it as ``python -m spanlight._experiments``.
4
+
5
+ The runner talks to the dashboard API (``/api/v1``) with a personal access token. It finds the
6
+ dataset, prompt version and evaluators by name, reuses or creates the experiment for that
7
+ combination, starts a run and polls it until it ends. Exit codes: 0 every threshold held, 1 a
8
+ score fell below its ``--fail-below`` threshold, 2 the run could not be started, failed or timed
9
+ out. The ``eval-gate`` GitHub Action wraps this module.
10
+
11
+ The token is read from ``SPANLIGHT_TOKEN`` and never printed.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import os
18
+ import sys
19
+ import time
20
+ from collections.abc import Callable, Mapping, Sequence
21
+ from decimal import Decimal, InvalidOperation
22
+ from typing import Any
23
+
24
+ import httpx
25
+
26
+ from ._experiments_api import (
27
+ Api,
28
+ ExperimentError,
29
+ ResolvedPrompt,
30
+ resolve_dataset,
31
+ resolve_evaluators,
32
+ resolve_prompt,
33
+ )
34
+ from ._experiments_parse import PromptRef, parse_fail_below, parse_prompt_ref
35
+ from ._experiments_report import render_markdown
36
+ from ._experiments_runs import (
37
+ ExperimentOutcome,
38
+ org_of,
39
+ outcome_of,
40
+ start_run,
41
+ wait,
42
+ wait_idle,
43
+ )
44
+ from ._experiments_setup import experiment_for, normalize_credential_id
45
+
46
+ __all__ = [
47
+ "ExperimentError",
48
+ "ExperimentOutcome",
49
+ "PromptRef",
50
+ "main",
51
+ "parse_fail_below",
52
+ "parse_prompt_ref",
53
+ "run_experiment",
54
+ ]
55
+
56
+ EXIT_PASSED = 0
57
+ EXIT_THRESHOLD = 1
58
+ EXIT_FAILED = 2
59
+ _MAX_COST_CEILING = Decimal(1000)
60
+
61
+
62
+ def _prepare(
63
+ api: Api,
64
+ dataset: str,
65
+ prompt: str,
66
+ evaluators: list[str],
67
+ credential_id: str,
68
+ model: str,
69
+ max_cost_usd: Decimal | None,
70
+ ) -> tuple[dict[str, Any], ResolvedPrompt]:
71
+ """Resolve the names to ids and find or create the experiment."""
72
+ dataset_row = resolve_dataset(api, dataset)
73
+ version = resolve_prompt(api, parse_prompt_ref(prompt))
74
+ experiment = experiment_for(
75
+ api,
76
+ dataset_row,
77
+ version,
78
+ evaluators,
79
+ resolve_evaluators(api, evaluators),
80
+ credential_id=credential_id,
81
+ model=model,
82
+ max_cost_usd=max_cost_usd,
83
+ )
84
+ return experiment, version
85
+
86
+
87
+ def run_experiment(
88
+ *,
89
+ host: str,
90
+ token: str,
91
+ project_id: str,
92
+ dataset: str,
93
+ prompt: str,
94
+ evaluators: list[str],
95
+ fail_below: dict[str, Decimal],
96
+ credential_id: str,
97
+ model: str,
98
+ max_cost_usd: Decimal | None = None,
99
+ timeout_s: int = 1800,
100
+ poll_s: float = 5,
101
+ transport: httpx.BaseTransport | None = None,
102
+ environ: Mapping[str, str] | None = None,
103
+ sleep: Callable[[float], None] = time.sleep,
104
+ clock: Callable[[], float] = time.monotonic,
105
+ ) -> ExperimentOutcome:
106
+ """Run ``prompt`` (``name``, ``name@label`` or ``name:version``) over ``dataset`` and score it.
107
+
108
+ ``credential_id`` and ``model`` choose the provider the prompt runs on and, with the
109
+ evaluators, which experiment is used (a different combination is a different experiment).
110
+ ``max_cost_usd`` caps a run and applies only when the experiment is created. The whole call,
111
+ including waiting for an earlier run of the same experiment, is bounded by ``timeout_s``.
112
+ Raises :class:`ExperimentError` when the run cannot be set up; a run that fails or times out
113
+ is returned with that ``status``.
114
+ """
115
+ env = os.environ if environ is None else environ
116
+ credential_id = normalize_credential_id(credential_id)
117
+ api = Api(host=host, token=token, project_id=project_id, transport=transport)
118
+ try:
119
+ deadline = clock() + timeout_s
120
+ experiment, version = _prepare(
121
+ api, dataset, prompt, evaluators, credential_id, model, max_cost_usd
122
+ )
123
+ url = f"{host.rstrip('/')}/{org_of(api)}/{project_id}/experiments/{experiment['id']}"
124
+ wait_idle(api, experiment["id"], deadline, poll_s, sleep, clock)
125
+ run = start_run(api, experiment["id"], version.version_id, env)
126
+ try:
127
+ final = wait(api, experiment["id"], run["id"], deadline, poll_s, sleep, clock)
128
+ except ExperimentError as error:
129
+ # The run exists: report it with its id rather than lose it.
130
+ final = {"status": "failed", "error": str(error), "totals": None}
131
+ finally:
132
+ api.close()
133
+ title = f"{dataset} / {version.name} v{version.version}"
134
+ return outcome_of(final, run["id"], url, title, timeout_s, fail_below)
135
+
136
+
137
+ def _poll_seconds(raw: str) -> float:
138
+ """``--poll``: at least one second, so the gate cannot hammer the server."""
139
+ value = float(raw)
140
+ if value < 1:
141
+ raise argparse.ArgumentTypeError("must be at least 1 second")
142
+ return value
143
+
144
+
145
+ def _max_cost(raw: str) -> Decimal:
146
+ """``--max-cost-usd``: a positive amount of at most the server's ceiling of 1000."""
147
+ try:
148
+ value = Decimal(raw)
149
+ except InvalidOperation:
150
+ raise argparse.ArgumentTypeError("must be a number") from None
151
+ if not value.is_finite() or not 0 < value <= _MAX_COST_CEILING:
152
+ raise argparse.ArgumentTypeError("must be above 0 and at most 1000")
153
+ return value
154
+
155
+
156
+ def _parser() -> argparse.ArgumentParser:
157
+ parser = argparse.ArgumentParser(
158
+ prog="python -m spanlight._experiments",
159
+ description="Run a prompt over a dataset and fail when a score is below its threshold. "
160
+ "The access token is read from SPANLIGHT_TOKEN.",
161
+ )
162
+ env = os.environ.get
163
+ parser.add_argument("--host", default=env("SPANLIGHT_HOST"), help="Spanlight URL (https)")
164
+ parser.add_argument("--project-id", default=env("SPANLIGHT_PROJECT_ID"))
165
+ parser.add_argument("--dataset", required=True, help="dataset name")
166
+ parser.add_argument("--prompt", required=True, help="name, name@label or name:version")
167
+ parser.add_argument("--evaluators", default="", help="comma-separated evaluator names")
168
+ parser.add_argument("--fail-below", default="", help="e.g. relevance=0.8,format=1")
169
+ parser.add_argument("--credential-id", default=None, help="provider credential to run on")
170
+ parser.add_argument("--model", default=None, help="model to run the prompt on")
171
+ parser.add_argument(
172
+ "--max-cost-usd", type=_max_cost, default=None, help="run cost cap (new experiments)"
173
+ )
174
+ parser.add_argument("--timeout", type=int, default=1800, help="seconds to wait in total")
175
+ parser.add_argument("--poll", type=_poll_seconds, default=5.0, help="seconds between polls")
176
+ return parser
177
+
178
+
179
+ def _publish(outcome: ExperimentOutcome, markdown: str) -> None:
180
+ """Print the report; inside GitHub Actions also fill the step summary and step outputs."""
181
+ print(markdown)
182
+ summary = os.environ.get("GITHUB_STEP_SUMMARY")
183
+ if summary:
184
+ with open(summary, "a", encoding="utf-8") as handle:
185
+ handle.write(markdown + "\n")
186
+ outputs = os.environ.get("GITHUB_OUTPUT")
187
+ if outputs:
188
+ with open(outputs, "a", encoding="utf-8") as handle:
189
+ handle.write(f"run-id={outcome.run_id}\nrun-url={outcome.url}\n")
190
+
191
+
192
+ def _missing_settings(args: argparse.Namespace, token: str | None) -> list[str]:
193
+ """What the caller left out, as messages."""
194
+ checks = (
195
+ ("--host or SPANLIGHT_HOST is required", args.host),
196
+ ("--project-id or SPANLIGHT_PROJECT_ID is required", args.project_id),
197
+ ("--credential-id is required", args.credential_id),
198
+ ("--model is required", args.model),
199
+ ("SPANLIGHT_TOKEN is required", token),
200
+ )
201
+ return [message for message, value in checks if not value]
202
+
203
+
204
+ def _gate(args: argparse.Namespace, token: str) -> int:
205
+ """Run the experiment, publish the report and return the exit code."""
206
+ fail_below = parse_fail_below(args.fail_below)
207
+ outcome = run_experiment(
208
+ host=args.host,
209
+ token=token,
210
+ project_id=args.project_id,
211
+ dataset=args.dataset,
212
+ prompt=args.prompt,
213
+ evaluators=[name.strip() for name in args.evaluators.split(",") if name.strip()],
214
+ fail_below=fail_below,
215
+ credential_id=args.credential_id,
216
+ model=args.model,
217
+ max_cost_usd=args.max_cost_usd,
218
+ timeout_s=args.timeout,
219
+ poll_s=args.poll,
220
+ )
221
+ report = render_markdown(
222
+ title=outcome.title,
223
+ status=outcome.status,
224
+ error=outcome.error,
225
+ url=outcome.url,
226
+ totals=outcome.totals,
227
+ fail_below=fail_below,
228
+ failures=outcome.failures,
229
+ )
230
+ _publish(outcome, report)
231
+ if outcome.status != "done":
232
+ return EXIT_FAILED
233
+ return EXIT_PASSED if outcome.passed else EXIT_THRESHOLD
234
+
235
+
236
+ def main(argv: Sequence[str] | None = None) -> int:
237
+ """Run the gate. Returns the exit code: 0 passed, 1 a threshold missed, 2 anything else."""
238
+ args = _parser().parse_args(argv)
239
+ token = os.environ.get("SPANLIGHT_TOKEN")
240
+ problems = _missing_settings(args, token)
241
+ if problems:
242
+ print("error: " + "; ".join(problems), file=sys.stderr)
243
+ return EXIT_FAILED
244
+ try:
245
+ return _gate(args, token or "")
246
+ except (ExperimentError, ValueError) as error:
247
+ print(f"error: {error}", file=sys.stderr)
248
+ except Exception as error:
249
+ # Exit 1 means "a score fell below its threshold", so a bug or an answer of an
250
+ # unexpected shape must not look like one. The type only: a repr could carry a header.
251
+ print(f"error: unexpected {type(error).__name__}", file=sys.stderr)
252
+ return EXIT_FAILED
253
+
254
+
255
+ if __name__ == "__main__":
256
+ sys.exit(main())