commoncompute 0.3.1__tar.gz → 0.4.0__tar.gz

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.
Files changed (22) hide show
  1. {commoncompute-0.3.1 → commoncompute-0.4.0}/.gitignore +3 -0
  2. {commoncompute-0.3.1 → commoncompute-0.4.0}/PKG-INFO +1 -1
  3. {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/__init__.py +3 -1
  4. {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/_async_client.py +15 -0
  5. commoncompute-0.4.0/commoncompute/billing.py +124 -0
  6. {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/cli.py +205 -0
  7. {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/client.py +26 -0
  8. {commoncompute-0.3.1 → commoncompute-0.4.0}/LICENSE +0 -0
  9. {commoncompute-0.3.1 → commoncompute-0.4.0}/README.md +0 -0
  10. {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/_async_tasks.py +0 -0
  11. {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/_config.py +0 -0
  12. {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/_generated_workloads.py +0 -0
  13. {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/_transport.py +0 -0
  14. {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/aws_batch.py +0 -0
  15. {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/compat/__init__.py +0 -0
  16. {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/compat/openai.py +0 -0
  17. {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/connect.py +0 -0
  18. {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/errors.py +0 -0
  19. {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/models.py +0 -0
  20. {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/py.typed +0 -0
  21. {commoncompute-0.3.1 → commoncompute-0.4.0}/commoncompute/tasks.py +0 -0
  22. {commoncompute-0.3.1 → commoncompute-0.4.0}/pyproject.toml +0 -0
@@ -37,3 +37,6 @@ apps/web/.playwright/
37
37
  # Local scratch — never tracked (internal notes, draft reviews)
38
38
  tmp/
39
39
  .venv
40
+
41
+ # Device reliability reset snapshots (rollback data, never tracked)
42
+ scripts/.reliability-snapshots/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commoncompute
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Official Python SDK for Common Compute — the batch AI bill you shouldn't be paying.
5
5
  Project-URL: Homepage, https://commoncompute.ai
6
6
  Project-URL: Documentation, https://commoncompute.ai/docs
@@ -30,7 +30,7 @@ in the catalog — the API declines them with `workload_not_available`
30
30
  # `commoncompute.__version__` at import time for the User-Agent header,
31
31
  # and the previous ordering (`__version__` defined at the bottom) made
32
32
  # every clean install of the SDK fail with a circular ImportError.
33
- __version__ = "0.3.1"
33
+ __version__ = "0.4.0"
34
34
 
35
35
  from .client import Client, AsyncClient
36
36
  from .errors import (
@@ -51,6 +51,7 @@ from .errors import (
51
51
  )
52
52
  from .models import Job, Quote, Receipt, TaskResult, AccountTier, SpendSummary
53
53
  from .connect import connect, login
54
+ from .billing import add_card
54
55
  from . import aws_batch # noqa: E402 (intentional re-export)
55
56
 
56
57
  __all__ = [
@@ -58,6 +59,7 @@ __all__ = [
58
59
  "AsyncClient",
59
60
  "connect",
60
61
  "login",
62
+ "add_card",
61
63
  "CommonComputeError",
62
64
  "AuthenticationError",
63
65
  "PermissionDeniedError",
@@ -297,6 +297,21 @@ class _BillingAsync:
297
297
  async def balance(self) -> dict[str, Any]:
298
298
  return await self._t.request("GET", "/v1/billing/balance")
299
299
 
300
+ async def credits(self) -> dict[str, Any]:
301
+ """Prepaid credit balance, held amounts, and auto-reload settings —
302
+ ``GET /v1/billing/credits``. Mirrors ``Client.billing.credits()``."""
303
+ return await self._t.request("GET", "/v1/billing/credits")
304
+
305
+ async def buy_credits(self, *, amount_cents: int, idempotency_key: str | None = None) -> dict[str, Any]:
306
+ """Charge the saved card for credits. Mirrors ``Client.billing.buy_credits()``
307
+ — needs an API key carrying the ``billing`` scope."""
308
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
309
+ return await self._t.request(
310
+ "POST", "/v1/billing/credits",
311
+ json_body={"amount_cents": amount_cents},
312
+ headers=headers,
313
+ )
314
+
300
315
  async def history(self, *, limit: int = 50) -> list[dict[str, Any]]:
301
316
  return (await self._t.request("GET", "/v1/billing/history", params={"limit": limit})).get("entries", [])
302
317
 
@@ -0,0 +1,124 @@
1
+ """Attach a payment card from a script or notebook.
2
+
3
+ ``commoncompute.add_card()`` opens the dashboard billing page, waits for the
4
+ card to actually land server-side, and returns the resulting payment-method
5
+ summary::
6
+
7
+ import commoncompute as cc
8
+ cc.connect() # one-time: get a key
9
+ cc.add_card() # one-time: attach a card
10
+ cc.Client().submit(...)
11
+
12
+ WHY THE BROWSER. The card number has to reach Stripe directly. Accepting it
13
+ here would put the number in this process's memory, its traceback frames, and
14
+ whatever notebook checkpoint happens to capture the cell — and would drag the
15
+ user's machine into PCI scope for no benefit at all. So this function never
16
+ sees a card; it opens the page where Stripe's own iframe collects one.
17
+
18
+ WHY IT POLLS. The card is saved by the ``setup_intent.succeeded`` webhook, not
19
+ by the browser form submitting. Returning as soon as the browser opens would
20
+ report success for a card that may never arrive. Polling the read-only summary
21
+ means a returned value is the server agreeing the card exists.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import time
28
+ import webbrowser
29
+ from typing import Any, Callable, Optional
30
+
31
+ from .errors import CommonComputeError
32
+
33
+ DEFAULT_BASE_URL = "https://api.commoncompute.ai"
34
+ POLL_INTERVAL_S = 3.0
35
+
36
+
37
+ def dashboard_url_for(api_base: str, path: str = "/app/billing") -> str:
38
+ """Map an API base URL to the matching dashboard URL.
39
+
40
+ `api.` is stripped because that is the only difference between the two
41
+ hostnames in every environment we run, so a staging key opens the staging
42
+ dashboard rather than production's. CC_DASHBOARD_URL overrides outright,
43
+ for local dev where the two aren't related by prefix at all.
44
+ """
45
+ override = os.environ.get("CC_DASHBOARD_URL")
46
+ if override:
47
+ return f"{override.rstrip('/')}{path}"
48
+ return f"{api_base.rstrip('/').replace('://api.', '://', 1)}{path}"
49
+
50
+
51
+ def add_card(
52
+ *,
53
+ client: Any = None,
54
+ open_browser: bool = True,
55
+ timeout: float = 600.0,
56
+ quiet: bool = False,
57
+ _sleep: Callable[[float], None] = time.sleep,
58
+ ) -> dict[str, Any]:
59
+ """Attach a payment card to the account this machine is logged in as.
60
+
61
+ Opens the browser to the dashboard billing page (or prints the URL when
62
+ ``open_browser=False``), then blocks until the saved card appears.
63
+
64
+ Returns the payment-method summary — ``{"has_card": True, "brand": ...,
65
+ "last4": ...}``. Returns immediately if a card is already on file, or if
66
+ the account is billing-exempt and doesn't need one.
67
+
68
+ Raises :class:`CommonComputeError` if no card appears before ``timeout``.
69
+ """
70
+ from .client import Client # local import: avoids a cycle at module load
71
+
72
+ c = client or Client()
73
+
74
+ def say(msg: str) -> None:
75
+ if not quiet:
76
+ print(msg)
77
+
78
+ summary = c.billing.balance()
79
+ if summary.get("has_card"):
80
+ say(f"Card already on file — {describe_card(summary)}")
81
+ return summary
82
+ if summary.get("billing_exempt"):
83
+ say("This account is billing-exempt — no card needed.")
84
+ return summary
85
+
86
+ url = dashboard_url_for(c._transport._base_url)
87
+ say("Add your card in the browser — it goes straight to Stripe, not through this process.")
88
+ say(f" Open: {url}")
89
+ if open_browser:
90
+ try:
91
+ webbrowser.open(url)
92
+ except Exception:
93
+ pass # headless; the printed URL is the fallback
94
+
95
+ deadline = time.time() + timeout
96
+ while time.time() < deadline:
97
+ _sleep(POLL_INTERVAL_S)
98
+ try:
99
+ summary = c.billing.balance()
100
+ except Exception:
101
+ continue # transient — the user is mid-flow; keep waiting
102
+ if summary.get("has_card"):
103
+ say(f"✓ Card saved — {describe_card(summary)}")
104
+ return summary
105
+
106
+ raise CommonComputeError(
107
+ f"No card appeared within {timeout:.0f}s. If you did add one, it may still be "
108
+ "processing — check client.billing.balance()."
109
+ )
110
+
111
+
112
+ def describe_card(summary: dict[str, Any]) -> str:
113
+ """Human label for a payment-method summary.
114
+
115
+ Distinguishes "no card" from "card whose details Stripe couldn't be asked
116
+ for" — the API degrades to has_card without brand/last4 when Stripe is
117
+ unreachable, and calling that "none" would contradict the submit gate.
118
+ """
119
+ if summary.get("last4"):
120
+ brand = (summary.get("brand") or "card").capitalize()
121
+ return f"{brand} ••••{summary['last4']}"
122
+ if summary.get("has_card"):
123
+ return "on file (details unavailable)"
124
+ return "none"
@@ -56,10 +56,14 @@ jobs_app = typer.Typer(no_args_is_help=True, help="Inspect and submit jobs.")
56
56
  keys_app = typer.Typer(no_args_is_help=True, help="Manage API keys.")
57
57
  receipts_app = typer.Typer(no_args_is_help=True, help="List, get, and export job receipts.")
58
58
  config_app = typer.Typer(no_args_is_help=True, help="Local CLI/SDK configuration (base_url, org, credentials).")
59
+ billing_app = typer.Typer(no_args_is_help=True, help="Payment method and credit balance.")
60
+ credits_app = typer.Typer(no_args_is_help=True, help="Buy and inspect prepaid credits.")
59
61
  app.add_typer(jobs_app, name="jobs")
60
62
  app.add_typer(keys_app, name="keys")
61
63
  app.add_typer(receipts_app, name="receipts")
62
64
  app.add_typer(config_app, name="config")
65
+ app.add_typer(billing_app, name="billing")
66
+ app.add_typer(credits_app, name="credits")
63
67
 
64
68
  console = Console()
65
69
  err_console = Console(stderr=True, style="red")
@@ -112,6 +116,39 @@ def _fmt_usd(cents: int) -> str:
112
116
  return f"${cents / 100:,.2f}"
113
117
 
114
118
 
119
+ def _stdin_is_tty() -> bool:
120
+ """Is there a human who can answer a prompt?
121
+
122
+ Its own function so a test can drive both branches — CliRunner replaces
123
+ sys.stdin, so patching sys.stdin.isatty directly doesn't reach the code
124
+ under test, and the untested branch here is the one that charges a card.
125
+ """
126
+ try:
127
+ return sys.stdin.isatty()
128
+ except Exception:
129
+ return False # detached stdin — treat as non-interactive
130
+
131
+
132
+ def _dashboard_url(client: Client, path: str) -> str:
133
+ """Map the API base URL to the matching dashboard URL.
134
+
135
+ The card itself is entered in a browser, never here — Stripe must receive
136
+ the number directly, and routing it through this process would drag the
137
+ user's machine into PCI scope for no benefit. So the CLI's job is to open
138
+ the right page and then watch for the result.
139
+
140
+ Derived from the transport's base_url rather than hardcoded so `config set
141
+ base_url` and CC_BASE_URL keep pointing at the matching dashboard: staging
142
+ API → staging dashboard. `api.` is stripped because that is the only
143
+ difference between the two hostnames in every environment we run.
144
+ """
145
+ override = os.environ.get("CC_DASHBOARD_URL")
146
+ base = (override or client._transport._base_url).rstrip("/")
147
+ if not override:
148
+ base = base.replace("://api.", "://", 1)
149
+ return f"{base}{path}"
150
+
151
+
115
152
  def _print_err(e: Exception) -> None:
116
153
  err_console.print(f"[bold red]Error:[/bold red] {e}")
117
154
 
@@ -267,6 +304,174 @@ def balance(as_json: bool = typer.Option(False, "--json", help="Raw JSON output.
267
304
  console.print(table)
268
305
 
269
306
 
307
+ # ─── billing ────────────────────────────────────────────────────────────
308
+ #
309
+ # The card number never passes through this process. `billing add-card` opens
310
+ # the dashboard, where Stripe's own iframe collects it, and then polls a
311
+ # read-only endpoint until the saved card shows up. That split is why these
312
+ # commands need no new API surface and no new permission: GET
313
+ # /v1/billing/balance and /v1/billing/credits already accept an API key
314
+ # (`requireAuth`), while everything that *mutates* billing stays JWT-only and
315
+ # happens in the browser session.
316
+
317
+ @billing_app.command("status")
318
+ def billing_status(as_json: bool = typer.Option(False, "--json", help="Raw JSON output.")) -> None:
319
+ """Show the saved card and credit balance."""
320
+ c = _client()
321
+ try:
322
+ card = c.balance()
323
+ credits = c.billing.credits()
324
+ except Exception as e:
325
+ _print_err(e); raise typer.Exit(1)
326
+ if _emit({**credits, "payment_method": card}, as_json):
327
+ return
328
+
329
+ table = Table(show_header=False, box=None)
330
+ table.add_row("Payment method", _describe_card(card))
331
+ table.add_row("Credits", _fmt_usd(credits.get("balance_cents", 0)))
332
+ held = credits.get("held_cents")
333
+ if held:
334
+ table.add_row("Held for running jobs", _fmt_usd(held))
335
+ if credits.get("auto_reload_enabled"):
336
+ table.add_row("Auto-reload", f"on — {_fmt_usd(credits.get('auto_reload_amount_cents', 0))}")
337
+ console.print(table)
338
+
339
+ if card.get("billing_exempt"):
340
+ console.print("\n[dim]This account is billing-exempt — jobs run without a card.[/dim]")
341
+ elif not card.get("has_card"):
342
+ console.print("\nNo card yet. Run [bold]commoncompute billing add-card[/bold].")
343
+
344
+
345
+ def _describe_card(card: dict) -> str:
346
+ if card.get("last4"):
347
+ brand = (card.get("brand") or "card").capitalize()
348
+ return f"{brand} ••••{card['last4']}"
349
+ if card.get("has_card"):
350
+ # Stripe was unreachable when the summary was built; the submit gate
351
+ # still works, so report the card rather than implying there isn't one.
352
+ return "on file (details unavailable)"
353
+ return "none"
354
+
355
+
356
+ @billing_app.command("add-card")
357
+ def billing_add_card(
358
+ no_browser: bool = typer.Option(False, "--no-browser", help="Print the URL instead of opening it."),
359
+ timeout: float = typer.Option(600.0, "--timeout", help="Seconds to wait for the card to appear."),
360
+ ) -> None:
361
+ """Add a payment card (opens the browser; the card is entered on Stripe's form)."""
362
+ c = _client()
363
+ try:
364
+ card = c.balance()
365
+ except Exception as e:
366
+ _print_err(e); raise typer.Exit(1)
367
+
368
+ if card.get("has_card"):
369
+ console.print(f"[green]✓[/green] Card already on file — {_describe_card(card)}")
370
+ console.print("[dim]Replace it from the dashboard billing page.[/dim]")
371
+ return
372
+ if card.get("billing_exempt"):
373
+ console.print("This account is billing-exempt — no card needed to submit jobs.")
374
+ return
375
+
376
+ url = _dashboard_url(c, "/app/billing")
377
+ console.print("Add your card in the browser — it goes straight to Stripe, not through this CLI.")
378
+ console.print(f" Open: [bold]{url}[/bold]\n")
379
+ if not no_browser:
380
+ try:
381
+ webbrowser.open(url)
382
+ except Exception:
383
+ pass # headless box; the printed URL is the fallback
384
+
385
+ # Poll the read-only summary. The webhook (setup_intent.succeeded) is what
386
+ # actually saves the card, so success here means the card really landed
387
+ # server-side — not merely that the browser form looked happy.
388
+ deadline = time.time() + timeout
389
+ try:
390
+ with console.status("Waiting for the card…"):
391
+ while time.time() < deadline:
392
+ time.sleep(3)
393
+ try:
394
+ card = c.balance()
395
+ except Exception:
396
+ continue # transient; keep waiting until the deadline
397
+ if card.get("has_card"):
398
+ console.print(f"[green]✓[/green] Card saved — {_describe_card(card)}")
399
+ console.print("Next: [bold]commoncompute billing status[/bold]")
400
+ return
401
+ except KeyboardInterrupt:
402
+ console.print("\nStopped waiting. The card is saved if you finished in the browser;")
403
+ console.print("check with [bold]commoncompute billing status[/bold].")
404
+ raise typer.Exit(1)
405
+
406
+ err_console.print(
407
+ f"Timed out after {timeout:.0f}s without seeing a card.\n"
408
+ "If you did add one, check `commoncompute billing status` — the webhook may still be in flight."
409
+ )
410
+ raise typer.Exit(1)
411
+
412
+
413
+ @credits_app.command("buy")
414
+ def credits_buy(
415
+ amount: float = typer.Argument(..., help="Dollars of credit to buy, e.g. 20."),
416
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
417
+ as_json: bool = typer.Option(False, "--json", help="Raw JSON output."),
418
+ ) -> None:
419
+ """Charge your saved card for credits.
420
+
421
+ Needs a key with the `billing` scope — tick "Also let it buy credits" when
422
+ you run `commoncompute login`. Without it you'll get a clear 403.
423
+ """
424
+ c = _client()
425
+ amount_cents = int(round(amount * 100))
426
+
427
+ # Confirm before charging. This is the one CLI command that moves money, so
428
+ # a mistyped argument is a real charge rather than a failed request.
429
+ #
430
+ # When there's no tty we REFUSE rather than assuming consent. Skipping the
431
+ # prompt because nobody can answer it turns "couldn't ask" into "went
432
+ # ahead" — a cron job or a stray script would charge the card silently. A
433
+ # caller that means it passes --yes.
434
+ if not yes:
435
+ if _stdin_is_tty():
436
+ typer.confirm(f"Charge your saved card {_fmt_usd(amount_cents)}?", abort=True)
437
+ else:
438
+ err_console.print(
439
+ f"Refusing to charge {_fmt_usd(amount_cents)} without confirmation.\n"
440
+ "This isn't an interactive shell, so there's nobody to ask — "
441
+ "pass [bold]--yes[/bold] if you meant it."
442
+ )
443
+ raise typer.Exit(2)
444
+
445
+ try:
446
+ res = c.billing.buy_credits(amount_cents=amount_cents)
447
+ except Exception as e:
448
+ _print_err(e)
449
+ # The API distinguishes these; each needs a different action from the
450
+ # user, so don't flatten them into "purchase failed".
451
+ text = str(e)
452
+ if "insufficient_scope" in text or "billing\" scope" in text:
453
+ err_console.print(
454
+ "\nThis key can't buy credits. Re-run [bold]commoncompute login[/bold] and tick\n"
455
+ '"Also let it buy credits" on the approval page, or buy from the dashboard.'
456
+ )
457
+ elif "no_payment_method" in text:
458
+ err_console.print("\nNo card on file. Run [bold]commoncompute billing add-card[/bold] first.")
459
+ raise typer.Exit(1)
460
+
461
+ if _emit(res, as_json):
462
+ return
463
+ console.print(
464
+ f"[green]✓[/green] Added {_fmt_usd(res.get('purchased_cents', amount_cents))} — "
465
+ f"balance {_fmt_usd(res.get('available_cents', 0))}"
466
+ )
467
+
468
+
469
+ @credits_app.command("status")
470
+ def credits_status(as_json: bool = typer.Option(False, "--json", help="Raw JSON output.")) -> None:
471
+ """Show the credit balance (alias of `billing status`)."""
472
+ billing_status(as_json=as_json)
473
+
474
+
270
475
  @app.command()
271
476
  def usage(
272
477
  start: Optional[str] = typer.Option(None, "--start", help="Window start, YYYY-MM-DD (default: 30 days ago)."),
@@ -298,6 +298,32 @@ class _BillingSync:
298
298
  def balance(self) -> dict[str, Any]:
299
299
  return self._t.request("GET", "/v1/billing/balance")
300
300
 
301
+ def credits(self) -> dict[str, Any]:
302
+ """Prepaid credit balance, what's held against in-flight jobs, and
303
+ auto-reload settings — ``GET /v1/billing/credits``.
304
+
305
+ Read-only, so an API key is enough. Buying credits is a separate,
306
+ JWT-only operation done from the dashboard.
307
+ """
308
+ return self._t.request("GET", "/v1/billing/credits")
309
+
310
+ def buy_credits(self, *, amount_cents: int, idempotency_key: str | None = None) -> dict[str, Any]:
311
+ """Charge the saved card for credits — ``POST /v1/billing/credits``.
312
+
313
+ Needs an API key carrying the ``billing`` scope, which is granted only
314
+ by ticking "Also let it buy credits" during ``commoncompute login``.
315
+ A stock key raises with ``insufficient_scope``.
316
+
317
+ Pass ``idempotency_key`` to make a retry safe: the same key never
318
+ charges twice.
319
+ """
320
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
321
+ return self._t.request(
322
+ "POST", "/v1/billing/credits",
323
+ json_body={"amount_cents": amount_cents},
324
+ headers=headers,
325
+ )
326
+
301
327
  def history(self, *, limit: int = 50) -> list[dict[str, Any]]:
302
328
  return self._t.request("GET", "/v1/billing/history", params={"limit": limit}).get("entries", [])
303
329
 
File without changes
File without changes