contextdb-memory-ci 0.1.0a1__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.
- contextdb_memory_ci/__init__.py +55 -0
- contextdb_memory_ci/cli.py +545 -0
- contextdb_memory_ci/client.py +555 -0
- contextdb_memory_ci/errors.py +52 -0
- contextdb_memory_ci/models.py +625 -0
- contextdb_memory_ci/py.typed +1 -0
- contextdb_memory_ci-0.1.0a1.dist-info/METADATA +128 -0
- contextdb_memory_ci-0.1.0a1.dist-info/RECORD +12 -0
- contextdb_memory_ci-0.1.0a1.dist-info/WHEEL +4 -0
- contextdb_memory_ci-0.1.0a1.dist-info/entry_points.txt +2 -0
- contextdb_memory_ci-0.1.0a1.dist-info/licenses/LICENSE +190 -0
- contextdb_memory_ci-0.1.0a1.dist-info/licenses/NOTICE +10 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Public Python client for ContextDB Memory CI."""
|
|
2
|
+
|
|
3
|
+
from contextdb_memory_ci.client import (
|
|
4
|
+
DEFAULT_BASE_URL,
|
|
5
|
+
EXPORT_RESPONSE_CAP,
|
|
6
|
+
STATUS_RESPONSE_CAP,
|
|
7
|
+
MemoryCiClient,
|
|
8
|
+
validate_base_url,
|
|
9
|
+
)
|
|
10
|
+
from contextdb_memory_ci.errors import (
|
|
11
|
+
ApiError,
|
|
12
|
+
ConfigError,
|
|
13
|
+
MalformedResponseError,
|
|
14
|
+
MemoryCiError,
|
|
15
|
+
OutputError,
|
|
16
|
+
ResponseTooLargeError,
|
|
17
|
+
RunTimeout,
|
|
18
|
+
TransportError,
|
|
19
|
+
)
|
|
20
|
+
from contextdb_memory_ci.models import (
|
|
21
|
+
ExportFormat,
|
|
22
|
+
MemoryCiRun,
|
|
23
|
+
RegressionState,
|
|
24
|
+
RunCounts,
|
|
25
|
+
RunLimits,
|
|
26
|
+
RunProgress,
|
|
27
|
+
RunState,
|
|
28
|
+
TerminalReason,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"DEFAULT_BASE_URL",
|
|
33
|
+
"EXPORT_RESPONSE_CAP",
|
|
34
|
+
"STATUS_RESPONSE_CAP",
|
|
35
|
+
"ApiError",
|
|
36
|
+
"ConfigError",
|
|
37
|
+
"ExportFormat",
|
|
38
|
+
"MalformedResponseError",
|
|
39
|
+
"MemoryCiClient",
|
|
40
|
+
"MemoryCiError",
|
|
41
|
+
"MemoryCiRun",
|
|
42
|
+
"OutputError",
|
|
43
|
+
"RegressionState",
|
|
44
|
+
"ResponseTooLargeError",
|
|
45
|
+
"RunCounts",
|
|
46
|
+
"RunLimits",
|
|
47
|
+
"RunProgress",
|
|
48
|
+
"RunState",
|
|
49
|
+
"RunTimeout",
|
|
50
|
+
"TerminalReason",
|
|
51
|
+
"TransportError",
|
|
52
|
+
"validate_base_url",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
__version__ = "0.1.0a1"
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"""Command-line interface for public ContextDB Memory CI runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import NoReturn, cast
|
|
18
|
+
|
|
19
|
+
from contextdb_memory_ci.client import DEFAULT_BASE_URL, MemoryCiClient
|
|
20
|
+
from contextdb_memory_ci.errors import (
|
|
21
|
+
ApiError,
|
|
22
|
+
ConfigError,
|
|
23
|
+
MalformedResponseError,
|
|
24
|
+
MemoryCiError,
|
|
25
|
+
OutputError,
|
|
26
|
+
ResponseTooLargeError,
|
|
27
|
+
RunTimeout,
|
|
28
|
+
TransportError,
|
|
29
|
+
)
|
|
30
|
+
from contextdb_memory_ci.models import (
|
|
31
|
+
ExportFormat,
|
|
32
|
+
MemoryCiRun,
|
|
33
|
+
RegressionState,
|
|
34
|
+
RunState,
|
|
35
|
+
validate_idempotency_key,
|
|
36
|
+
validate_safe_id,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
EXIT_SUCCESS = 0
|
|
40
|
+
EXIT_BEHAVIORAL_FAILURE = 1
|
|
41
|
+
EXIT_OPERATIONAL_FAILURE = 2
|
|
42
|
+
MIN_RUN_TIMEOUT_SECONDS = 1.0
|
|
43
|
+
MAX_RUN_TIMEOUT_SECONDS = 86_400.0
|
|
44
|
+
MIN_POLL_INTERVAL_SECONDS = 0.05
|
|
45
|
+
MAX_POLL_INTERVAL_SECONDS = 60.0
|
|
46
|
+
_TOKEN_LIKE_PATTERN = re.compile(r"cbe_[A-Za-z0-9._~-]+")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _SecretSafeArgumentParser(argparse.ArgumentParser):
|
|
50
|
+
def error(self, message: str) -> NoReturn:
|
|
51
|
+
super().error(_TOKEN_LIKE_PATTERN.sub("<redacted>", message))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class RunConfig:
|
|
56
|
+
token: str
|
|
57
|
+
project_id: str
|
|
58
|
+
suite_id: str
|
|
59
|
+
base_url: str
|
|
60
|
+
baseline_run_id: str | None
|
|
61
|
+
idempotency_key: str
|
|
62
|
+
timeout_seconds: float
|
|
63
|
+
poll_interval: float
|
|
64
|
+
json_output: Path | None
|
|
65
|
+
junit_output: Path | None
|
|
66
|
+
github_summary: bool
|
|
67
|
+
allow_no_baseline: bool
|
|
68
|
+
cancel_on_timeout: bool
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
72
|
+
parser = _SecretSafeArgumentParser(
|
|
73
|
+
prog="contextdb-memory-ci",
|
|
74
|
+
description="Run a hosted Memory CI suite and return a CI-safe exit code.",
|
|
75
|
+
)
|
|
76
|
+
subparsers = parser.add_subparsers(
|
|
77
|
+
dest="command",
|
|
78
|
+
required=True,
|
|
79
|
+
parser_class=_SecretSafeArgumentParser,
|
|
80
|
+
)
|
|
81
|
+
run = subparsers.add_parser(
|
|
82
|
+
"run",
|
|
83
|
+
help="start a run, poll to terminal status, and optionally export it",
|
|
84
|
+
)
|
|
85
|
+
run.add_argument("--token", help="evaluation token (or CONTEXTDB_EVAL_TOKEN)")
|
|
86
|
+
run.add_argument(
|
|
87
|
+
"--project-id", help="project ID (or CONTEXTDB_PROJECT_ID)"
|
|
88
|
+
)
|
|
89
|
+
run.add_argument(
|
|
90
|
+
"--suite-id", help="suite ID (or CONTEXTDB_EVAL_SUITE_ID)"
|
|
91
|
+
)
|
|
92
|
+
run.add_argument(
|
|
93
|
+
"--base-url",
|
|
94
|
+
help="API base URL (or CONTEXTDB_EVALS_BASE_URL)",
|
|
95
|
+
)
|
|
96
|
+
run.add_argument(
|
|
97
|
+
"--baseline-run-id",
|
|
98
|
+
help="explicit baseline run ID; omit to use the pinned baseline",
|
|
99
|
+
)
|
|
100
|
+
run.add_argument(
|
|
101
|
+
"--idempotency-key",
|
|
102
|
+
help="stable run key (or CONTEXTDB_EVAL_IDEMPOTENCY_KEY)",
|
|
103
|
+
)
|
|
104
|
+
run.add_argument(
|
|
105
|
+
"--timeout-seconds",
|
|
106
|
+
type=float,
|
|
107
|
+
default=1_800.0,
|
|
108
|
+
help="overall polling timeout, from 1 to 86400 seconds",
|
|
109
|
+
)
|
|
110
|
+
run.add_argument(
|
|
111
|
+
"--poll-interval",
|
|
112
|
+
type=float,
|
|
113
|
+
default=2.0,
|
|
114
|
+
help="seconds between status requests, from 0.05 to 60",
|
|
115
|
+
)
|
|
116
|
+
run.add_argument("--json-output", metavar="PATH")
|
|
117
|
+
run.add_argument("--junit-output", metavar="PATH")
|
|
118
|
+
run.add_argument(
|
|
119
|
+
"--github-summary",
|
|
120
|
+
action="store_true",
|
|
121
|
+
help="append a safe summary to GITHUB_STEP_SUMMARY",
|
|
122
|
+
)
|
|
123
|
+
run.add_argument(
|
|
124
|
+
"--allow-no-baseline",
|
|
125
|
+
action="store_true",
|
|
126
|
+
help="allow a passed run without a baseline to exit zero",
|
|
127
|
+
)
|
|
128
|
+
run.add_argument(
|
|
129
|
+
"--no-cancel-on-timeout",
|
|
130
|
+
action="store_true",
|
|
131
|
+
help="do not request cancellation after timeout or interruption",
|
|
132
|
+
)
|
|
133
|
+
return parser
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main(
|
|
137
|
+
argv: Sequence[str] | None = None,
|
|
138
|
+
*,
|
|
139
|
+
environ: Mapping[str, str] | None = None,
|
|
140
|
+
client_factory: Callable[..., MemoryCiClient] = MemoryCiClient,
|
|
141
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
142
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
143
|
+
uuid_factory: Callable[[], uuid.UUID] = uuid.uuid4,
|
|
144
|
+
) -> int:
|
|
145
|
+
env = os.environ if environ is None else environ
|
|
146
|
+
parser = build_parser()
|
|
147
|
+
args = parser.parse_args(argv)
|
|
148
|
+
|
|
149
|
+
client: MemoryCiClient | None = None
|
|
150
|
+
current_run: MemoryCiRun | None = None
|
|
151
|
+
config: RunConfig | None = None
|
|
152
|
+
try:
|
|
153
|
+
config, generated_local_key = _resolve_config(args, env, uuid_factory)
|
|
154
|
+
if generated_local_key:
|
|
155
|
+
print(config.idempotency_key, file=sys.stderr)
|
|
156
|
+
|
|
157
|
+
client = client_factory(
|
|
158
|
+
config.base_url,
|
|
159
|
+
config.token,
|
|
160
|
+
timeout=min(30.0, config.timeout_seconds),
|
|
161
|
+
)
|
|
162
|
+
deadline = monotonic() + config.timeout_seconds
|
|
163
|
+
current_run = client.create_run(
|
|
164
|
+
config.project_id,
|
|
165
|
+
config.suite_id,
|
|
166
|
+
idempotency_key=config.idempotency_key,
|
|
167
|
+
baseline_run_id=config.baseline_run_id,
|
|
168
|
+
)
|
|
169
|
+
while not current_run.terminal:
|
|
170
|
+
remaining = deadline - monotonic()
|
|
171
|
+
if remaining <= 0:
|
|
172
|
+
raise RunTimeout("run polling timed out")
|
|
173
|
+
sleep(min(config.poll_interval, remaining))
|
|
174
|
+
if monotonic() >= deadline:
|
|
175
|
+
raise RunTimeout("run polling timed out")
|
|
176
|
+
current_run = client.get_run(config.project_id, current_run.id)
|
|
177
|
+
|
|
178
|
+
exit_code = _terminal_exit_code(
|
|
179
|
+
current_run,
|
|
180
|
+
allow_no_baseline=config.allow_no_baseline,
|
|
181
|
+
)
|
|
182
|
+
_print_terminal_status(current_run)
|
|
183
|
+
output_failed = _write_requested_outputs(
|
|
184
|
+
client,
|
|
185
|
+
config,
|
|
186
|
+
current_run,
|
|
187
|
+
env,
|
|
188
|
+
)
|
|
189
|
+
if output_failed:
|
|
190
|
+
return EXIT_OPERATIONAL_FAILURE
|
|
191
|
+
if (
|
|
192
|
+
current_run.status is RunState.PASSED
|
|
193
|
+
and current_run.regression_status is RegressionState.NO_BASELINE
|
|
194
|
+
and not config.allow_no_baseline
|
|
195
|
+
):
|
|
196
|
+
print(
|
|
197
|
+
"operational result: no baseline; pass "
|
|
198
|
+
"--allow-no-baseline to opt in",
|
|
199
|
+
file=sys.stderr,
|
|
200
|
+
)
|
|
201
|
+
return exit_code
|
|
202
|
+
except RunTimeout:
|
|
203
|
+
if (
|
|
204
|
+
client is not None
|
|
205
|
+
and current_run is not None
|
|
206
|
+
and config is not None
|
|
207
|
+
and config.cancel_on_timeout
|
|
208
|
+
):
|
|
209
|
+
_best_effort_cancel(client, config.project_id, current_run.id)
|
|
210
|
+
print("operational error: run polling timed out", file=sys.stderr)
|
|
211
|
+
return EXIT_OPERATIONAL_FAILURE
|
|
212
|
+
except KeyboardInterrupt:
|
|
213
|
+
if (
|
|
214
|
+
client is not None
|
|
215
|
+
and current_run is not None
|
|
216
|
+
and config is not None
|
|
217
|
+
and config.cancel_on_timeout
|
|
218
|
+
):
|
|
219
|
+
_best_effort_cancel(client, config.project_id, current_run.id)
|
|
220
|
+
print("operational error: interrupted", file=sys.stderr)
|
|
221
|
+
return EXIT_OPERATIONAL_FAILURE
|
|
222
|
+
except MemoryCiError as error:
|
|
223
|
+
print(_format_expected_error(error), file=sys.stderr)
|
|
224
|
+
return EXIT_OPERATIONAL_FAILURE
|
|
225
|
+
except Exception:
|
|
226
|
+
print("operational error: unexpected client failure", file=sys.stderr)
|
|
227
|
+
return EXIT_OPERATIONAL_FAILURE
|
|
228
|
+
finally:
|
|
229
|
+
if client is not None:
|
|
230
|
+
try:
|
|
231
|
+
client.close()
|
|
232
|
+
except Exception:
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _resolve_config(
|
|
237
|
+
args: argparse.Namespace,
|
|
238
|
+
env: Mapping[str, str],
|
|
239
|
+
uuid_factory: Callable[[], uuid.UUID],
|
|
240
|
+
) -> tuple[RunConfig, bool]:
|
|
241
|
+
token = _required_value(
|
|
242
|
+
cast(str | None, args.token),
|
|
243
|
+
env.get("CONTEXTDB_EVAL_TOKEN"),
|
|
244
|
+
"evaluation token",
|
|
245
|
+
)
|
|
246
|
+
project_id = validate_safe_id(
|
|
247
|
+
_required_value(
|
|
248
|
+
cast(str | None, args.project_id),
|
|
249
|
+
env.get("CONTEXTDB_PROJECT_ID"),
|
|
250
|
+
"project ID",
|
|
251
|
+
),
|
|
252
|
+
"project ID",
|
|
253
|
+
)
|
|
254
|
+
suite_id = validate_safe_id(
|
|
255
|
+
_required_value(
|
|
256
|
+
cast(str | None, args.suite_id),
|
|
257
|
+
env.get("CONTEXTDB_EVAL_SUITE_ID"),
|
|
258
|
+
"suite ID",
|
|
259
|
+
),
|
|
260
|
+
"suite ID",
|
|
261
|
+
)
|
|
262
|
+
command_base_url = cast(str | None, args.base_url)
|
|
263
|
+
if command_base_url is not None:
|
|
264
|
+
base_url = command_base_url
|
|
265
|
+
else:
|
|
266
|
+
base_url = env.get("CONTEXTDB_EVALS_BASE_URL") or DEFAULT_BASE_URL
|
|
267
|
+
baseline = cast(str | None, args.baseline_run_id)
|
|
268
|
+
if baseline is not None:
|
|
269
|
+
baseline = validate_safe_id(baseline, "baseline run ID")
|
|
270
|
+
|
|
271
|
+
command_key = cast(str | None, args.idempotency_key)
|
|
272
|
+
supplied_key = (
|
|
273
|
+
command_key
|
|
274
|
+
if command_key is not None
|
|
275
|
+
else env.get("CONTEXTDB_EVAL_IDEMPOTENCY_KEY")
|
|
276
|
+
)
|
|
277
|
+
idempotency_key, generated_local_key = _choose_idempotency_key(
|
|
278
|
+
supplied_key,
|
|
279
|
+
env,
|
|
280
|
+
suite_id,
|
|
281
|
+
uuid_factory,
|
|
282
|
+
)
|
|
283
|
+
timeout_seconds = _bounded_number(
|
|
284
|
+
cast(float, args.timeout_seconds),
|
|
285
|
+
"timeout",
|
|
286
|
+
MIN_RUN_TIMEOUT_SECONDS,
|
|
287
|
+
MAX_RUN_TIMEOUT_SECONDS,
|
|
288
|
+
)
|
|
289
|
+
poll_interval = _bounded_number(
|
|
290
|
+
cast(float, args.poll_interval),
|
|
291
|
+
"poll interval",
|
|
292
|
+
MIN_POLL_INTERVAL_SECONDS,
|
|
293
|
+
MAX_POLL_INTERVAL_SECONDS,
|
|
294
|
+
)
|
|
295
|
+
return (
|
|
296
|
+
RunConfig(
|
|
297
|
+
token=token,
|
|
298
|
+
project_id=project_id,
|
|
299
|
+
suite_id=suite_id,
|
|
300
|
+
base_url=base_url,
|
|
301
|
+
baseline_run_id=baseline,
|
|
302
|
+
idempotency_key=idempotency_key,
|
|
303
|
+
timeout_seconds=timeout_seconds,
|
|
304
|
+
poll_interval=poll_interval,
|
|
305
|
+
json_output=_optional_path(cast(str | None, args.json_output)),
|
|
306
|
+
junit_output=_optional_path(cast(str | None, args.junit_output)),
|
|
307
|
+
github_summary=bool(args.github_summary),
|
|
308
|
+
allow_no_baseline=bool(args.allow_no_baseline),
|
|
309
|
+
cancel_on_timeout=not bool(args.no_cancel_on_timeout),
|
|
310
|
+
),
|
|
311
|
+
generated_local_key,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _required_value(
|
|
316
|
+
command_value: str | None,
|
|
317
|
+
environment_value: str | None,
|
|
318
|
+
label: str,
|
|
319
|
+
) -> str:
|
|
320
|
+
value = (
|
|
321
|
+
command_value
|
|
322
|
+
if command_value is not None
|
|
323
|
+
else environment_value
|
|
324
|
+
)
|
|
325
|
+
if value is None or not value:
|
|
326
|
+
raise ConfigError(f"{label} is required")
|
|
327
|
+
return value
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _choose_idempotency_key(
|
|
331
|
+
supplied: str | None,
|
|
332
|
+
env: Mapping[str, str],
|
|
333
|
+
suite_id: str,
|
|
334
|
+
uuid_factory: Callable[[], uuid.UUID],
|
|
335
|
+
) -> tuple[str, bool]:
|
|
336
|
+
if supplied is not None:
|
|
337
|
+
return validate_idempotency_key(supplied), False
|
|
338
|
+
github_run_id = env.get("GITHUB_RUN_ID")
|
|
339
|
+
github_attempt = env.get("GITHUB_RUN_ATTEMPT")
|
|
340
|
+
if github_run_id and github_attempt:
|
|
341
|
+
material = f"{github_run_id}\0{github_attempt}\0{suite_id}".encode()
|
|
342
|
+
digest = hashlib.sha256(material).hexdigest()[:48]
|
|
343
|
+
return f"github-{digest}", False
|
|
344
|
+
return validate_idempotency_key(f"local-{uuid_factory().hex}"), True
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _bounded_number(
|
|
348
|
+
value: float,
|
|
349
|
+
label: str,
|
|
350
|
+
minimum: float,
|
|
351
|
+
maximum: float,
|
|
352
|
+
) -> float:
|
|
353
|
+
if (
|
|
354
|
+
isinstance(value, bool)
|
|
355
|
+
or not isinstance(value, (int, float))
|
|
356
|
+
or not math.isfinite(value)
|
|
357
|
+
or not minimum <= value <= maximum
|
|
358
|
+
):
|
|
359
|
+
raise ConfigError(f"{label} must be between {minimum:g} and {maximum:g}")
|
|
360
|
+
return float(value)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _optional_path(value: str | None) -> Path | None:
|
|
364
|
+
if value is None:
|
|
365
|
+
return None
|
|
366
|
+
if not value or "\0" in value or len(value) > 4_096:
|
|
367
|
+
raise ConfigError("output path is invalid")
|
|
368
|
+
return Path(value)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _terminal_exit_code(
|
|
372
|
+
run: MemoryCiRun,
|
|
373
|
+
*,
|
|
374
|
+
allow_no_baseline: bool,
|
|
375
|
+
) -> int:
|
|
376
|
+
if run.status in {RunState.ERROR, RunState.CANCELLED}:
|
|
377
|
+
return EXIT_OPERATIONAL_FAILURE
|
|
378
|
+
if (
|
|
379
|
+
run.status is RunState.FAILED
|
|
380
|
+
or run.regression_status is RegressionState.REGRESSED
|
|
381
|
+
):
|
|
382
|
+
return EXIT_BEHAVIORAL_FAILURE
|
|
383
|
+
if (
|
|
384
|
+
run.status is RunState.PASSED
|
|
385
|
+
and run.regression_status is RegressionState.UNCHANGED
|
|
386
|
+
):
|
|
387
|
+
return EXIT_SUCCESS
|
|
388
|
+
if (
|
|
389
|
+
run.status is RunState.PASSED
|
|
390
|
+
and run.regression_status is RegressionState.NO_BASELINE
|
|
391
|
+
):
|
|
392
|
+
return EXIT_SUCCESS if allow_no_baseline else EXIT_OPERATIONAL_FAILURE
|
|
393
|
+
return EXIT_OPERATIONAL_FAILURE
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _write_requested_outputs(
|
|
397
|
+
client: MemoryCiClient,
|
|
398
|
+
config: RunConfig,
|
|
399
|
+
run: MemoryCiRun,
|
|
400
|
+
env: Mapping[str, str],
|
|
401
|
+
) -> bool:
|
|
402
|
+
failed = False
|
|
403
|
+
requested = (
|
|
404
|
+
(ExportFormat.JSON, config.json_output),
|
|
405
|
+
(ExportFormat.JUNIT, config.junit_output),
|
|
406
|
+
)
|
|
407
|
+
for export_format, output_path in requested:
|
|
408
|
+
if output_path is None:
|
|
409
|
+
continue
|
|
410
|
+
try:
|
|
411
|
+
payload = client.export_run(
|
|
412
|
+
config.project_id,
|
|
413
|
+
run.id,
|
|
414
|
+
export_format,
|
|
415
|
+
)
|
|
416
|
+
_atomic_write(output_path, payload)
|
|
417
|
+
except MemoryCiError as error:
|
|
418
|
+
print(_format_expected_error(error), file=sys.stderr)
|
|
419
|
+
failed = True
|
|
420
|
+
except Exception:
|
|
421
|
+
print("output error: safe export could not be written", file=sys.stderr)
|
|
422
|
+
failed = True
|
|
423
|
+
|
|
424
|
+
if config.github_summary:
|
|
425
|
+
try:
|
|
426
|
+
summary_value = env.get("GITHUB_STEP_SUMMARY")
|
|
427
|
+
if not summary_value:
|
|
428
|
+
raise ConfigError(
|
|
429
|
+
"GITHUB_STEP_SUMMARY is required with --github-summary"
|
|
430
|
+
)
|
|
431
|
+
summary_path = _optional_path(summary_value)
|
|
432
|
+
if summary_path is None:
|
|
433
|
+
raise ConfigError("GitHub summary path is invalid")
|
|
434
|
+
_atomic_append(summary_path, _github_summary(run).encode("utf-8"))
|
|
435
|
+
except MemoryCiError as error:
|
|
436
|
+
print(_format_expected_error(error), file=sys.stderr)
|
|
437
|
+
failed = True
|
|
438
|
+
except Exception:
|
|
439
|
+
print("output error: GitHub summary could not be written", file=sys.stderr)
|
|
440
|
+
failed = True
|
|
441
|
+
return failed
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _atomic_write(path: Path, payload: bytes) -> None:
|
|
445
|
+
parent = path.parent
|
|
446
|
+
if not parent.is_dir():
|
|
447
|
+
raise OutputError("output parent directory does not exist")
|
|
448
|
+
temporary_name: str | None = None
|
|
449
|
+
try:
|
|
450
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
451
|
+
prefix=f".{path.name}.",
|
|
452
|
+
suffix=".tmp",
|
|
453
|
+
dir=parent,
|
|
454
|
+
)
|
|
455
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
456
|
+
output.write(payload)
|
|
457
|
+
output.flush()
|
|
458
|
+
os.fsync(output.fileno())
|
|
459
|
+
os.replace(temporary_name, path)
|
|
460
|
+
temporary_name = None
|
|
461
|
+
except OSError:
|
|
462
|
+
raise OutputError("safe output could not be written atomically") from None
|
|
463
|
+
finally:
|
|
464
|
+
if temporary_name is not None:
|
|
465
|
+
try:
|
|
466
|
+
os.unlink(temporary_name)
|
|
467
|
+
except OSError:
|
|
468
|
+
pass
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _atomic_append(path: Path, payload: bytes) -> None:
|
|
472
|
+
existing = b""
|
|
473
|
+
try:
|
|
474
|
+
if path.exists():
|
|
475
|
+
existing = path.read_bytes()
|
|
476
|
+
except OSError:
|
|
477
|
+
raise OutputError("GitHub summary could not be read") from None
|
|
478
|
+
_atomic_write(path, existing + payload)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _github_summary(run: MemoryCiRun) -> str:
|
|
482
|
+
reason = (
|
|
483
|
+
run.terminal_reason.value
|
|
484
|
+
if run.terminal_reason is not None
|
|
485
|
+
else "none"
|
|
486
|
+
)
|
|
487
|
+
return (
|
|
488
|
+
"\n## ContextDB Memory CI\n"
|
|
489
|
+
f"- Run: `{run.id}`\n"
|
|
490
|
+
f"- Project: `{run.project_id}`\n"
|
|
491
|
+
f"- Suite: `{run.suite_id}`\n"
|
|
492
|
+
f"- Status: `{run.status.value}`\n"
|
|
493
|
+
f"- Regression: `{run.regression_status.value}`\n"
|
|
494
|
+
f"- Progress: {run.progress.completed}/{run.progress.total}\n"
|
|
495
|
+
"- Counts: "
|
|
496
|
+
f"passed={run.counts.passed}, failed={run.counts.failed}, "
|
|
497
|
+
f"error={run.counts.error}, cancelled={run.counts.cancelled}\n"
|
|
498
|
+
f"- Terminal reason: `{reason}`\n"
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _print_terminal_status(run: MemoryCiRun) -> None:
|
|
503
|
+
reason = (
|
|
504
|
+
run.terminal_reason.value
|
|
505
|
+
if run.terminal_reason is not None
|
|
506
|
+
else "none"
|
|
507
|
+
)
|
|
508
|
+
print(
|
|
509
|
+
f"run={run.id} status={run.status.value} "
|
|
510
|
+
f"regression={run.regression_status.value} "
|
|
511
|
+
f"passed={run.counts.passed} failed={run.counts.failed} "
|
|
512
|
+
f"error={run.counts.error} cancelled={run.counts.cancelled} "
|
|
513
|
+
f"reason={reason}"
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _best_effort_cancel(
|
|
518
|
+
client: MemoryCiClient,
|
|
519
|
+
project_id: str,
|
|
520
|
+
run_id: str,
|
|
521
|
+
) -> None:
|
|
522
|
+
try:
|
|
523
|
+
client.cancel_run(project_id, run_id)
|
|
524
|
+
except BaseException:
|
|
525
|
+
pass
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _format_expected_error(error: MemoryCiError) -> str:
|
|
529
|
+
if isinstance(error, ConfigError):
|
|
530
|
+
return f"configuration error: {error}"
|
|
531
|
+
if isinstance(error, ApiError):
|
|
532
|
+
return f"API error: {error}"
|
|
533
|
+
if isinstance(error, TransportError):
|
|
534
|
+
return "transport error: API request failed"
|
|
535
|
+
if isinstance(error, ResponseTooLargeError):
|
|
536
|
+
return "response error: response exceeded the safe size cap"
|
|
537
|
+
if isinstance(error, MalformedResponseError):
|
|
538
|
+
return f"response error: {error}"
|
|
539
|
+
if isinstance(error, OutputError):
|
|
540
|
+
return f"output error: {error}"
|
|
541
|
+
return "operational error: Memory CI could not complete"
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
if __name__ == "__main__":
|
|
545
|
+
raise SystemExit(main())
|