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.
@@ -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())