meterline-anthropic-admin 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,18 @@
1
+ """meterline-anthropic-admin: reads the Anthropic Admin Usage and Cost API of a Console organisation and emits meterline records."""
2
+
3
+ from importlib.metadata import PackageNotFoundError as _NotInstalled, version as _version
4
+
5
+ try:
6
+ __version__ = _version("meterline-anthropic-admin") # the one source is pyproject.toml
7
+ except _NotInstalled: # a source tree that is not installed
8
+ __version__ = "0+unknown"
9
+
10
+ # after __version__: client.py reads it
11
+ from meterline import RunResult # noqa: E402
12
+
13
+ from .cli import run # noqa: E402
14
+ from .client import ApiError, PaginationError # noqa: E402
15
+
16
+ # The public interface is the command line, ``run`` and its ``RunResult``, the error classes below, the records the
17
+ # commands write (README, "Public interface") and the documented ``testing`` module; every other module is internal.
18
+ __all__ = ["ApiError", "PaginationError", "RunResult", "__version__", "run"]
@@ -0,0 +1,596 @@
1
+ """Command line: ``meterline-anthropic-admin backfill | daily | rebuild``.
2
+
3
+ Exit codes: 0 ok, 1 API or transport failure (state kept, a re-run resumes), 2 checks failed, a response of a shape
4
+ the connector does not know, or a configuration error.
5
+
6
+ The key is read from ``ANTHROPIC_ADMIN_KEY`` in ``run`` (or comes from its ``credentials`` argument) and nowhere
7
+ else; it is never read from a keychain. The key
8
+ goes to the client and nowhere further: not to a file, a log line, a manifest or the state file.
9
+
10
+ Layout under the data directory::
11
+
12
+ raw/<endpoint>/<window>/<pulled_at>.json.gz every response page as received
13
+ runs/<batch_id>.json one manifest per run
14
+ records/<kind>/<batch_id>.jsonl published records
15
+ state.json what is loaded, plus a ``pending`` block
16
+ """
17
+
18
+ import argparse
19
+ import logging
20
+ import os
21
+ import random
22
+ import shutil
23
+ import sys
24
+ import time
25
+ from datetime import date, datetime, timedelta, timezone
26
+ from decimal import Decimal
27
+ from pathlib import Path
28
+ from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple, Union
29
+
30
+ from meterline import RunResult, note_failure, result_of_run, run_manifests
31
+ from meterline.run import (
32
+ EXIT_PERMANENT,
33
+ EXIT_TRANSIENT,
34
+ WritePhase,
35
+ exit_code_of_status,
36
+ unknown_fields_of,
37
+ warn_unknown_fields,
38
+ )
39
+ from meterline.state import STATE_NAME as STATE_NAME
40
+ from meterline.state import StateError as StateError
41
+ from meterline.state import is_day_list, is_relative_path, read_state, state_damaged_message, write_state
42
+ from meterline.writers import write_jsonl, write_parquet
43
+
44
+ from .client import MAX_RETRY_AFTER_SECONDS, AdminClient, ApiError, PaginationError
45
+ from .fetch import (
46
+ COST,
47
+ USAGE,
48
+ RawFileExists,
49
+ RunManifest,
50
+ backward_windows,
51
+ batch_id_for,
52
+ fetch_endpoint,
53
+ forward_windows,
54
+ window_label,
55
+ )
56
+ from .normalise import (
57
+ KINDS,
58
+ NormalisedRun,
59
+ drop_unchanged,
60
+ first_open_day,
61
+ is_final_day,
62
+ load_published,
63
+ normalise_run,
64
+ read_raw,
65
+ run_checks,
66
+ )
67
+
68
+ logger = logging.getLogger("meterline_anthropic_admin.cli")
69
+
70
+ KEY_ENV = "ANTHROPIC_ADMIN_KEY"
71
+ # the evidence level of the shapes, said where a user meets the command (docs/glossary.md)
72
+ SHAPES_NOTE = ("Evidence level: verified live, on a small Console organisation (one model, two days of usage); "
73
+ "cache tokens, priority tier and several workspaces not yet seen. If a live response differs, "
74
+ "follow Response shapes in the package README.")
75
+ DEFAULT_DATA_DIR = "exports/meterline-anthropic-admin"
76
+ PRIVATE_UMASK = 0o077 # files 0600, folders 0700
77
+ REREAD_DAYS = 3 # a day is final 48 hours after it ended, so the three days before today are read once more
78
+
79
+ EXIT_OK = 0
80
+ EXIT_API = 1
81
+ EXIT_CHECKS = 2
82
+
83
+
84
+ # ---- state ------------------------------------------------------------------------------------------------------
85
+
86
+
87
+ def _empty_state() -> Dict[str, Any]:
88
+ return {"days_loaded": [], "pending": None}
89
+
90
+
91
+ def _check_state(state: Any) -> None:
92
+ """Raise StateError (naming the first thing wrong, never a value) unless ``state`` has the shape ``_write_state`` writes."""
93
+ if not isinstance(state, dict):
94
+ raise StateError("it does not hold a JSON object")
95
+ for key in _empty_state():
96
+ if key not in state:
97
+ raise StateError("the key %s is missing" % key)
98
+ if not is_day_list(state["days_loaded"]):
99
+ raise StateError("days_loaded is not a list of YYYY-MM-DD dates")
100
+ pending = state["pending"]
101
+ if pending is None:
102
+ return
103
+ if not isinstance(pending, dict) or not isinstance(pending.get("command"), str):
104
+ raise StateError("pending is not a block with a command")
105
+ steps = pending.get("steps")
106
+ if not isinstance(steps, dict) or not all(isinstance(k, str) and is_relative_path(v) for k, v in steps.items()):
107
+ raise StateError("pending.steps is not a map of step names to relative file paths")
108
+
109
+
110
+ def _read_state(data_dir: Path) -> Optional[Dict[str, Any]]:
111
+ """The state file as a dict, None when there is none. Raises StateError
112
+ when it is there but damaged."""
113
+ return read_state(data_dir, _check_state)
114
+
115
+
116
+ def _state_damaged(data_dir: Path, exc: StateError) -> int:
117
+ print(state_damaged_message(data_dir, exc), file=sys.stderr)
118
+ return EXIT_CHECKS
119
+
120
+
121
+ _write_state = write_state
122
+
123
+
124
+ class _Stepper:
125
+ """Runs the fetch steps of one run. A step that finished in an earlier, failed run of the same command is reused,
126
+ not requested again."""
127
+
128
+ def __init__(self, data_dir: Path, state: Dict[str, Any], command: str) -> None:
129
+ self.data_dir = data_dir
130
+ self.state = state
131
+ self.command = command
132
+ self.done: Dict[str, str] = {}
133
+ pending = state.get("pending")
134
+ if isinstance(pending, dict) and pending.get("command") == command:
135
+ self.done = dict(pending.get("steps") or {})
136
+ self.order: List[str] = []
137
+
138
+ def has(self, label: str) -> bool:
139
+ return label in self.done
140
+
141
+ def path(self, label: str) -> Path:
142
+ self.order.append(label)
143
+ return self.data_dir / self.done[label]
144
+
145
+ def record(self, label: str, path: Path) -> Path:
146
+ self.order.append(label)
147
+ self.done[label] = Path(path).relative_to(self.data_dir).as_posix()
148
+ self.state["pending"] = {"command": self.command, "steps": dict(sorted(self.done.items()))}
149
+ _write_state(self.data_dir, self.state)
150
+ return Path(path)
151
+
152
+ def raw_files(self) -> List[str]:
153
+ return [self.done[label] for label in self.order]
154
+
155
+
156
+ # ---- collecting and publishing ---------------------------------------------------------------------------------------
157
+
158
+
159
+ def _collect(client: AdminClient, data_dir: Path, stepper: _Stepper, pulled_at: datetime, windows: Sequence[Tuple[date, date]],
160
+ provider_account_id: str, workspace_id: Optional[str]) -> None:
161
+ """Fetch everything one run needs: both reports for every window."""
162
+ for first, last in windows:
163
+ for endpoint in (USAGE, COST):
164
+ label = "%s:%s" % (endpoint, window_label(first, last))
165
+ if stepper.has(label):
166
+ stepper.path(label)
167
+ continue
168
+ pull = fetch_endpoint(client, data_dir, endpoint, first=first, last=last, pulled_at=pulled_at, provider_account_id=provider_account_id,
169
+ workspace_id=workspace_id)
170
+ stepper.record(label, pull.path)
171
+
172
+
173
+ def _totals_text(run: NormalisedRun) -> Dict[str, str]:
174
+ """Token and cost figures per day and model (or product), as text: what the run report compares with the Console."""
175
+ out: Dict[str, str] = {}
176
+ for rec in run.org_totals:
177
+ who = rec.model or rec.product
178
+ for name in ("tokens_input_uncached", "tokens_cache_read", "tokens_cache_write", "tokens_output", "cost"):
179
+ value = getattr(rec, name)
180
+ if value is not None:
181
+ out["%s.%s.%s" % (rec.period_start.isoformat(), who, name)] = format(value, "f") if isinstance(value, Decimal) else str(value)
182
+ return out
183
+
184
+
185
+ def _publish(run: NormalisedRun, published: Dict[str, Dict[tuple, Any]], records_root: Path, batch_id: str,
186
+ parquet_for: Callable[[str], bool]) -> Dict[str, int]:
187
+ counts: Dict[str, int] = {}
188
+ for kind, cls in KINDS.items():
189
+ changed = drop_unchanged(run.records(kind), published.get(kind, {}))
190
+ counts[kind] = len(changed)
191
+ if not changed:
192
+ continue
193
+ target = records_root / kind / (batch_id + ".jsonl")
194
+ write_jsonl(changed, target)
195
+ if parquet_for(kind):
196
+ write_parquet(changed, target.with_suffix(".parquet"), cls)
197
+ return counts
198
+
199
+
200
+ def _fail_run(command: str, batch_id: str, pulled_at: datetime, raw_rel: Sequence[str], failures: List[str], data_dir: Path,
201
+ state: Dict[str, Any]) -> None:
202
+ RunManifest(command=command, batch_id=batch_id, pulled_at=pulled_at, status="failed", raw_files=list(raw_rel), failures=failures).write(data_dir)
203
+ state["pending"] = None
204
+ _write_state(data_dir, state)
205
+
206
+
207
+ def _loadable(covered: Sequence[Tuple[date, date]], present: Set[date], pulled_at: datetime) -> Tuple[Set[date], int]:
208
+ """The days a run may record as loaded, and how many of them came from an answer with no rows.
209
+
210
+ A day with rows is loaded. A day without rows is loaded only once it is final (48 hours after it ended): before that
211
+ the provider may still fill it in, and the day stays unloaded."""
212
+ loaded: Set[date] = set()
213
+ empty = 0
214
+ for first, last in covered:
215
+ for n in range((last - first).days + 1):
216
+ day = first + timedelta(days=n)
217
+ if day in present or is_final_day(day, pulled_at):
218
+ loaded.add(day)
219
+ empty += day not in present
220
+ return loaded, empty
221
+
222
+
223
+ def _finish(command: str, data_dir: Path, state: Dict[str, Any], stepper: _Stepper, pulled_at: datetime,
224
+ covered: Sequence[Tuple[date, date]], parquet: bool) -> int:
225
+ batch_id = batch_id_for(pulled_at)
226
+ raw_rel = stepper.raw_files()
227
+ published = load_published(data_dir)
228
+ try:
229
+ run = normalise_run([data_dir / p for p in raw_rel], batch_id, pulled_at)
230
+ except ValueError as exc: # ShapeError is a ValueError: a response that is not what the shapes promise
231
+ _fail_run(command, batch_id, pulled_at, raw_rel, ["raw data not usable: " + str(exc)], data_dir, state)
232
+ print("raw data not usable: nothing published (%s)" % exc, file=sys.stderr)
233
+ return EXIT_CHECKS
234
+ failures = run_checks(run, pulled_at.date())
235
+ if failures:
236
+ _fail_run(command, batch_id, pulled_at, raw_rel, failures, data_dir, state)
237
+ print("checks failed: nothing published (%d failures)" % len(failures), file=sys.stderr)
238
+ for message in failures:
239
+ print(" - " + message, file=sys.stderr)
240
+ return EXIT_CHECKS
241
+
242
+ unknown = unknown_fields_of(run.unknown_fields)
243
+ warn_unknown_fields("meterline-anthropic-admin", unknown)
244
+ with WritePhase(data_dir, batch_id) as phase:
245
+ counts = _publish(run, published, data_dir / "records", batch_id, lambda _kind: parquet)
246
+ pages = sum(len(read_raw(data_dir / rel)["pages"]) for rel in raw_rel)
247
+ newly_loaded, empty_days = _loadable(covered, run.data_days, pulled_at)
248
+ RunManifest(
249
+ command=command, batch_id=batch_id, pulled_at=pulled_at, status="published", windows=list(stepper.order), pages=pages,
250
+ row_counts=dict(run.row_counts), totals=_totals_text(run), empty_days=empty_days, raw_files=list(raw_rel),
251
+ published_counts=counts, unknown_fields=unknown,
252
+ ).write(data_dir)
253
+ loaded = {date.fromisoformat(d) for d in state.get("days_loaded") or []} | newly_loaded
254
+ state["days_loaded"] = sorted(d.isoformat() for d in loaded)
255
+ state["pending"] = None
256
+ _write_state(data_dir, state)
257
+ if phase.failed:
258
+ return EXIT_TRANSIENT
259
+ print("ok: %s published %s" % (command, ", ".join("%s=%d" % kv for kv in sorted(counts.items()))), file=sys.stdout)
260
+ return EXIT_OK
261
+
262
+
263
+ def _same_second(data_dir: Path, pulled_at: datetime) -> bool:
264
+ """True, with a message, when a finished run already carries this second as its batch id."""
265
+ if (data_dir / "runs" / (batch_id_for(pulled_at) + ".json")).exists():
266
+ print("a run already finished in the same second (batch %s); wait a second and run again. Nothing was fetched or "
267
+ "changed." % batch_id_for(pulled_at), file=sys.stderr)
268
+ return True
269
+ return False
270
+
271
+
272
+ # ---- commands ---------------------------------------------------------------------------------------------------------
273
+
274
+
275
+ def _read_now(now: Callable[[], datetime]) -> datetime:
276
+ value = now()
277
+ if value.tzinfo is None:
278
+ raise ValueError("now() must return a timezone-aware datetime")
279
+ # whole seconds: the batch id and the manifest carry no fraction, and a rebuild must reproduce the pulled_at
280
+ # of the published records exactly
281
+ return value.astimezone(timezone.utc).replace(microsecond=0)
282
+
283
+
284
+ def _run(command: str, client: AdminClient, data_dir: Path, windows: Sequence[Tuple[date, date]], pulled_at: datetime, provider_account_id: str,
285
+ workspace_id: Optional[str], parquet: bool) -> int:
286
+ try:
287
+ state = _read_state(data_dir) or _empty_state()
288
+ except StateError as exc:
289
+ return _state_damaged(data_dir, exc)
290
+ if _same_second(data_dir, pulled_at):
291
+ return EXIT_CHECKS
292
+ stepper = _Stepper(data_dir, state, command)
293
+ try:
294
+ _collect(client, data_dir, stepper, pulled_at, windows, provider_account_id, workspace_id)
295
+ except RawFileExists as exc:
296
+ print("%s; nothing was published. Wait a second and run again." % exc, file=sys.stderr)
297
+ return EXIT_CHECKS
298
+ except ApiError as exc:
299
+ _write_state(data_dir, state)
300
+ return _api_failed(exc.status)
301
+ except PaginationError as exc: # the pages of a query do not add up: nothing from it is used
302
+ _fail_run(command, batch_id_for(pulled_at), pulled_at, stepper.raw_files(), ["pagination: " + str(exc)], data_dir, state)
303
+ print("pagination: nothing published (%s)" % exc, file=sys.stderr)
304
+ return EXIT_CHECKS
305
+ return _finish(command, data_dir, state, stepper, pulled_at, windows, parquet)
306
+
307
+
308
+ def _api_failed(status: int) -> int:
309
+ """Exit 1 (transient) or 3 (permanent) for an API failure that stopped the run; the status goes to the RunResult."""
310
+ code = exit_code_of_status(status)
311
+ note_failure(status)
312
+ if code == EXIT_PERMANENT:
313
+ print(
314
+ "permanent: the API refused the request (HTTP status %d); fix the key, its scope or the identifier it "
315
+ "names, then run the same command again. Progress is kept." % status,
316
+ file=sys.stderr,
317
+ )
318
+ else:
319
+ print(
320
+ "transient: re-run later. API request failed (HTTP status %d); progress is kept, run the same command "
321
+ "again to resume" % status,
322
+ file=sys.stderr,
323
+ )
324
+ return code
325
+
326
+
327
+ def run_backfill(client: AdminClient, data_dir: Path, since: date, *, provider_account_id: str, now: Callable[[], datetime],
328
+ workspace_id: Optional[str] = None, parquet: bool = False) -> int:
329
+ pulled_at = _read_now(now)
330
+ yesterday = pulled_at.date() - timedelta(days=1)
331
+ windows = backward_windows(since, yesterday) if since <= yesterday else []
332
+ return _run("backfill", client, Path(data_dir), windows, pulled_at, provider_account_id, workspace_id, parquet)
333
+
334
+
335
+ def _daily_start(data_dir: Path, state: Optional[Dict[str, Any]], since: Optional[date], today: date) -> Optional[date]:
336
+ """The first day ``daily`` reads, or None (with a message) when it cannot tell."""
337
+ if state is None:
338
+ # without it, daily cannot tell what is loaded and would quietly fetch a long stretch again
339
+ if since is None:
340
+ print("no state file %s: daily cannot tell which days are already loaded. Pass --since YYYY-MM-DD to start from that "
341
+ "day, or run backfill --since YYYY-MM-DD." % (data_dir / STATE_NAME), file=sys.stderr)
342
+ return None
343
+ print("note: no state file %s; starting at %s" % (data_dir / STATE_NAME, since.isoformat()), file=sys.stderr)
344
+ return since
345
+ if since is not None:
346
+ print("note: the state file exists, so --since is ignored", file=sys.stderr)
347
+ loaded = [date.fromisoformat(d) for d in state.get("days_loaded") or []]
348
+ start = (max(loaded) + timedelta(days=1)) if loaded else today - timedelta(days=REREAD_DAYS)
349
+ open_day = first_open_day(load_published(data_dir)) # a day published before it was final is read until it is
350
+ return min(start, today - timedelta(days=REREAD_DAYS), open_day or start)
351
+
352
+
353
+ def run_daily(client: AdminClient, data_dir: Path, *, provider_account_id: str, now: Callable[[], datetime], workspace_id: Optional[str] = None,
354
+ parquet: bool = False, since: Optional[date] = None) -> int:
355
+ data_dir = Path(data_dir)
356
+ pulled_at = _read_now(now)
357
+ today = pulled_at.date()
358
+ try:
359
+ state = _read_state(data_dir)
360
+ except StateError as exc:
361
+ return _state_damaged(data_dir, exc)
362
+ start = _daily_start(data_dir, state, since, today)
363
+ if start is None:
364
+ return EXIT_CHECKS
365
+ windows = forward_windows(start, today - timedelta(days=1))
366
+ return _run("daily", client, data_dir, windows, pulled_at, provider_account_id, workspace_id, parquet)
367
+
368
+
369
+ def run_rebuild(data_dir: Path, parquet: bool = False) -> int:
370
+ """Re-create ``records/`` from the raw store and the run manifests. No network, no key."""
371
+ data_dir = Path(data_dir)
372
+ runs_dir = data_dir / "runs"
373
+ manifests: List[RunManifest] = []
374
+ if runs_dir.is_dir():
375
+ manifests = [m for m in (RunManifest.read(p) for p in sorted(runs_dir.glob("*.json"))) if m.status == "published"]
376
+ manifests.sort(key=lambda m: m.batch_id)
377
+
378
+ work = data_dir / ".rebuild"
379
+ if work.exists():
380
+ shutil.rmtree(work)
381
+ new_records = work / "records"
382
+ new_records.mkdir(parents=True)
383
+ old_records = data_dir / "records"
384
+
385
+ totals: Dict[str, int] = {kind: 0 for kind in KINDS}
386
+ for manifest in manifests:
387
+ paths = [data_dir / rel for rel in manifest.raw_files]
388
+ missing = [p for p in paths if not p.is_file()]
389
+ if missing:
390
+ print("rebuild: %d raw files missing for run %s" % (len(missing), manifest.batch_id), file=sys.stderr)
391
+ shutil.rmtree(work)
392
+ return EXIT_CHECKS
393
+ published = load_published(work)
394
+ try:
395
+ run = normalise_run(paths, manifest.batch_id, manifest.pulled_at)
396
+ except ValueError as exc: # ShapeError is a ValueError
397
+ print("rebuild: raw data not usable for run %s (%s)" % (manifest.batch_id, exc), file=sys.stderr)
398
+ shutil.rmtree(work)
399
+ return EXIT_CHECKS
400
+ failures = run_checks(run, manifest.pulled_at.astimezone(timezone.utc).date())
401
+ if failures:
402
+ print("rebuild: checks failed for run %s (%d failures); records left as they were" % (manifest.batch_id, len(failures)),
403
+ file=sys.stderr)
404
+ shutil.rmtree(work)
405
+ return EXIT_CHECKS
406
+
407
+ def want_parquet(kind: str, batch: str = manifest.batch_id) -> bool:
408
+ return parquet or (old_records / kind / (batch + ".parquet")).exists()
409
+
410
+ for kind, n in _publish(run, published, new_records, manifest.batch_id, want_parquet).items():
411
+ totals[kind] += n
412
+
413
+ retired = data_dir / "records.old"
414
+ if retired.exists():
415
+ shutil.rmtree(retired)
416
+ if old_records.exists():
417
+ os.replace(old_records, retired)
418
+ os.replace(new_records, old_records)
419
+ if retired.exists():
420
+ shutil.rmtree(retired)
421
+ shutil.rmtree(work)
422
+ print("ok: rebuild of %d runs wrote %s" % (len(manifests), ", ".join("%s=%d" % kv for kv in sorted(totals.items()))), file=sys.stdout)
423
+ return EXIT_OK
424
+
425
+
426
+ # ---- entry point -----------------------------------------------------------------------------------------------------
427
+
428
+
429
+ def _parse_date(text: str) -> date:
430
+ try:
431
+ return date.fromisoformat(text)
432
+ except ValueError:
433
+ raise argparse.ArgumentTypeError("expected a date as YYYY-MM-DD, got %r" % text) from None
434
+
435
+
436
+ def _build_parser() -> argparse.ArgumentParser:
437
+ common = argparse.ArgumentParser(add_help=False)
438
+ common.add_argument("--data-dir", default=DEFAULT_DATA_DIR, help="where raw, runs, records and state live")
439
+ common.add_argument("--parquet", action="store_true", help="also write <batch_id>.parquet (needs the parquet extra)")
440
+ pull = argparse.ArgumentParser(add_help=False)
441
+ pull.add_argument("--provider-account-id", required=True,
442
+ help="a label for this organisation; becomes provider_account_id (not the Anthropic organisation id)")
443
+ pull.add_argument("--workspace-id", default=None, help="only this workspace (optional; the default is every workspace)")
444
+ pull.add_argument("--rate-per-minute", type=int, default=20, help="request budget per minute (default 20)")
445
+ pull.add_argument("--max-retry-after", type=int, default=MAX_RETRY_AFTER_SECONDS,
446
+ help="longest wait in seconds a Retry-After header can cause (default 300)")
447
+
448
+ parser = argparse.ArgumentParser(
449
+ prog="meterline-anthropic-admin",
450
+ description="Read the Anthropic Admin Usage and Cost API of a Console organisation and write meterline records. "
451
+ "The Admin key is read from %s." % KEY_ENV,
452
+ epilog=SHAPES_NOTE,
453
+ )
454
+ sub = parser.add_subparsers(dest="command", required=True)
455
+ back = sub.add_parser("backfill", parents=[common, pull], help="walk 31-day windows back from yesterday; resumes after a failure")
456
+ back.add_argument("--since", type=_parse_date, required=True, help="first day to load, YYYY-MM-DD")
457
+ daily = sub.add_parser("daily", parents=[common, pull], help="every closed day since the last one loaded, and every day not yet final")
458
+ daily.add_argument("--since", type=_parse_date, default=None, help="first day to load; needed only when there is no state file")
459
+ sub.add_parser("rebuild", parents=[common], help="re-create records from the raw store; no API calls")
460
+ return parser
461
+
462
+
463
+ def _utc_now() -> datetime:
464
+ return datetime.now(timezone.utc)
465
+
466
+
467
+ Credentials = Union[Mapping[str, str], Callable[[], Mapping[str, str]], None]
468
+
469
+ _PULL_OPTIONS = ("provider_account_id", "workspace_id", "rate_per_minute", "max_retry_after", "parquet")
470
+ _COMMAND_OPTIONS = {
471
+ "backfill": _PULL_OPTIONS + ("since",),
472
+ "daily": _PULL_OPTIONS + ("since",),
473
+ "rebuild": ("parquet",),
474
+ }
475
+
476
+
477
+ def _date_option(name: str, value: Any) -> Optional[date]:
478
+ if value is None or (isinstance(value, date) and not isinstance(value, datetime)):
479
+ return value
480
+ if isinstance(value, str):
481
+ try:
482
+ return date.fromisoformat(value)
483
+ except ValueError:
484
+ pass
485
+ raise ValueError("%s must be a date or a string YYYY-MM-DD" % name)
486
+
487
+
488
+ def run(
489
+ command: str,
490
+ *,
491
+ data_dir: Union[str, Path],
492
+ credentials: Credentials = None,
493
+ transport: Any = None,
494
+ clock: Callable[[], Any] = time.monotonic,
495
+ sleep: Callable[[Any], Any] = time.sleep,
496
+ now: Optional[Callable[[], datetime]] = None,
497
+ jitter: Callable[[], Any] = random.random,
498
+ wall: Callable[[], Any] = time.time,
499
+ **options: Any,
500
+ ) -> RunResult:
501
+ """Run one command (``backfill``, ``daily`` or ``rebuild``) and return its ``RunResult``.
502
+
503
+ ``options`` are the command line options under their Python names (``since``, ``provider_account_id``, ``workspace_id``,
504
+ ``rate_per_minute``, ``max_retry_after``, ``parquet``), with the same defaults; an unknown name raises
505
+ ``TypeError``. ``credentials`` is a mapping ``{"ANTHROPIC_ADMIN_KEY": key}`` or a function returning one: when it
506
+ is given the environment is not read for the key, and a name missing from it counts as unset. A failed run does
507
+ not raise: ``RunResult.exit_code`` is the code the command line exits with, after the same messages on stderr.
508
+ Everything written under the data directory is readable by its owner only (files 0600, folders 0700), whatever
509
+ the caller's umask: the process umask is 077 for the duration of the command and is put back afterwards."""
510
+ if command not in _COMMAND_OPTIONS:
511
+ raise ValueError("command must be one of backfill, daily, rebuild, not %r" % (command,))
512
+ unknown = sorted(set(options) - set(_COMMAND_OPTIONS[command]))
513
+ if unknown:
514
+ raise TypeError("run(%r) got unexpected option(s): %s" % (command, ", ".join(unknown)))
515
+ data_path = Path(data_dir)
516
+ secrets = credentials() if callable(credentials) else credentials
517
+ before = run_manifests(data_path)
518
+ old_umask = os.umask(PRIVATE_UMASK)
519
+ try:
520
+ code = _run_command(command, data_path, secrets, credentials is not None, options, transport=transport, clock=clock,
521
+ sleep=sleep, now=now, jitter=jitter, wall=wall)
522
+ finally:
523
+ os.umask(old_umask)
524
+ return result_of_run(data_path, code, before)
525
+
526
+
527
+ def _run_command(command: str, data_dir: Path, secrets: Optional[Mapping[str, str]], given: bool, options: Dict[str, Any], *,
528
+ transport: Any, clock: Callable[[], Any], sleep: Callable[[Any], Any], now: Optional[Callable[[], datetime]],
529
+ jitter: Callable[[], Any], wall: Callable[[], Any]) -> int:
530
+ parquet = bool(options.get("parquet", False))
531
+ if command == "rebuild":
532
+ return run_rebuild(data_dir, parquet=parquet)
533
+
534
+ since = _date_option("since", options.get("since"))
535
+ provider_account_id = options.get("provider_account_id")
536
+ if provider_account_id is None:
537
+ raise TypeError("run(%r) needs the option provider_account_id" % command)
538
+ if command == "backfill" and since is None:
539
+ raise TypeError("run('backfill') needs the option since")
540
+ rate_per_minute = options.get("rate_per_minute", 20)
541
+ max_retry_after = options.get("max_retry_after", MAX_RETRY_AFTER_SECONDS)
542
+ if rate_per_minute < 1:
543
+ print("--rate-per-minute must be at least 1", file=sys.stderr)
544
+ return EXIT_CHECKS
545
+ if max_retry_after < 0:
546
+ print("--max-retry-after must not be negative", file=sys.stderr)
547
+ return EXIT_CHECKS
548
+ key = (secrets or {}).get(KEY_ENV, "") if given else os.environ.get(KEY_ENV, "")
549
+ if not key:
550
+ print("%s must be set in the environment (an Admin key, sk-ant-admin..., of the organisation)" % KEY_ENV, file=sys.stderr)
551
+ return EXIT_CHECKS
552
+ if not provider_account_id.strip():
553
+ print("--provider-account-id must not be empty", file=sys.stderr)
554
+ return EXIT_CHECKS
555
+
556
+ workspace_id = options.get("workspace_id")
557
+ clock_now = now if now is not None else _utc_now
558
+ client = AdminClient(
559
+ key, transport=transport, rate_per_minute=rate_per_minute, clock=clock, sleep=sleep, jitter=jitter, wall=wall,
560
+ max_retry_after=max_retry_after,
561
+ )
562
+ with client:
563
+ if command == "backfill":
564
+ return run_backfill(client, data_dir, since, provider_account_id=provider_account_id, now=clock_now,
565
+ workspace_id=workspace_id, parquet=parquet)
566
+ return run_daily(client, data_dir, provider_account_id=provider_account_id, now=clock_now, workspace_id=workspace_id,
567
+ parquet=parquet, since=since)
568
+
569
+
570
+ def main(
571
+ argv: Optional[Sequence[str]] = None,
572
+ *,
573
+ transport: Any = None,
574
+ clock: Callable[[], Any] = time.monotonic,
575
+ sleep: Callable[[Any], Any] = time.sleep,
576
+ now: Optional[Callable[[], datetime]] = None,
577
+ jitter: Callable[[], Any] = random.random,
578
+ wall: Callable[[], Any] = time.time,
579
+ ) -> int:
580
+ """Run one command from the command line and return its exit code; the work is ``run``. The key is read from the
581
+ environment."""
582
+ args = _build_parser().parse_args(argv)
583
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
584
+ for noisy in ("httpx", "httpcore"): # the HTTP libraries log every request URL
585
+ logging.getLogger(noisy).setLevel(logging.WARNING)
586
+ options: Dict[str, Any] = {"parquet": args.parquet}
587
+ if args.command != "rebuild":
588
+ options.update(provider_account_id=args.provider_account_id, workspace_id=args.workspace_id, rate_per_minute=args.rate_per_minute,
589
+ max_retry_after=args.max_retry_after, since=args.since)
590
+ result = run(args.command, data_dir=args.data_dir, credentials=None, transport=transport, clock=clock, sleep=sleep, now=now,
591
+ jitter=jitter, wall=wall, **options)
592
+ return result.exit_code
593
+
594
+
595
+ if __name__ == "__main__": # pragma: no cover
596
+ sys.exit(main())