commoncompute 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,589 @@
1
+ """boto3-shaped AWS Batch shim that talks to Common Compute.
2
+
3
+ Lets a codebase that calls ``boto3.client("batch")`` migrate to Common
4
+ Compute by changing one import line. The methods accept the same
5
+ keyword arguments and return dicts with the same key names that AWS
6
+ Batch returns, so downstream code that reads ``response["jobId"]``,
7
+ ``response["jobs"][0]["status"]``, etc. keeps working.
8
+
9
+ Quick start::
10
+
11
+ # before
12
+ import boto3
13
+ batch = boto3.client("batch", region_name="us-east-1")
14
+
15
+ # after
16
+ from commoncompute.aws_batch import client
17
+ batch = client() # reads CC_API_KEY
18
+
19
+ resp = batch.submit_job(
20
+ jobName="nightly-embed",
21
+ jobQueue="ml-gpu", # routed by us, name is informational
22
+ jobDefinition="bge-large:1", # parsed as workload_id[:model_id]
23
+ containerOverrides={
24
+ "environment": [
25
+ {"name": "INPUT_PAYLOAD", "value": "{...json...}"},
26
+ ],
27
+ },
28
+ )
29
+ print(resp["jobId"])
30
+
31
+ Mapping rules:
32
+
33
+ * ``jobDefinition`` is parsed as ``"<workload_id>[:<model_id>]"``. A
34
+ bare ``"bge-large"`` is treated as ``model_id`` only and the workload
35
+ is inferred (currently ``coreml_embed``).
36
+ * ``containerOverrides.environment`` is collected into a dict and passed
37
+ through as the job ``payload``. The well-known key ``INPUT_PAYLOAD``
38
+ may carry a JSON string that is parsed and used as-is.
39
+ * ``schedulingPriority`` (AWS) or ``shareIdentifier=='priority'`` maps
40
+ to Common Compute ``priority='priority'``; otherwise we default to
41
+ ``'batch'`` (cheaper and matches the typical AWS Batch use case).
42
+ * AWS Batch state strings (``SUBMITTED``, ``PENDING``, ``RUNNABLE``,
43
+ ``STARTING``, ``RUNNING``, ``SUCCEEDED``, ``FAILED``) are mapped from
44
+ Common Compute states (``queued``, ``leased``, ``running``,
45
+ ``completed``, ``failed``, ``dead_letter``, ``cancelled``).
46
+
47
+ What this shim does NOT do (out of scope, by design):
48
+
49
+ * It does not call AWS. It does not require boto3 to be installed.
50
+ * It does not implement compute environments or job queues as
51
+ configurable resources — those are synthesised as defaults by the
52
+ CommonCompute backend.
53
+
54
+ For full boto3 wire-protocol parity (so existing scripts using
55
+ ``boto3.client('batch', endpoint_url='https://aws.commoncompute.ai')``
56
+ work without any code change), see the AWS-shim Worker. It accepts
57
+ SigV4-signed JSON-1.1 traffic and supports RegisterJobDefinition,
58
+ SubmitJob (including arrayProperties), DescribeJobs, ListJobs,
59
+ TerminateJob, CancelJob, and the synthesised Describe* read-only set.
60
+ Issue access keys at https://commoncompute.ai/app/aws-keys.
61
+ """
62
+
63
+ from __future__ import annotations
64
+
65
+ import json
66
+ import os
67
+ import re
68
+ import time
69
+ from datetime import datetime, timezone
70
+ from typing import Any, Iterable, Mapping, Optional
71
+
72
+ from .client import Client
73
+
74
+
75
+ # ─── State + priority translation ───────────────────────────────────────
76
+
77
+ # Common Compute → AWS Batch terminal/transient state strings. Multiple
78
+ # CC states fold into a single AWS state on purpose — AWS Batch consumers
79
+ # only care about a handful of values.
80
+ _CC_TO_AWS_STATE = {
81
+ "queued": "SUBMITTED",
82
+ "leased": "STARTING",
83
+ "running": "RUNNING",
84
+ "completed": "SUCCEEDED",
85
+ "failed": "FAILED",
86
+ "dead_letter": "FAILED",
87
+ "cancelled": "FAILED",
88
+ }
89
+
90
+
91
+ def _aws_state(cc_state: Optional[str]) -> str:
92
+ return _CC_TO_AWS_STATE.get(cc_state or "", "SUBMITTED")
93
+
94
+
95
+ def _to_iso(ms: Optional[int]) -> Optional[str]:
96
+ if not ms:
97
+ return None
98
+ return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()
99
+
100
+
101
+ # ─── jobDefinition parsing ──────────────────────────────────────────────
102
+
103
+ # Heuristic: anything containing one of these workload ids gets routed
104
+ # directly. Otherwise we default to ``coreml_embed`` (the most common
105
+ # batch workload migrated from AWS Batch + bge-* embeddings pipelines).
106
+ # Workload identity comes from the generated catalog mirror so this shim
107
+ # resolves jobDefinitions identically to the wire shim (apps/aws-shim) and the
108
+ # Node SDK. The previous hand-maintained set had drifted to 12 of the catalog's
109
+ # 24 workloads. Regenerate with:
110
+ # npx tsx packages/workloads/codegen/emit-sdk-workloads.ts
111
+ from ._generated_workloads import IMAGE_PATTERNS as _IMAGE_PATTERN_SOURCES
112
+ from ._generated_workloads import KNOWN_WORKLOAD_IDS as _KNOWN_WORKLOADS
113
+
114
+ _DEF_RE = re.compile(r"^([A-Za-z0-9_\-]+)(?::(\d+|\$LATEST))?$")
115
+
116
+ # Compile the generated (source, workload) patterns case-insensitively.
117
+ _IMAGE_PATTERNS: list[tuple[re.Pattern, str]] = [
118
+ (re.compile(src, re.I), workload) for src, workload in _IMAGE_PATTERN_SOURCES
119
+ ]
120
+
121
+
122
+ def _infer_workload_from_image(image: str) -> Optional[str]:
123
+ for pattern, workload in _IMAGE_PATTERNS:
124
+ if pattern.search(image):
125
+ return workload
126
+ return None
127
+
128
+
129
+ def _parse_job_definition(job_definition: str) -> tuple[str, Optional[str]]:
130
+ """Returns (workload_id, model_id) from a jobDefinition like
131
+ ``"bge-large:1"`` or ``"coreml_embed:bge-large"`` or
132
+ ``"my-pipeline-rev3"``."""
133
+ m = _DEF_RE.match(job_definition or "")
134
+ if m:
135
+ head, _rev = m.group(1), m.group(2)
136
+ if head in _KNOWN_WORKLOADS:
137
+ return head, None
138
+ return "coreml_embed", head
139
+ if ":" in job_definition:
140
+ left, right = job_definition.split(":", 1)
141
+ if left in _KNOWN_WORKLOADS:
142
+ return left, right
143
+ return "coreml_embed", job_definition or None
144
+
145
+
146
+ def _payload_from_overrides(overrides: Optional[Mapping[str, Any]]) -> Any:
147
+ """AWS Batch passes user data through environment variables. We
148
+ collect them into a dict; if INPUT_PAYLOAD is a JSON string we use
149
+ it directly so the user can carry arbitrary structure across the
150
+ boundary without inventing a new convention."""
151
+ if not overrides:
152
+ return None
153
+ env_list = overrides.get("environment") or []
154
+ env: dict[str, str] = {}
155
+ for item in env_list:
156
+ name = item.get("name")
157
+ value = item.get("value")
158
+ if isinstance(name, str) and isinstance(value, str):
159
+ env[name] = value
160
+ if "INPUT_PAYLOAD" in env:
161
+ raw = env.pop("INPUT_PAYLOAD")
162
+ try:
163
+ return json.loads(raw)
164
+ except json.JSONDecodeError:
165
+ return {"input": raw, "env": env}
166
+ return env if env else None
167
+
168
+
169
+ def _priority_from_args(*, scheduling_priority: Any, share_identifier: Any) -> str:
170
+ if isinstance(share_identifier, str) and share_identifier.lower() == "priority":
171
+ return "priority"
172
+ if isinstance(scheduling_priority, (int, float)) and scheduling_priority >= 50:
173
+ return "priority"
174
+ return "batch"
175
+
176
+
177
+ # ─── Public surface ─────────────────────────────────────────────────────
178
+
179
+ class BatchClient:
180
+ """boto3.client('batch')-shaped facade over a CommonCompute client.
181
+
182
+ Implements the AWS Batch methods that account for >95% of usage in
183
+ inference pipelines: submit_job, describe_jobs, list_jobs,
184
+ cancel_job, terminate_job. Any unimplemented method raises
185
+ ``NotImplementedError`` with a link to the migration guide.
186
+ """
187
+
188
+ def __init__(
189
+ self,
190
+ cc: Optional[Client] = None,
191
+ *,
192
+ api_key: Optional[str] = None,
193
+ base_url: Optional[str] = None,
194
+ ) -> None:
195
+ if cc is not None:
196
+ self._cc = cc
197
+ else:
198
+ kwargs: dict[str, Any] = {}
199
+ if api_key:
200
+ kwargs["api_key"] = api_key
201
+ if base_url:
202
+ kwargs["base_url"] = base_url
203
+ self._cc = Client(**kwargs)
204
+ # Process-local jobName cache so describe_jobs/list_jobs can echo
205
+ # back the original AWS-Batch jobName the customer chose. The
206
+ # CC backend doesn't store user-supplied job names today, so
207
+ # without this cache the only way to identify a job in describe
208
+ # output is by jobId. Hash-keyed by jobId.
209
+ self._job_names: dict[str, str] = {}
210
+
211
+ # ── submit_job ─────────────────────────────────────────────────────
212
+
213
+ def submit_job(
214
+ self,
215
+ *,
216
+ jobName: str,
217
+ jobQueue: Optional[str] = None,
218
+ jobDefinition: str = "",
219
+ containerOverrides: Optional[Mapping[str, Any]] = None,
220
+ nodeOverrides: Optional[Mapping[str, Any]] = None,
221
+ retryStrategy: Optional[Mapping[str, Any]] = None,
222
+ timeout: Optional[Mapping[str, Any]] = None,
223
+ propagateTags: Optional[bool] = None,
224
+ tags: Optional[Mapping[str, str]] = None,
225
+ schedulingPriorityOverride: Any = None,
226
+ shareIdentifier: Any = None,
227
+ dependsOn: Optional[Iterable[Mapping[str, Any]]] = None,
228
+ arrayProperties: Optional[Mapping[str, Any]] = None,
229
+ parameters: Optional[Mapping[str, Any]] = None,
230
+ # AWS Batch accepts **kwargs we don't model — silently ignore
231
+ # for forward-compat with new boto3 fields.
232
+ **_extra: Any,
233
+ ) -> dict[str, Any]:
234
+ if arrayProperties:
235
+ return self._submit_array_job(
236
+ jobName=jobName,
237
+ jobDefinition=jobDefinition,
238
+ containerOverrides=containerOverrides,
239
+ parameters=parameters,
240
+ arrayProperties=arrayProperties,
241
+ schedulingPriorityOverride=schedulingPriorityOverride,
242
+ shareIdentifier=shareIdentifier,
243
+ )
244
+ if dependsOn:
245
+ raise NotImplementedError(
246
+ "Job dependencies aren't supported by this shim. Sequence "
247
+ "submissions in your application code or open an issue with "
248
+ "your dependency pattern."
249
+ )
250
+
251
+ workload_id, model_id = _parse_job_definition(jobDefinition)
252
+ payload = _payload_from_overrides(containerOverrides)
253
+ priority = _priority_from_args(
254
+ scheduling_priority=schedulingPriorityOverride,
255
+ share_identifier=shareIdentifier,
256
+ )
257
+
258
+ # max_spend_usd from `timeout.attemptDurationSeconds` would be a
259
+ # very loose proxy; leave it unset so the user gets a quote-shape
260
+ # error and learns the correct field if they want a cap.
261
+ idempotency_key = jobName # AWS Batch dedupes on jobName per queue.
262
+
263
+ job = self._cc.jobs.submit(
264
+ workload_id=workload_id,
265
+ model_id=model_id,
266
+ priority=priority,
267
+ payload=payload,
268
+ idempotency_key=idempotency_key,
269
+ )
270
+ job_id = job.get("id") or ""
271
+ if job_id:
272
+ self._job_names[job_id] = jobName
273
+ return {
274
+ "jobName": jobName,
275
+ "jobId": job_id,
276
+ "jobArn": f"cc:job:{job_id}", # AWS callers expect an ARN-ish string
277
+ }
278
+
279
+ # ── array jobs ────────────────────────────────────────────────────
280
+
281
+ def _submit_array_job(
282
+ self,
283
+ *,
284
+ jobName: str,
285
+ jobDefinition: str,
286
+ containerOverrides: Optional[Mapping[str, Any]],
287
+ parameters: Optional[Mapping[str, Any]],
288
+ arrayProperties: Mapping[str, Any],
289
+ schedulingPriorityOverride: Any,
290
+ shareIdentifier: Any,
291
+ ) -> dict[str, Any]:
292
+ """Translate AWS arrayProperties into a CommonCompute batch.
293
+
294
+ Item source priority:
295
+ 1. ``parameters['array_items_json']`` — literal JSON array
296
+ 2. ``parameters['array_input_uri']`` — JSONL/JSON file URI
297
+ 3. fan-out: N copies, each with ``payload['array_index'] = i``
298
+ """
299
+ size = int(arrayProperties.get("size") or 0)
300
+ if size <= 0:
301
+ raise ValueError("arrayProperties.size must be a positive integer")
302
+ workload_id, model_id = _parse_job_definition(jobDefinition)
303
+ priority = _priority_from_args(
304
+ scheduling_priority=schedulingPriorityOverride,
305
+ share_identifier=shareIdentifier,
306
+ )
307
+ env_payload = _payload_from_overrides(containerOverrides)
308
+
309
+ params = dict(parameters or {})
310
+ items: list[dict[str, Any]] = []
311
+ if "array_items_json" in params:
312
+ arr = json.loads(params["array_items_json"])
313
+ for i, raw in enumerate(arr[:size]):
314
+ if isinstance(raw, dict) and "payload" in raw:
315
+ items.append({"custom_id": raw.get("custom_id") or f"{jobName}-{i}", "payload": raw["payload"]})
316
+ else:
317
+ items.append({"custom_id": f"{jobName}-{i}", "payload": raw})
318
+ elif "array_input_uri" in params:
319
+ raise NotImplementedError(
320
+ "array_input_uri requires the wire-protocol shim at "
321
+ "aws.commoncompute.ai (the in-process shim can't fetch R2 URIs). "
322
+ "Set endpoint_url=https://aws.commoncompute.ai on boto3 to use it."
323
+ )
324
+ else:
325
+ base_payload = env_payload if isinstance(env_payload, dict) else {}
326
+ for i in range(size):
327
+ items.append({
328
+ "custom_id": f"{jobName}-{i}",
329
+ "payload": {**base_payload, "array_index": i},
330
+ })
331
+
332
+ resp = self._cc._transport.request( # type: ignore[attr-defined]
333
+ "POST", "/v1/batches",
334
+ json_body={
335
+ "name": jobName,
336
+ "workload_id": workload_id,
337
+ "model_id": model_id,
338
+ "priority": priority,
339
+ "items": items,
340
+ },
341
+ )
342
+ batch_id = resp.get("id") or ""
343
+ if batch_id:
344
+ self._job_names[batch_id] = jobName
345
+ return {
346
+ "jobName": jobName,
347
+ "jobId": batch_id,
348
+ "jobArn": f"arn:cc:batch:::job/{batch_id}",
349
+ "arrayProperties": {"size": resp.get("total", size), "statusSummary": {}},
350
+ }
351
+
352
+ # ── register_job_definition ───────────────────────────────────────
353
+
354
+ def register_job_definition(
355
+ self,
356
+ *,
357
+ jobDefinitionName: str,
358
+ type: str = "container",
359
+ containerProperties: Optional[Mapping[str, Any]] = None,
360
+ parameters: Optional[Mapping[str, Any]] = None,
361
+ tags: Optional[Mapping[str, str]] = None,
362
+ **_extra: Any,
363
+ ) -> dict[str, Any]:
364
+ """In-process shim: persist a JobDefinition by recording the
365
+ resolved workload locally so subsequent ``submit_job`` calls
366
+ can reference it by name. The wire-protocol shim at
367
+ aws.commoncompute.ai persists this server-side; this shim
368
+ keeps a per-process registry only.
369
+ """
370
+ if not hasattr(self, "_local_defs"):
371
+ self._local_defs: dict[str, dict[str, Any]] = {}
372
+ # Resolve a workload from the image hint or the cc-workload tag.
373
+ cc_workload = (tags or {}).get("cc-workload") or (parameters or {}).get("cc_workload")
374
+ if cc_workload and cc_workload in _KNOWN_WORKLOADS:
375
+ workload_id = cc_workload
376
+ else:
377
+ image = (containerProperties or {}).get("image", "") or ""
378
+ workload_id = _infer_workload_from_image(image)
379
+ if not workload_id:
380
+ raise ValueError(
381
+ f"Couldn't infer a CommonCompute workload from image={image!r}. "
382
+ "Set tags['cc-workload'] to one of: " + ", ".join(sorted(_KNOWN_WORKLOADS))
383
+ )
384
+ revision = 1 + sum(1 for d in self._local_defs.values() if d["name"] == jobDefinitionName)
385
+ arn = f"arn:cc:batch:::job-definition/{jobDefinitionName}:{revision}"
386
+ self._local_defs[arn] = {
387
+ "name": jobDefinitionName, "revision": revision, "workload_id": workload_id,
388
+ "model_id": (tags or {}).get("cc-model"), "containerProperties": containerProperties or {},
389
+ "parameters": parameters or {}, "tags": tags or {},
390
+ }
391
+ # Also key by name (latest) and name:rev for boto3 lookup parity.
392
+ self._local_defs[jobDefinitionName] = self._local_defs[arn]
393
+ self._local_defs[f"{jobDefinitionName}:{revision}"] = self._local_defs[arn]
394
+ return {
395
+ "jobDefinitionName": jobDefinitionName,
396
+ "jobDefinitionArn": arn,
397
+ "revision": revision,
398
+ }
399
+
400
+ # ── describe_jobs ──────────────────────────────────────────────────
401
+
402
+ def describe_jobs(self, *, jobs: Iterable[str], **_extra: Any) -> dict[str, Any]:
403
+ """Mirror AWS describe_jobs — returns ``{"jobs": [...]}``.
404
+
405
+ Just-submitted jobs may not be queryable for ~100ms while the
406
+ underlying D1 row propagates across the worker's read replicas.
407
+ We absorb a single 404 by retrying once after 250ms, which is
408
+ enough to mask the eventual-consistency window in practice."""
409
+ out: list[dict[str, Any]] = []
410
+ for jid in jobs:
411
+ row = None
412
+ for attempt in range(2):
413
+ try:
414
+ row = self._cc.jobs.get(jid)
415
+ break
416
+ except Exception:
417
+ if attempt == 0:
418
+ time.sleep(0.25)
419
+ continue
420
+ if row is None:
421
+ continue
422
+ shape = _aws_job_shape(row)
423
+ # Patch in the original jobName from our process-local cache,
424
+ # so describe_jobs is round-trip-stable for in-process callers.
425
+ cached_name = self._job_names.get(jid)
426
+ if cached_name:
427
+ shape["jobName"] = cached_name
428
+ out.append(shape)
429
+ return {"jobs": out}
430
+
431
+ # ── list_jobs ──────────────────────────────────────────────────────
432
+
433
+ def list_jobs(
434
+ self,
435
+ *,
436
+ jobQueue: Optional[str] = None,
437
+ arrayJobId: Optional[str] = None,
438
+ multiNodeJobId: Optional[str] = None,
439
+ jobStatus: Optional[str] = None,
440
+ maxResults: Optional[int] = None,
441
+ nextToken: Optional[str] = None,
442
+ filters: Any = None,
443
+ **_extra: Any,
444
+ ) -> dict[str, Any]:
445
+ """List jobs from /v1/me/submissions and translate each to AWS shape."""
446
+ rows = self._cc._transport.request("GET", "/v1/me/submissions").get("jobs", []) # type: ignore[attr-defined]
447
+ if maxResults:
448
+ rows = rows[: int(maxResults)]
449
+ target = jobStatus.upper() if isinstance(jobStatus, str) else None
450
+ summaries = []
451
+ for row in rows:
452
+ shape = _aws_job_shape(row)
453
+ if target and shape["status"] != target:
454
+ continue
455
+ summaries.append({
456
+ "jobId": shape["jobId"],
457
+ "jobName": shape["jobName"],
458
+ "createdAt": shape.get("createdAt"),
459
+ "startedAt": shape.get("startedAt"),
460
+ "stoppedAt": shape.get("stoppedAt"),
461
+ "status": shape["status"],
462
+ "statusReason": shape.get("statusReason"),
463
+ })
464
+ return {"jobSummaryList": summaries}
465
+
466
+ # ── cancel_job / terminate_job ─────────────────────────────────────
467
+
468
+ def cancel_job(self, *, jobId: str, reason: str = "", **_extra: Any) -> dict[str, Any]:
469
+ self._cc._transport.request("POST", f"/v1/jobs/{jobId}/cancel", json_body={"reason": reason}) # type: ignore[attr-defined]
470
+ return {}
471
+
472
+ def terminate_job(self, *, jobId: str, reason: str = "", **_extra: Any) -> dict[str, Any]:
473
+ # AWS distinguishes cancel (queued) vs terminate (running). On
474
+ # Common Compute the same endpoint handles both — the router
475
+ # picks the right path based on current state.
476
+ return self.cancel_job(jobId=jobId, reason=reason)
477
+
478
+ # ── Convenience: poll-to-completion ───────────────────────────────
479
+
480
+ def wait_until_done(
481
+ self,
482
+ *,
483
+ jobId: str,
484
+ timeout: float = 300.0,
485
+ poll_interval: float = 2.0,
486
+ ) -> dict[str, Any]:
487
+ """Block until job is SUCCEEDED or FAILED. Not part of boto3 API
488
+ but commonly needed at migration sites that previously used
489
+ ``waiter = batch.get_waiter('jobs_complete')``."""
490
+ deadline = time.monotonic() + timeout
491
+ while True:
492
+ row = self._cc.jobs.get(jobId)
493
+ state = _aws_state(row.get("state"))
494
+ if state in ("SUCCEEDED", "FAILED"):
495
+ shape = _aws_job_shape(row)
496
+ cached_name = self._job_names.get(jobId)
497
+ if cached_name:
498
+ shape["jobName"] = cached_name
499
+ return shape
500
+ if time.monotonic() > deadline:
501
+ raise TimeoutError(f"Job {jobId} not done in {timeout}s (state={state})")
502
+ time.sleep(poll_interval)
503
+
504
+ # ── Result download (not in boto3 — commonly needed for CC) ────────
505
+
506
+ def download_result(self, *, jobId: str, dest: Optional[str] = None) -> bytes:
507
+ """Download a completed job's result body. AWS Batch customers
508
+ typically read S3 themselves; on Common Compute the API streams
509
+ the result directly. Returns the raw bytes; if ``dest`` is given,
510
+ writes to that path and returns the bytes anyway.
511
+
512
+ Raises if the job isn't in a terminal state yet."""
513
+ body = self._cc._transport.request("GET", f"/v1/jobs/{jobId}/result")
514
+ # The transport may return parsed JSON or raw bytes depending on
515
+ # content-type — coerce to bytes for the file-write path.
516
+ if isinstance(body, (dict, list)):
517
+ data = json.dumps(body).encode("utf-8")
518
+ elif isinstance(body, str):
519
+ data = body.encode("utf-8")
520
+ elif isinstance(body, (bytes, bytearray)):
521
+ data = bytes(body)
522
+ else:
523
+ data = json.dumps(body).encode("utf-8")
524
+ if dest:
525
+ with open(dest, "wb") as f:
526
+ f.write(data)
527
+ return data
528
+
529
+ # ── Unimplemented stubs for legibility ────────────────────────────
530
+
531
+ def __getattr__(self, name: str) -> Any:
532
+ # Catch any boto3 method we haven't implemented and return a
533
+ # helpful error rather than AttributeError.
534
+ if name.startswith("_"):
535
+ raise AttributeError(name)
536
+
537
+ def _missing(*_a: Any, **_kw: Any) -> Any:
538
+ raise NotImplementedError(
539
+ f"BatchClient.{name} is not implemented by the Common Compute "
540
+ "AWS Batch shim. The shim covers submit_job, describe_jobs, "
541
+ "list_jobs, cancel_job, terminate_job, and wait_until_done — "
542
+ "AWS Batch concepts like compute environments and job queues "
543
+ "are obviated by the Common Compute architecture. See "
544
+ "https://commoncompute.ai/docs/migrate-from-aws-batch."
545
+ )
546
+
547
+ return _missing
548
+
549
+
550
+ def _aws_job_shape(cc_row: Mapping[str, Any]) -> dict[str, Any]:
551
+ """Translate a Common Compute job row into the AWS Batch describe_jobs
552
+ shape that downstream code expects."""
553
+ state = cc_row.get("state")
554
+ return {
555
+ "jobId": cc_row.get("id"),
556
+ "jobName": (cc_row.get("metadata") or {}).get("jobName") or cc_row.get("type"),
557
+ "jobArn": f"cc:job:{cc_row.get('id', '')}",
558
+ "status": _aws_state(state),
559
+ "statusReason": cc_row.get("error") or None,
560
+ "createdAt": _to_iso(cc_row.get("created_at")),
561
+ "startedAt": _to_iso(cc_row.get("leased_at") or cc_row.get("started_at")),
562
+ "stoppedAt": _to_iso(cc_row.get("completed_at")),
563
+ "attempts": cc_row.get("attempts"),
564
+ "container": {
565
+ "exitCode": 0 if state == "completed" else None,
566
+ },
567
+ # Common Compute extension fields — not in AWS Batch but useful
568
+ # to callers who learn the shape and want extra signal.
569
+ "_cc_workload_id": cc_row.get("type"),
570
+ "_cc_device_id": cc_row.get("device_id"),
571
+ "_cc_result_uri": cc_row.get("result_uri"),
572
+ }
573
+
574
+
575
+ def client(
576
+ *,
577
+ api_key: Optional[str] = None,
578
+ base_url: Optional[str] = None,
579
+ ) -> BatchClient:
580
+ """Construct a boto3-shaped client. Mirrors ``boto3.client('batch')``.
581
+
582
+ ``region_name``, ``aws_access_key_id``, etc. are accepted by the
583
+ boto3 caller convention but ignored here (no AWS region applies).
584
+ Use ``api_key`` to override the ``CC_API_KEY`` env var.
585
+ """
586
+ return BatchClient(api_key=api_key, base_url=base_url)
587
+
588
+
589
+ __all__ = ["BatchClient", "client"]