coelevate-cli 0.2.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: coelevate-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Command-line client for the Coelevate Context Repository public API: read, search, stage changes, and inspect releases.
5
5
  Project-URL: Documentation, https://docs.coelevate.ai
6
6
  License-Expression: Apache-2.0
@@ -20,16 +20,16 @@ Requires Python 3.11 or newer. The only dependency is `httpx`.
20
20
  ## Install
21
21
 
22
22
  The package is published on PyPI as `coelevate-cli`; the command it installs is
23
- `coelevate`. Pin a version (`0.2.0` below):
23
+ `coelevate`. Pin a version (`0.3.0` below):
24
24
 
25
25
  ```bash
26
26
  # one-off, no install (recommended: pinned, nothing left behind)
27
- uvx --from coelevate-cli==0.2.0 coelevate --help
27
+ uvx --from coelevate-cli==0.3.0 coelevate --help
28
28
 
29
29
  # isolated tool install
30
- uv tool install coelevate-cli==0.2.0
30
+ uv tool install coelevate-cli==0.3.0
31
31
  # or
32
- pipx install coelevate-cli==0.2.0
32
+ pipx install coelevate-cli==0.3.0
33
33
  ```
34
34
 
35
35
  PyPI lists the SHA-256 of every file it serves under the project's **Download files**; compare a
@@ -114,11 +114,24 @@ coelevate asset-types
114
114
  coelevate changeset
115
115
  coelevate stage --op-file op.json
116
116
  coelevate stage --remove-op op_123 # un-stage one op
117
+ coelevate stage --remove-op op_1,op_2 # un-stage several (the flag also repeats)
118
+ coelevate stage --remove-batch-file out.json # un-stage every op a saved `stage --json` answer minted
117
119
  coelevate tag --name stable
118
120
  coelevate release --number 3
119
121
  coelevate release --limit 10 --offset 0
120
122
  ```
121
123
 
124
+ ### Output shapes
125
+
126
+ `--json` prints the service's answer with a few keys added, never removed or renamed, so one parser reads every action:
127
+
128
+ | Action | Added keys |
129
+ |---|---|
130
+ | `search`, `assets`, `kits` | each entry gets `id`, `type`, `title`, `summary` (taken from `asset_id`, `card`, `label` or `description`; `summary` is null when the service sends none) |
131
+ | `changeset` | `changeset_id` and `op_count` at the top level, next to `revision` and `base_release` (the open changeset stays under `changeset`, its ops in `changeset.ops[]`, each op's body under `op`) |
132
+ | `stage` | `staged_op_ids` (the ops this request minted, from `batch_ops`), `changeset_id`, `op_count`, `expected_revision`, `expected_release` |
133
+ | `stage --remove-op` | `removed_op_ids` |
134
+
122
135
  ### Staging
123
136
 
124
137
  Ops are JSON passed through unchanged; the server validates them and answers 422 for a bad one.
@@ -9,16 +9,16 @@ Requires Python 3.11 or newer. The only dependency is `httpx`.
9
9
  ## Install
10
10
 
11
11
  The package is published on PyPI as `coelevate-cli`; the command it installs is
12
- `coelevate`. Pin a version (`0.2.0` below):
12
+ `coelevate`. Pin a version (`0.3.0` below):
13
13
 
14
14
  ```bash
15
15
  # one-off, no install (recommended: pinned, nothing left behind)
16
- uvx --from coelevate-cli==0.2.0 coelevate --help
16
+ uvx --from coelevate-cli==0.3.0 coelevate --help
17
17
 
18
18
  # isolated tool install
19
- uv tool install coelevate-cli==0.2.0
19
+ uv tool install coelevate-cli==0.3.0
20
20
  # or
21
- pipx install coelevate-cli==0.2.0
21
+ pipx install coelevate-cli==0.3.0
22
22
  ```
23
23
 
24
24
  PyPI lists the SHA-256 of every file it serves under the project's **Download files**; compare a
@@ -103,11 +103,24 @@ coelevate asset-types
103
103
  coelevate changeset
104
104
  coelevate stage --op-file op.json
105
105
  coelevate stage --remove-op op_123 # un-stage one op
106
+ coelevate stage --remove-op op_1,op_2 # un-stage several (the flag also repeats)
107
+ coelevate stage --remove-batch-file out.json # un-stage every op a saved `stage --json` answer minted
106
108
  coelevate tag --name stable
107
109
  coelevate release --number 3
108
110
  coelevate release --limit 10 --offset 0
109
111
  ```
110
112
 
113
+ ### Output shapes
114
+
115
+ `--json` prints the service's answer with a few keys added, never removed or renamed, so one parser reads every action:
116
+
117
+ | Action | Added keys |
118
+ |---|---|
119
+ | `search`, `assets`, `kits` | each entry gets `id`, `type`, `title`, `summary` (taken from `asset_id`, `card`, `label` or `description`; `summary` is null when the service sends none) |
120
+ | `changeset` | `changeset_id` and `op_count` at the top level, next to `revision` and `base_release` (the open changeset stays under `changeset`, its ops in `changeset.ops[]`, each op's body under `op`) |
121
+ | `stage` | `staged_op_ids` (the ops this request minted, from `batch_ops`), `changeset_id`, `op_count`, `expected_revision`, `expected_release` |
122
+ | `stage --remove-op` | `removed_op_ids` |
123
+
111
124
  ### Staging
112
125
 
113
126
  Ops are JSON passed through unchanged; the server validates them and answers 422 for a bad one.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "coelevate-cli"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Command-line client for the Coelevate Context Repository public API: read, search, stage changes, and inspect releases."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Command-line client for the Coelevate Context Repository public API."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -22,6 +22,7 @@ from __future__ import annotations
22
22
  import argparse
23
23
  import json
24
24
  import os
25
+ import re
25
26
  import sys
26
27
  import time
27
28
  from contextlib import ExitStack
@@ -266,7 +267,27 @@ def _cmd_search(args: argparse.Namespace, ctx: _Context) -> Any:
266
267
  }
267
268
  if args.asset_type:
268
269
  body["asset_type"] = args.asset_type
269
- return ctx.call("search", json_body=body)
270
+ result = ctx.call("search", json_body=body)
271
+ if isinstance(result, dict) and isinstance(result.get("results"), list):
272
+ result["results"] = [_normalise_item(item) for item in result["results"]]
273
+ return result
274
+
275
+
276
+ def _normalise_item(item: Any, kind: str | None = None) -> Any:
277
+ """Add the four keys every listing shares (``id``, ``type``, ``title``, ``summary``) to one entry.
278
+
279
+ ``search``, ``assets`` and ``kits`` answer with different shapes (``asset_id`` and ``label``, a ``card``,
280
+ ``title`` and ``description``), so a caller would need one parser per action. The keys are added next to
281
+ the service's own, never in place of them; ``summary`` is null when the service sends none.
282
+ """
283
+ if not isinstance(item, dict):
284
+ return item
285
+ card = item.get("card") if isinstance(item.get("card"), dict) else {}
286
+ item.setdefault("id", item.get("asset_id") or card.get("id"))
287
+ item.setdefault("type", item.get("asset_type") or kind)
288
+ item.setdefault("title", card.get("title") or item.get("label"))
289
+ item.setdefault("summary", card.get("summary") or item.get("description"))
290
+ return item
270
291
 
271
292
 
272
293
  def _cmd_kits(args: argparse.Namespace, ctx: _Context) -> Any:
@@ -275,6 +296,7 @@ def _cmd_kits(args: argparse.Namespace, ctx: _Context) -> Any:
275
296
  raise CliError("unexpected response from the service: no 'kits' list")
276
297
  if not all(isinstance(kit, dict) for kit in result["kits"]):
277
298
  raise CliError("unexpected response from the service: every entry of 'kits' must be an object")
299
+ result["kits"] = [_normalise_item(kit, "kit") for kit in result["kits"]]
278
300
  return result
279
301
 
280
302
 
@@ -295,6 +317,7 @@ def _cmd_assets(args: argparse.Namespace, ctx: _Context) -> Any:
295
317
  result = ctx.call("assets", params=params)
296
318
  if not isinstance(result, dict) or not isinstance(result.get("assets"), list):
297
319
  raise CliError("unexpected response from the service: no 'assets' list")
320
+ result["assets"] = [_normalise_item(item) for item in result["assets"]]
298
321
  return result
299
322
 
300
323
 
@@ -363,7 +386,84 @@ def _cmd_asset_types(args: argparse.Namespace, ctx: _Context) -> Any:
363
386
 
364
387
 
365
388
  def _cmd_changeset(args: argparse.Namespace, ctx: _Context) -> Any:
366
- return ctx.call("changeset")
389
+ return _lift_changeset_keys(ctx.call("changeset"))
390
+
391
+
392
+ def _lift_changeset_keys(result: Any) -> Any:
393
+ """Put ``changeset_id`` and ``op_count`` at the top level, where ``stage`` also answers them.
394
+
395
+ ``changeset`` wraps the open changeset under ``changeset`` while ``stage`` answers it flat; with the
396
+ same top-level keys on both, one parser reads either. Nothing the service sent is moved or removed.
397
+ """
398
+ if not isinstance(result, dict):
399
+ return result
400
+ detail = result.get("changeset") if isinstance(result.get("changeset"), dict) else result
401
+ if detail.get("changeset_id"):
402
+ result.setdefault("changeset_id", detail["changeset_id"])
403
+ if isinstance(detail.get("ops"), list):
404
+ result.setdefault("op_count", len(detail["ops"]))
405
+ for key in ("revision", "base_release"):
406
+ if key in detail:
407
+ result.setdefault(key, detail[key])
408
+ return result
409
+
410
+
411
+ #: What an op id may hold (the service mints ``csop_<uuid>``); checked before a removal goes over the
412
+ #: wire, so a pasted list or a stray quote fails with a hint instead of a 404 for a malformed id.
413
+ _OP_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*")
414
+
415
+
416
+ def _batch_op_ids(data: Any) -> list[str]:
417
+ """The op ids in a saved ``stage --json`` answer (``staged_op_ids`` or ``batch_ops[].op_id``), or a JSON list of ids."""
418
+ if isinstance(data, list):
419
+ return [str(op_id) for op_id in data]
420
+ if isinstance(data, dict):
421
+ if isinstance(data.get("staged_op_ids"), list):
422
+ return [str(op_id) for op_id in data["staged_op_ids"]]
423
+ if isinstance(data.get("batch_ops"), list):
424
+ return [str(op["op_id"]) for op in data["batch_ops"] if isinstance(op, dict) and op.get("op_id")]
425
+ raise CliError("--remove-batch-file must hold a saved `stage --json` answer or a JSON list of op ids", code=2)
426
+
427
+
428
+ def _remove_op_ids(args: argparse.Namespace) -> list[str]:
429
+ """Every op id to un-stage, in order and once each: repeated ``--remove-op`` values, lists inside one
430
+ value (split on commas and whitespace) and the ops of a saved batch."""
431
+ ids: list[str] = []
432
+ for value in args.remove_op or []:
433
+ ids.extend(part for part in re.split(r"[\s,]+", value) if part)
434
+ if args.remove_batch_file:
435
+ ids.extend(_batch_op_ids(_load_json_file(args.remove_batch_file, "--remove-batch-file")))
436
+ if not ids:
437
+ raise CliError("no op id to un-stage", code=2)
438
+ bad = [op_id for op_id in ids if not _OP_ID.fullmatch(op_id)]
439
+ if bad:
440
+ raise CliError(f"not an op id: {', '.join(repr(op_id) for op_id in bad)} (read the ids with `coelevate changeset --json`)", code=2)
441
+ return list(dict.fromkeys(ids))
442
+
443
+
444
+ def _unstage(args: argparse.Namespace, ctx: _Context) -> Any:
445
+ """Un-stage each op in turn and stop at the first refusal, saying which were removed and which were not tried.
446
+
447
+ The service removes one op per request, so a batch is not atomic: the report is what makes a partial
448
+ removal safe to finish by hand.
449
+ """
450
+ ids = _remove_op_ids(args)
451
+ removed: list[str] = []
452
+ result: Any = {}
453
+ for index, op_id in enumerate(ids):
454
+ try:
455
+ result = ctx.call("unstage", op_id=op_id)
456
+ except CliError as exc:
457
+ raise CliError(
458
+ f"{exc}\nun-staged before the refusal: {', '.join(removed) or 'none'}; "
459
+ f"not attempted: {', '.join(ids[index + 1 :]) or 'none'}",
460
+ code=exc.code,
461
+ ) from exc
462
+ removed.append(op_id)
463
+ if not isinstance(result, dict):
464
+ result = {"answer": result}
465
+ result["removed_op_ids"] = removed
466
+ return result
367
467
 
368
468
 
369
469
  def _stage_ops(args: argparse.Namespace) -> list[Any]:
@@ -382,9 +482,13 @@ def _stage_ops(args: argparse.Namespace) -> list[Any]:
382
482
  return [_load_json(args.op, "--op")]
383
483
 
384
484
 
485
+ def _removing(args: argparse.Namespace) -> bool:
486
+ return bool(getattr(args, "remove_op", None) or getattr(args, "remove_batch_file", None))
487
+
488
+
385
489
  def _cmd_stage(args: argparse.Namespace, ctx: _Context) -> Any:
386
- if args.remove_op:
387
- return ctx.call("unstage", op_id=args.remove_op)
490
+ if _removing(args):
491
+ return _unstage(args, ctx)
388
492
  ops = _stage_ops(args)
389
493
  provenance = None
390
494
  if args.provenance is not None:
@@ -418,6 +522,11 @@ def _cmd_stage(args: argparse.Namespace, ctx: _Context) -> Any:
418
522
  # What this request declared, so a retry can pin the same values.
419
523
  result["expected_revision"] = expected_revision
420
524
  result["expected_release"] = expected_release
525
+ # The ops this request minted, by id: what `--remove-op` or `--remove-batch-file` takes back.
526
+ batch = result.get("batch_ops")
527
+ if isinstance(batch, list):
528
+ result.setdefault("staged_op_ids", [op["op_id"] for op in batch if isinstance(op, dict) and op.get("op_id")])
529
+ _lift_changeset_keys(result)
421
530
  return result
422
531
 
423
532
 
@@ -512,7 +621,7 @@ def _cmd_gap(args: argparse.Namespace, ctx: _Context) -> Any:
512
621
  def _check_retry_is_pinned(args: argparse.Namespace) -> None:
513
622
  """A retry is only the same request when what it declares is pinned, so a key demands
514
623
  both expected values. Checked before anything goes over the wire."""
515
- if args.action != "stage" or not args.idempotency_key or args.remove_op:
624
+ if args.action != "stage" or not args.idempotency_key or _removing(args):
516
625
  return
517
626
  missing = [
518
627
  flag
@@ -536,7 +645,7 @@ def _check_retry_is_pinned(args: argparse.Namespace) -> None:
536
645
  def _stage_scope(args: argparse.Namespace) -> str:
537
646
  """Staging narrows the token to what it needs: write, plus read when it must read the
538
647
  changeset first (a write-only token is refused on that read)."""
539
- if args.remove_op:
648
+ if _removing(args):
540
649
  return ASSETS_SCOPE
541
650
  if args.expected_revision is not None and args.expected_release is not None:
542
651
  return ASSETS_SCOPE
@@ -788,7 +897,15 @@ def build_parser() -> argparse.ArgumentParser:
788
897
  ops.add_argument("--op", help="one op as a JSON string")
789
898
  ops.add_argument("--op-file", help="one op as a JSON file")
790
899
  ops.add_argument("--ops-file", help=f"a JSON array of up to {MAX_OPS_PER_REQUEST} ops, staged in one request")
791
- ops.add_argument("--remove-op", help="un-stage this op id instead of staging")
900
+ ops.add_argument(
901
+ "--remove-op",
902
+ action="append",
903
+ help="un-stage this op id instead of staging; repeatable, and one value may list several ids (comma or space)",
904
+ )
905
+ ops.add_argument(
906
+ "--remove-batch-file",
907
+ help="un-stage every op of a saved `stage --json` answer (its batch_ops), or of a JSON list of op ids",
908
+ )
792
909
  stage_p.add_argument("--provenance", default=None, help="a JSON object recording where the ops came from")
793
910
  stage_p.add_argument("--idempotency-key", default=None, help="make a retry of this request safe")
794
911
  stage_p.add_argument("--expected-revision", type=int, default=None, help="default: read from the changeset just before staging")
File without changes
File without changes