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.
- {coelevate_cli-0.2.0 → coelevate_cli-0.3.0}/PKG-INFO +18 -5
- {coelevate_cli-0.2.0 → coelevate_cli-0.3.0}/README.md +17 -4
- {coelevate_cli-0.2.0 → coelevate_cli-0.3.0}/pyproject.toml +1 -1
- {coelevate_cli-0.2.0 → coelevate_cli-0.3.0}/src/coelevate_cli/__init__.py +1 -1
- {coelevate_cli-0.2.0 → coelevate_cli-0.3.0}/src/coelevate_cli/cli.py +124 -7
- {coelevate_cli-0.2.0 → coelevate_cli-0.3.0}/.gitignore +0 -0
- {coelevate_cli-0.2.0 → coelevate_cli-0.3.0}/CONTRIBUTING.md +0 -0
- {coelevate_cli-0.2.0 → coelevate_cli-0.3.0}/LICENSE +0 -0
- {coelevate_cli-0.2.0 → coelevate_cli-0.3.0}/src/coelevate_cli/__main__.py +0 -0
- {coelevate_cli-0.2.0 → coelevate_cli-0.3.0}/src/coelevate_cli/auth.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: coelevate-cli
|
|
3
|
-
Version: 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.
|
|
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.
|
|
27
|
+
uvx --from coelevate-cli==0.3.0 coelevate --help
|
|
28
28
|
|
|
29
29
|
# isolated tool install
|
|
30
|
-
uv tool install coelevate-cli==0.
|
|
30
|
+
uv tool install coelevate-cli==0.3.0
|
|
31
31
|
# or
|
|
32
|
-
pipx install coelevate-cli==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.
|
|
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.
|
|
16
|
+
uvx --from coelevate-cli==0.3.0 coelevate --help
|
|
17
17
|
|
|
18
18
|
# isolated tool install
|
|
19
|
-
uv tool install coelevate-cli==0.
|
|
19
|
+
uv tool install coelevate-cli==0.3.0
|
|
20
20
|
# or
|
|
21
|
-
pipx install coelevate-cli==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.
|
|
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"
|
|
@@ -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
|
-
|
|
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
|
|
387
|
-
return
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|