ravmem-cli 1.2.0__tar.gz → 1.3.1__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 (39) hide show
  1. {ravmem_cli-1.2.0/ravmem_cli.egg-info → ravmem_cli-1.3.1}/PKG-INFO +1 -1
  2. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/pyproject.toml +1 -1
  3. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/_common.py +2 -4
  4. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/git_local.py +6 -13
  5. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/license.py +158 -76
  6. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/main.py +10 -0
  7. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/client/api.py +38 -8
  8. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1/ravmem_cli.egg-info}/PKG-INFO +1 -1
  9. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem_cli.egg-info/SOURCES.txt +1 -0
  10. ravmem_cli-1.3.1/tests/test_client_delete_timeout.py +28 -0
  11. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/LICENSE +0 -0
  12. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/README.md +0 -0
  13. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/__init__.py +0 -0
  14. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/_errors.py +0 -0
  15. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/analyze.py +0 -0
  16. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/audit.py +0 -0
  17. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/auth.py +0 -0
  18. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/browse.py +0 -0
  19. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/config_cmd.py +0 -0
  20. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/diff.py +0 -0
  21. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/doctor.py +0 -0
  22. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/impact.py +0 -0
  23. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/info.py +0 -0
  24. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/kg.py +0 -0
  25. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/remote.py +0 -0
  26. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/search.py +0 -0
  27. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/cli/serve.py +0 -0
  28. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/client/__init__.py +0 -0
  29. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/client/config.py +0 -0
  30. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/config.py +0 -0
  31. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/config_loader.py +0 -0
  32. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/kg_config.py +0 -0
  33. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem/kg_init.py +0 -0
  34. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem_cli.egg-info/dependency_links.txt +0 -0
  35. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem_cli.egg-info/entry_points.txt +0 -0
  36. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem_cli.egg-info/requires.txt +0 -0
  37. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/ravmem_cli.egg-info/top_level.txt +0 -0
  38. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/setup.cfg +0 -0
  39. {ravmem_cli-1.2.0 → ravmem_cli-1.3.1}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ravmem-cli
3
- Version: 1.2.0
3
+ Version: 1.3.1
4
4
  Summary: RavMem CLI — lightweight client for the RavMem code knowledge graph server
5
5
  Author-email: RavMem <harpreet.singh@ravmem.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ravmem-cli"
7
- version = "1.2.0"
7
+ version = "1.3.1"
8
8
  description = "RavMem CLI — lightweight client for the RavMem code knowledge graph server"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -73,7 +73,7 @@ def wait_for_indexing(client, project_id: str, branch_name: str, job_id: int, wa
73
73
 
74
74
 
75
75
  def _poll_job_with_progress(
76
- client, job_id: int, label: str, interval: float = 1.0, timeout: float = 600.0,
76
+ client, job_id: int, label: str, interval: float = 1.0,
77
77
  ) -> dict:
78
78
  """Poll a job to completion, rendering a live progress bar (phase + %)
79
79
  instead of ``client.poll_job``'s silent loop — which, combined with the
@@ -92,7 +92,6 @@ def _poll_job_with_progress(
92
92
  httpx_logger = logging.getLogger("httpx")
93
93
  prev_level = httpx_logger.level
94
94
  httpx_logger.setLevel(logging.WARNING)
95
- deadline = time.monotonic() + timeout
96
95
  try:
97
96
  with Progress(
98
97
  TextColumn("[bold]{task.description}"),
@@ -112,8 +111,7 @@ def _poll_job_with_progress(
112
111
  if status in ("done", "failed", "cancelled"):
113
112
  progress.update(task, completed=100 if status == "done" else pct)
114
113
  return job
115
- if time.monotonic() >= deadline:
116
- raise TimeoutError(f"Job {job_id} did not complete within {timeout}s")
114
+ # No deadline: --wait means until the job ends, and on a large repo embedding alone takes many minutes.
117
115
  time.sleep(interval)
118
116
  finally:
119
117
  httpx_logger.setLevel(prev_level)
@@ -56,23 +56,16 @@ def diff_name_status(
56
56
 
57
57
  Paths are relative to *repo_path*.
58
58
  """
59
+ # NUL separation keeps tabs, spaces and non-ASCII names intact, and --no-renames reports a rename as a delete
60
+ # plus an add, so the old path is removed and the new one uploaded.
59
61
  out = _run(
60
- ["git", "diff", "--name-status", base_commit, target], cwd=repo_path,
62
+ ["git", "diff", "--name-status", "-z", "--no-renames", base_commit, target], cwd=repo_path,
61
63
  )
62
64
  modified: list[str] = []
63
65
  deleted: list[str] = []
64
- for line in out.splitlines():
65
- line = line.strip()
66
- if not line:
67
- continue
68
- parts = line.split("\t", 1)
69
- if len(parts) != 2:
70
- continue
71
- status, path = parts[0].strip(), parts[1].strip()
72
- if status.startswith("D"):
73
- deleted.append(path)
74
- else:
75
- modified.append(path)
66
+ fields = out.split("\0")
67
+ for status, path in zip(fields[::2], fields[1::2]):
68
+ (deleted if status.startswith("D") else modified).append(path)
76
69
  return modified, deleted
77
70
 
78
71
 
@@ -47,7 +47,7 @@ def _post_with_key_fallback(client, con: Console, path: str, body: Optional[dict
47
47
  try:
48
48
  return client.post(path, json=body)
49
49
  except RavMemAPIError as exc:
50
- if exc.status == 400 and "RAVMEM_LICENSE_KEY is not set" in (exc.detail or ""):
50
+ if exc.status == 400 and "RAVMEM_LICENSE_KEY is not set" in (exc.detail or "") and not body.get("license_key"):
51
51
  con.print("[yellow]Server has no license key on file — enter it to proceed.[/yellow]")
52
52
  key = typer.prompt("License key", hide_input=True).strip()
53
53
  if not key:
@@ -57,6 +57,37 @@ def _post_with_key_fallback(client, con: Console, path: str, body: Optional[dict
57
57
  raise
58
58
 
59
59
 
60
+ def _license_hint(detail: str) -> Optional[str]:
61
+ """Map a license-server refusal to the concrete next step for the operator."""
62
+ d = detail or ""
63
+ if "LICENSE_REVOKED" in d:
64
+ return ("This license was revoked. Rotate the key in the console (that un-revokes it and "
65
+ "issues a new key), then run: ravmem license activate <new-key>")
66
+ if "LICENSE_KEY_NOT_FOUND" in d or "License key not found" in d or "No account found" in d:
67
+ return ("The license server doesn't recognise this key. If it was rotated in the console, "
68
+ "run: ravmem license activate <new-key>")
69
+ if "ACTIVATION_LIMIT_REACHED" in d:
70
+ return ("Every seat on this license is in use — possibly by this same machine under a "
71
+ "previous key. Deactivate the old machine in the console, then retry.")
72
+ if "SUBSCRIPTION_SUSPENDED" in d:
73
+ return "The subscription is suspended. Resolve billing in the console, then run: ravmem license renew"
74
+ if "SUBSCRIPTION_EXPIRED" in d:
75
+ return "The subscription has expired. Renew it in the console, then run: ravmem license renew"
76
+ if "No license record found" in d:
77
+ return "Run: ravmem license activate <key>"
78
+ return None
79
+
80
+
81
+ def _fail(con: Console, prefix: str, exc: Exception, note: Optional[str] = None) -> None:
82
+ con.print(f"[red]{prefix}: {exc}[/red]")
83
+ if note:
84
+ con.print(f"[dim]{note}[/dim]")
85
+ hint = _license_hint(getattr(exc, "detail", "") or str(exc))
86
+ if hint:
87
+ con.print(f"[yellow]-> {hint}[/yellow]")
88
+ raise typer.Exit(1)
89
+
90
+
60
91
  # ---------------------------------------------------------------------------
61
92
  # ravmem license init
62
93
  # ---------------------------------------------------------------------------
@@ -146,19 +177,25 @@ def activate_cmd(
146
177
  # that is fine — we just continue to the entitlement step.
147
178
  con.print("Sealing license to this machine…")
148
179
  sealed_now = False
149
- try:
150
- client.post("/activate", json={"license_key": key})
151
- sealed_now = True
152
- con.print("[green]K3 sealed. Waiting for the server to load modules…[/green]")
153
- except RavMemAPIError as exc:
154
- if exc.status == 404:
155
- con.print("[dim]Server already activated — proceeding to entitlement init.[/dim]")
156
- else:
157
- con.print(f"[red]Activation failed: {exc}[/red]")
180
+ for attempt in range(3):
181
+ try:
182
+ client.post("/activate", json={"license_key": key})
183
+ sealed_now = True
184
+ con.print("[green]K3 sealed. Waiting for the server to load modules…[/green]")
185
+ break
186
+ except RavMemAPIError as exc:
187
+ if exc.status == 404:
188
+ con.print("[dim]Server already activated — proceeding to entitlement init.[/dim]")
189
+ break
190
+ _fail(con, "Activation failed", exc)
191
+ except Exception as exc:
192
+ # Transient: server still booting, or a network blip to it.
193
+ if attempt < 2:
194
+ time.sleep(3)
195
+ continue
196
+ con.print(f"[red]Could not reach the server: {exc}[/red]")
197
+ con.print("[yellow]-> Check it's running ('docker logs ravmem'), then retry.[/yellow]")
158
198
  raise typer.Exit(1)
159
- except Exception as exc:
160
- con.print(f"[red]Could not reach the server: {exc}[/red]")
161
- raise typer.Exit(1)
162
199
 
163
200
  # Phase 2 — wait for the re-exec'd full server to finish loading modules.
164
201
  if sealed_now:
@@ -181,44 +218,57 @@ def activate_cmd(
181
218
 
182
219
  # Phase 3 — store the signed entitlement JWT (idempotent: 409 = already done).
183
220
  con.print("Activating entitlements…")
184
- init_payload: dict = {"key_hash": key_hash}
221
+ init_payload: dict = {"key_hash": key_hash, "license_key": key}
185
222
  if server_url:
186
223
  init_payload["server_url"] = server_url
187
224
  try:
188
225
  result = client.post("/api/license/init", json=init_payload)
189
226
  except RavMemAPIError as exc:
190
- if exc.status == 409:
191
- # Already initialized — a 409 alone doesn't mean the stored token is
192
- # still usable (e.g. a container recreate can make it undecryptable).
193
- # Check token_integrity and repair if it isn't "ok" before declaring
194
- # success.
195
- con.print("[dim]License already initialized — verifying token integrity…[/dim]")
196
- try:
197
- status_data = client.get("/api/license/status")
198
- except RavMemAPIError as exc2:
199
- con.print(f"[red]Could not verify license status: {exc2}[/red]")
200
- raise typer.Exit(1)
227
+ if exc.status != 409:
228
+ _fail(con, "Entitlement init failed", exc)
229
+
230
+ # Already initialized. That alone doesn't mean everything is fine: the
231
+ # stored token may be broken (recreate), the license may be
232
+ # deactivated/revoked, or the key typed here may differ from the one
233
+ # this server holds (rotated key, or a typo). Only report "ready" when
234
+ # all three check out; otherwise re-fetch with the key given here —
235
+ # the license server validates it either way, and on refusal nothing
236
+ # stored locally is changed.
237
+ con.print("[dim]License already initialized — verifying…[/dim]")
238
+ try:
239
+ status_data = client.get("/api/license/status")
240
+ except RavMemAPIError as exc2:
241
+ _fail(con, "Could not verify license status", exc2)
201
242
 
202
- integrity = status_data.get("token_integrity")
203
- if integrity == "ok":
204
- con.print("[bold green]License already initialized — ravmem is ready.[/bold green]")
205
- return
243
+ integrity = status_data.get("token_integrity")
244
+ status = status_data.get("status")
245
+ stored_prefix = status_data.get("key_hash_prefix")
246
+ key_differs = bool(stored_prefix) and not key_hash.startswith(stored_prefix)
206
247
 
207
- con.print(f"[yellow]Stored token integrity is '{integrity}' — repairing…[/yellow]")
208
- try:
209
- # Already have the key in scope from this activate call — no
210
- # need to prompt again even if the server has nothing on file.
211
- repair_result = client.post("/api/license/repair", json={"license_key": key})
212
- except RavMemAPIError as exc3:
213
- con.print(f"[red]Repair failed: {exc3}[/red]")
214
- raise typer.Exit(1)
215
-
216
- con.print("\n[bold green]License repaired. ravmem is ready.[/bold green]")
217
- con.print(f" Status: {repair_result.get('status')}")
218
- con.print(f" Expires: {repair_result.get('expiry_ts')}")
248
+ if integrity == "ok" and status in ("ACTIVE", "GRACE_PERIOD") and not key_differs:
249
+ con.print("[bold green]License already initialized — ravmem is ready.[/bold green]")
219
250
  return
220
- con.print(f"[red]Entitlement init failed: {exc}[/red]")
221
- raise typer.Exit(1)
251
+
252
+ if key_differs:
253
+ con.print("[yellow]This key differs from the one this server is activated with — "
254
+ "switching to it…[/yellow]")
255
+ elif status not in ("ACTIVE", "GRACE_PERIOD"):
256
+ con.print(f"[yellow]License status is {status} — re-activating…[/yellow]")
257
+ else:
258
+ con.print(f"[yellow]Stored token integrity is '{integrity}' — repairing…[/yellow]")
259
+ try:
260
+ repair_result = client.post("/api/license/repair", json={"license_key": key})
261
+ except RavMemAPIError as exc3:
262
+ _fail(con, "Activation failed", exc3,
263
+ note="Your existing license on this server was left unchanged." if key_differs else None)
264
+
265
+ if repair_result.get("status") == "rebind_pending":
266
+ con.print(f"[yellow]{repair_result.get('message')}[/yellow]")
267
+ raise typer.Exit(1)
268
+ con.print("\n[bold green]License activated. ravmem is ready.[/bold green]")
269
+ con.print(f" Status: {repair_result.get('status')}")
270
+ con.print(f" Expires: {repair_result.get('expiry_ts')}")
271
+ return
222
272
 
223
273
  con.print("\n[bold green]License activated. ravmem is ready.[/bold green]")
224
274
  con.print(f" Status: {result.get('status')}")
@@ -382,6 +432,8 @@ def status_cmd(
382
432
  expiry_ts = data.get("expiry_ts")
383
433
  table.add_row("Expires", _fmt_ts(expiry_ts) + _days_until(expiry_ts))
384
434
  table.add_row("Last heartbeat", _fmt_ts(data.get("last_heartbeat")))
435
+ if data.get("heartbeat_interval"):
436
+ table.add_row("Heartbeat every", f"{data['heartbeat_interval']}s (set in the license console)")
385
437
  table.add_row("Last server time", _fmt_ts(data.get("last_server_time")))
386
438
 
387
439
  grace_deadline = data.get("grace_deadline")
@@ -405,6 +457,8 @@ def status_cmd(
405
457
  )
406
458
  elif status == "GRACE_PERIOD":
407
459
  con.print("\n[yellow]⚠ License in grace period. Renew before the grace window closes.[/yellow]")
460
+ else:
461
+ _status_hint(con, status)
408
462
 
409
463
  if status not in ("ACTIVE", "GRACE_PERIOD", "DEACTIVATED"):
410
464
  raise typer.Exit(1)
@@ -414,16 +468,31 @@ def status_cmd(
414
468
  # ravmem license renew
415
469
  # ---------------------------------------------------------------------------
416
470
 
471
+ _KEY_OPTION_HELP = (
472
+ "License key to use. Only needed if the key was rotated in the console "
473
+ "(this server is then switched onto it), or if the server has no key on file."
474
+ )
475
+
476
+
477
+ def _run_recovery(con: Console, path: str, key: Optional[str], failed: str) -> dict:
478
+ client = _get_client()
479
+ body = {"license_key": key.strip()} if key and key.strip() else {}
480
+ try:
481
+ return _post_with_key_fallback(client, con, path, body)
482
+ except RavMemAPIError as exc:
483
+ _fail(con, failed, exc)
484
+
485
+
417
486
  @app.command("renew")
418
- def renew_cmd() -> None:
487
+ def renew_cmd(
488
+ key: Optional[str] = typer.Option(None, "--key", help=_KEY_OPTION_HELP),
489
+ ) -> None:
419
490
  """Re-activate an expired license against the live license server."""
420
491
  con = _console()
421
- client = _get_client()
422
492
  con.print("Renewing license…")
423
- try:
424
- result = _post_with_key_fallback(client, con, "/api/license/renew")
425
- except RavMemAPIError as exc:
426
- con.print(f"[red]Renewal failed: {exc}[/red]")
493
+ result = _run_recovery(con, "/api/license/renew", key, "Renewal failed")
494
+ if result.get("status") == "rebind_pending":
495
+ con.print(f"[yellow]{result.get('message')}[/yellow]")
427
496
  raise typer.Exit(1)
428
497
  con.print(f"[green]Renewed. Status: {result.get('status')} | Expiry: {result.get('expiry_ts')}[/green]")
429
498
 
@@ -433,20 +502,19 @@ def renew_cmd() -> None:
433
502
  # ---------------------------------------------------------------------------
434
503
 
435
504
  @app.command("repair")
436
- def repair_cmd() -> None:
437
- """Re-fetch and re-encrypt the JWT when the stored token is corrupted."""
505
+ def repair_cmd(
506
+ key: Optional[str] = typer.Option(None, "--key", help=_KEY_OPTION_HELP),
507
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt"),
508
+ ) -> None:
509
+ """Re-fetch a valid license token for this server (broken token, recreated
510
+ container, deactivated machine, or a key rotated in the console)."""
438
511
  con = _console()
439
- con.print(
440
- "[yellow]This will re-contact the license server using the stored key_hash "
441
- "and machine fingerprint. RAVMEM_LICENSE_KEY must be set on the server.[/yellow]"
442
- )
443
- typer.confirm("Continue?", abort=True)
444
-
445
- client = _get_client()
446
- try:
447
- result = _post_with_key_fallback(client, con, "/api/license/repair")
448
- except RavMemAPIError as exc:
449
- con.print(f"[red]Repair failed: {exc}[/red]")
512
+ if not yes:
513
+ con.print("[yellow]This re-contacts the license server and replaces this server's stored token.[/yellow]")
514
+ typer.confirm("Continue?", abort=True)
515
+ result = _run_recovery(con, "/api/license/repair", key, "Repair failed")
516
+ if result.get("status") == "rebind_pending":
517
+ con.print(f"[yellow]{result.get('message')}[/yellow]")
450
518
  raise typer.Exit(1)
451
519
  con.print(f"[green]Repair complete. Status: {result.get('status')}[/green]")
452
520
 
@@ -489,29 +557,43 @@ def check_cmd() -> None:
489
557
  table.add_row("Revision", str(data.get("revision", "—")))
490
558
  table.add_row("Expires", _fmt_ts(data.get("expiry_ts")))
491
559
  table.add_row("Last heartbeat", _fmt_ts(data.get("last_heartbeat")))
560
+ if data.get("heartbeat_interval"):
561
+ table.add_row("Heartbeat every", f"{data['heartbeat_interval']}s (set in the license console)")
492
562
  grace_deadline = data.get("grace_deadline")
493
563
  if grace_deadline:
494
564
  table.add_row("[yellow]Grace deadline[/yellow]", f"[yellow]{_fmt_ts(grace_deadline)}[/yellow]")
495
565
  con.print(table)
566
+ _status_hint(con, status)
496
567
 
497
568
  if status not in ("ACTIVE", "GRACE_PERIOD", "DEACTIVATED", "SUSPENDED"):
498
569
  raise typer.Exit(1)
499
570
 
500
571
 
572
+ def _status_hint(con: Console, status: str) -> None:
573
+ hints = {
574
+ "REVOKED": _license_hint("LICENSE_REVOKED"),
575
+ "REQUIRES_REACTIVATION": "Run: ravmem license repair (or: ravmem license activate <key>)",
576
+ "TAMPERED": "Run: ravmem license repair",
577
+ "DEACTIVATED": "This machine was deactivated. To keep it, run: ravmem license activate <key>",
578
+ "SUSPENDED": _license_hint("SUBSCRIPTION_SUSPENDED"),
579
+ "UNINITIALIZED": "Run: ravmem license activate <key>",
580
+ }
581
+ if hints.get(status):
582
+ con.print(f"[yellow]-> {hints[status]}[/yellow]")
583
+
584
+
501
585
  @app.command("rebind")
502
- def rebind_cmd() -> None:
503
- """Request a hardware fingerprint rebind after machine migration."""
586
+ def rebind_cmd(
587
+ key: Optional[str] = typer.Option(None, "--key", help=_KEY_OPTION_HELP),
588
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt"),
589
+ ) -> None:
590
+ """Move this license's seat onto this machine (after migration/recreate)."""
504
591
  con = _console()
505
- con.print(
506
- "[yellow]This submits a rebind request to the license server. "
507
- "Vendor approval is required before a new token is issued.[/yellow]"
508
- )
509
- typer.confirm("Continue?", abort=True)
510
-
511
- client = _get_client()
512
- try:
513
- result = _post_with_key_fallback(client, con, "/api/license/rebind")
514
- except RavMemAPIError as exc:
515
- con.print(f"[red]Rebind request failed: {exc}[/red]")
592
+ if not yes:
593
+ con.print("[yellow]This moves the license seat onto this machine's current identity.[/yellow]")
594
+ typer.confirm("Continue?", abort=True)
595
+ result = _run_recovery(con, "/api/license/rebind", key, "Rebind failed")
596
+ if result.get("status") == "rebind_pending":
597
+ con.print(f"[yellow]{result.get('message')}[/yellow]")
516
598
  raise typer.Exit(1)
517
- con.print(f"[green]{result.get('message', 'Rebind request submitted.')}[/green]")
599
+ con.print(f"[green]{result.get('message', 'Rebound.')}[/green]")
@@ -1,10 +1,20 @@
1
1
  """CLI entry point — registers all subcommands."""
2
2
 
3
3
  import logging
4
+ import sys
4
5
  from typing import Optional
5
6
 
6
7
  import typer
7
8
 
9
+ # Legacy Windows consoles (cp1252) can't encode the ⚠/…/— glyphs used in
10
+ # output; without this, e.g. `ravmem license status` crashed with a
11
+ # UnicodeEncodeError *after* printing, exiting non-zero on a healthy license.
12
+ for _stream in (sys.stdout, sys.stderr):
13
+ try:
14
+ _stream.reconfigure(errors="replace")
15
+ except Exception:
16
+ pass
17
+
8
18
 
9
19
  def setup_logging(verbose: bool = False) -> None:
10
20
  """Configure root logger based on verbosity or RAVMEM_LOG_LEVEL env var.
@@ -52,6 +52,21 @@ def handle_api_error(exc: Exception) -> None:
52
52
  raise typer.Exit(1)
53
53
 
54
54
 
55
+ _MAX_ATTEMPTS = 8
56
+ _MAX_RETRY_WAIT_SECS = 120
57
+ # A delete waits up to 2 minutes for running jobs to stop, then purges the graph in chunks, which takes minutes on
58
+ # a large project. The normal 30 s timeout would report a failure for a delete that goes on to succeed.
59
+ _DELETE_TIMEOUT_SECS = 1800.0
60
+
61
+
62
+ def _retry_after_secs(resp: httpx.Response, attempt: int) -> float:
63
+ try:
64
+ wait = float(resp.headers.get("Retry-After", ""))
65
+ except ValueError:
66
+ wait = 2 ** attempt
67
+ return min(max(wait, 1.0), _MAX_RETRY_WAIT_SECS)
68
+
69
+
55
70
  class RavMemClient:
56
71
  """Thin wrapper around the RavMem REST API."""
57
72
 
@@ -100,11 +115,25 @@ class RavMemClient:
100
115
  # Internal helper
101
116
  # ------------------------------------------------------------------
102
117
 
103
- def _request(self, method: str, path: str, **kwargs) -> dict | list:
104
- try:
105
- resp = self._client.request(method, path, **kwargs)
106
- except (httpx.ConnectError, httpx.TimeoutException):
107
- raise
118
+ def _request(self, method: str, path: str, *, retry_timeouts: bool = False, **kwargs) -> dict | list:
119
+ """Send a request, waiting out any 429 for as long as the server's Retry-After says.
120
+
121
+ ``retry_timeouts`` is only for calls the server treats as idempotent: a timed-out request may still have
122
+ been processed, so resending anything else could apply it twice.
123
+ """
124
+ attempt = 0
125
+ while True:
126
+ attempt += 1
127
+ try:
128
+ resp = self._client.request(method, path, **kwargs)
129
+ except httpx.TimeoutException:
130
+ if not retry_timeouts or attempt >= _MAX_ATTEMPTS:
131
+ raise
132
+ time.sleep(min(2 ** attempt, _MAX_RETRY_WAIT_SECS))
133
+ continue
134
+ if resp.status_code != 429 or attempt >= _MAX_ATTEMPTS:
135
+ break
136
+ time.sleep(_retry_after_secs(resp, attempt))
108
137
 
109
138
  try:
110
139
  resp.raise_for_status()
@@ -163,7 +192,7 @@ class RavMemClient:
163
192
  return self._request("GET", f"/api/projects/{project_id}")
164
193
 
165
194
  def delete_project(self, project_id: str) -> dict:
166
- return self._request("DELETE", f"/api/projects/{project_id}")
195
+ return self._request("DELETE", f"/api/projects/{project_id}", timeout=_DELETE_TIMEOUT_SECS)
167
196
 
168
197
  def update_project(self, project_id: str, default_branch: str) -> dict:
169
198
  return self._request(
@@ -233,7 +262,8 @@ class RavMemClient:
233
262
  body["parent_branch"] = parent_branch
234
263
  if langs is not None:
235
264
  body["langs"] = langs
236
- return self._request("POST", f"/api/projects/{project_id}/index", json=body)
265
+ # The server ignores a batch it already has, so a timed-out batch is safe to send again.
266
+ return self._request("POST", f"/api/projects/{project_id}/index", json=body, retry_timeouts=True)
237
267
 
238
268
  def index_branch(
239
269
  self,
@@ -264,7 +294,7 @@ class RavMemClient:
264
294
 
265
295
  def delete_branch_index(self, project_id: str, branch: str) -> dict:
266
296
  return self._request(
267
- "DELETE", f"/api/projects/{project_id}/branches/{branch}/index"
297
+ "DELETE", f"/api/projects/{project_id}/branches/{branch}/index", timeout=_DELETE_TIMEOUT_SECS,
268
298
  )
269
299
 
270
300
  def get_divergence(self, project_id: str, branch: str) -> dict:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ravmem-cli
3
- Version: 1.2.0
3
+ Version: 1.3.1
4
4
  Summary: RavMem CLI — lightweight client for the RavMem code knowledge graph server
5
5
  Author-email: RavMem <harpreet.singh@ravmem.com>
6
6
  License-Expression: MIT
@@ -33,4 +33,5 @@ ravmem_cli.egg-info/dependency_links.txt
33
33
  ravmem_cli.egg-info/entry_points.txt
34
34
  ravmem_cli.egg-info/requires.txt
35
35
  ravmem_cli.egg-info/top_level.txt
36
+ tests/test_client_delete_timeout.py
36
37
  tests/test_smoke.py
@@ -0,0 +1,28 @@
1
+ import httpx
2
+
3
+ from ravmem.client import api
4
+
5
+
6
+ def _client_seeing(seen):
7
+ def handler(request: httpx.Request) -> httpx.Response:
8
+ seen.append(request.extensions["timeout"])
9
+ return httpx.Response(200, json={"deleted": True})
10
+
11
+ client = api.RavMemClient("http://server")
12
+ client._client = httpx.Client(base_url="http://server", transport=httpx.MockTransport(handler), timeout=30.0)
13
+ return client
14
+
15
+
16
+ def test_deletes_wait_longer_than_the_default_timeout():
17
+ seen = []
18
+ client = _client_seeing(seen)
19
+ client.delete_project("p")
20
+ client.delete_branch_index("p", "main")
21
+ assert [t["read"] for t in seen] == [api._DELETE_TIMEOUT_SECS] * 2
22
+
23
+
24
+ def test_other_calls_keep_the_default_timeout():
25
+ seen = []
26
+ client = _client_seeing(seen)
27
+ client._request("GET", "/api/projects")
28
+ assert seen[0]["read"] == 30.0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes