coho-cli 2026.1.0a21__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.
- coho_cli/__init__.py +8 -0
- coho_cli/_entry_ids.py +164 -0
- coho_cli/_io.py +55 -0
- coho_cli/_output.py +103 -0
- coho_cli/_slugs.py +59 -0
- coho_cli/_state.py +205 -0
- coho_cli/_usage.py +367 -0
- coho_cli/commands/__init__.py +1 -0
- coho_cli/commands/account.py +159 -0
- coho_cli/commands/asset.py +325 -0
- coho_cli/commands/auth.py +385 -0
- coho_cli/commands/branch.py +74 -0
- coho_cli/commands/context.py +119 -0
- coho_cli/commands/diffmerge.py +159 -0
- coho_cli/commands/entry.py +297 -0
- coho_cli/commands/env.py +188 -0
- coho_cli/commands/export.py +65 -0
- coho_cli/commands/invite.py +135 -0
- coho_cli/commands/key.py +92 -0
- coho_cli/commands/member.py +79 -0
- coho_cli/commands/preview.py +70 -0
- coho_cli/commands/project.py +157 -0
- coho_cli/commands/ref.py +81 -0
- coho_cli/commands/role.py +95 -0
- coho_cli/commands/tag.py +76 -0
- coho_cli/commands/tier.py +87 -0
- coho_cli/commands/token.py +81 -0
- coho_cli/commands/trait.py +150 -0
- coho_cli/commands/type_.py +159 -0
- coho_cli/commands/user.py +50 -0
- coho_cli/main.py +304 -0
- coho_cli/py.typed +0 -0
- coho_cli-2026.1.0a21.dist-info/METADATA +96 -0
- coho_cli-2026.1.0a21.dist-info/RECORD +37 -0
- coho_cli-2026.1.0a21.dist-info/WHEEL +4 -0
- coho_cli-2026.1.0a21.dist-info/entry_points.txt +2 -0
- coho_cli-2026.1.0a21.dist-info/licenses/LICENSE +201 -0
coho_cli/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""coho_cli — the ``coho`` command. A thin layer over ``coho_management_sdk``."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("coho-cli")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - editable/dev installs
|
|
8
|
+
__version__ = "0.0.0"
|
coho_cli/_entry_ids.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""``@<slug>`` wherever a command takes an entry id: the slug is looked up on the same
|
|
2
|
+
ref, and the command carries on with the id as if it had been typed.
|
|
3
|
+
|
|
4
|
+
The lookup is the delivery contract's ``entries-by-slug/{slug}`` read, which the BFF
|
|
5
|
+
serves live for a ref under ``preview/`` — one request at depth 0, the entry alone. The
|
|
6
|
+
management API has no slug filter on ``entries``, and paging through every entry to find
|
|
7
|
+
one is not a lookup.
|
|
8
|
+
|
|
9
|
+
What that read can and cannot answer:
|
|
10
|
+
|
|
11
|
+
- Slugs are unique per ref among every node's slugs (coho-data D24), so a slug names at
|
|
12
|
+
most one node. That node may be a content type or an asset rather than an entry, and
|
|
13
|
+
is refused as one.
|
|
14
|
+
- A deleted entry has no slug on the ref any more: its id still reaches its history.
|
|
15
|
+
- Live reads serve a branch, or an environment on one. A tag is refused as a ref that
|
|
16
|
+
does not exist, so on a tag the id is the only way in.
|
|
17
|
+
|
|
18
|
+
Entry ids are UUIDs, so no id starts with ``@`` and the prefix cannot shadow one. A UUID
|
|
19
|
+
is a well-formed slug too, so ``@<uuid>`` is looked up as a slug like any other.
|
|
20
|
+
|
|
21
|
+
The same goes for the value of a reference field in ``--set``: ``author=@priya-shah``,
|
|
22
|
+
``tags=["@traffic","@drainage"]``, and of an asset field, where the slug is an asset's:
|
|
23
|
+
``image=@culvert``, ``downloads=["@guide"]``. Only reference and asset fields, as the
|
|
24
|
+
entry's content type declares them, are looked up, so a text field may still hold
|
|
25
|
+
``@handle``. The server then checks the ids as it checks any: that each exists and is of
|
|
26
|
+
a type, or a media type, the field allows.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import re
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
from typing import Any
|
|
34
|
+
from uuid import UUID
|
|
35
|
+
|
|
36
|
+
from coho_management_sdk import NotFound, Ref
|
|
37
|
+
from coho_management_sdk.errors import RefNotFound
|
|
38
|
+
from coho_management_sdk.transport import segment
|
|
39
|
+
|
|
40
|
+
from ._state import Usage
|
|
41
|
+
|
|
42
|
+
# A content slug, as `_slugs.content_slug` makes them and the server checks them.
|
|
43
|
+
_CONTENT_SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def entry_id(ref: Ref, raw: str) -> str:
|
|
47
|
+
"""The entry id ``raw`` names on ``ref``: itself, or for ``@<slug>`` the id of the
|
|
48
|
+
entry with that slug. Pass the ``Ref`` the command then acts on, so the slug is
|
|
49
|
+
looked up where the id will be used."""
|
|
50
|
+
if not raw.startswith("@"):
|
|
51
|
+
return raw
|
|
52
|
+
slug = raw[1:]
|
|
53
|
+
if not _CONTENT_SLUG.match(slug):
|
|
54
|
+
raise Usage(
|
|
55
|
+
f"{raw!r} is not an entry slug: after `@` comes a slug such as @hello-world. "
|
|
56
|
+
"An entry id is a UUID and is passed without the `@`."
|
|
57
|
+
)
|
|
58
|
+
preview = ref.preview()
|
|
59
|
+
try:
|
|
60
|
+
# ⚠️ The SDK's Preview has no by-slug read yet; this is the one request it would
|
|
61
|
+
# make. Move it to `preview.entry_by_slug(slug)` once the SDK has one.
|
|
62
|
+
body = preview._t.get(f"{preview.path}/entries-by-slug/{segment(slug)}").body or {}
|
|
63
|
+
except RefNotFound:
|
|
64
|
+
raise Usage(
|
|
65
|
+
f"cannot look up {raw} on {ref.name}: slugs resolve on a branch, or an "
|
|
66
|
+
"environment on one, and this ref is a tag or does not exist. Use the entry id."
|
|
67
|
+
) from None
|
|
68
|
+
except NotFound as exc:
|
|
69
|
+
# ⚠️ The BFF answers every 404 it relays as ROUTE_NOT_FOUND for now, its own
|
|
70
|
+
# status page replacing authoring's problem, so a missing slug and a tag both
|
|
71
|
+
# arrive as that. Read as either until the BFF relays the code it is given.
|
|
72
|
+
if exc.code not in ("ENTRY_NOT_FOUND", "ROUTE_NOT_FOUND"):
|
|
73
|
+
raise
|
|
74
|
+
# A UUID is a well-formed slug too, so `@<id>` is looked up rather than refused.
|
|
75
|
+
hint = (
|
|
76
|
+
f"To use {slug} as an entry id, drop the `@`."
|
|
77
|
+
if _is_uuid(slug)
|
|
78
|
+
else "`coho entry list` shows the slugs."
|
|
79
|
+
)
|
|
80
|
+
if exc.code == "ROUTE_NOT_FOUND":
|
|
81
|
+
hint += " On a tag, slugs do not resolve: use the entry id."
|
|
82
|
+
raise NotFound(
|
|
83
|
+
f"no entry with slug '{slug}' on {ref.name}. {hint}",
|
|
84
|
+
code="ENTRY_NOT_FOUND",
|
|
85
|
+
status=404,
|
|
86
|
+
) from None
|
|
87
|
+
roots = body.get("roots") or []
|
|
88
|
+
if len(roots) != 1: # pragma: no cover - slugs are unique per ref (D24)
|
|
89
|
+
raise Usage(f"{raw} names {len(roots)} nodes on {ref.name}. Use the entry id.")
|
|
90
|
+
node = (body.get("entries") or {}).get(roots[0]) or {}
|
|
91
|
+
# Only an entry has a content type. A type's or an asset's slug comes back without one.
|
|
92
|
+
if not node.get("type"):
|
|
93
|
+
raise Usage(
|
|
94
|
+
f"{raw} on {ref.name} is a content type or an asset, not an entry. "
|
|
95
|
+
"Slugs are unique across all three."
|
|
96
|
+
)
|
|
97
|
+
return str(roots[0])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def reference_ids(
|
|
101
|
+
ref: Ref, type_of: Callable[[], str], sets: list[tuple[str, Any]]
|
|
102
|
+
) -> list[tuple[str, Any]]:
|
|
103
|
+
"""``--set`` values, each ``@<slug>`` in a reference or asset field replaced by the id
|
|
104
|
+
of the entry, or the asset, with that slug on ``ref``. ``type_of`` gives the entry's
|
|
105
|
+
content type, and is only called, and the type only read, when some value names a
|
|
106
|
+
slug: otherwise nothing is requested."""
|
|
107
|
+
if not any(_names_slug(value) for _, value in sets):
|
|
108
|
+
return sets
|
|
109
|
+
# Effective, so a field the type inherits, or takes from a trait, is found too: a
|
|
110
|
+
# subtype's `author=@priya-shah` names its parent's reference field.
|
|
111
|
+
kinds = {
|
|
112
|
+
f.get("id"): f.get("type")
|
|
113
|
+
for f in ref.types.get(type_of(), effective=True).fields
|
|
114
|
+
if isinstance(f, dict) and f.get("type") in ("reference", "asset")
|
|
115
|
+
}
|
|
116
|
+
found: dict[str, str] = {}
|
|
117
|
+
assets: dict[str, str] | None = None
|
|
118
|
+
|
|
119
|
+
def asset_id(raw: str) -> str:
|
|
120
|
+
# Assets are listed once, by the first asset slug named: the management API has
|
|
121
|
+
# no by-slug read for them, and a page of 1000 covers most projects in one request.
|
|
122
|
+
nonlocal assets
|
|
123
|
+
if assets is None:
|
|
124
|
+
assets = {a.slug: a.id for a in ref.assets.iterate(page_size=1000)}
|
|
125
|
+
slug = raw[1:]
|
|
126
|
+
if slug not in assets:
|
|
127
|
+
raise NotFound(
|
|
128
|
+
f"no asset with slug '{slug}' on {ref.name}. `coho asset list` shows the slugs.",
|
|
129
|
+
code="ASSET_NOT_FOUND",
|
|
130
|
+
status=404,
|
|
131
|
+
)
|
|
132
|
+
return assets[slug]
|
|
133
|
+
|
|
134
|
+
def resolve(kind: Any, value: Any) -> Any:
|
|
135
|
+
if not (isinstance(value, str) and value.startswith("@")):
|
|
136
|
+
return value
|
|
137
|
+
key = f"{kind}:{value}"
|
|
138
|
+
if key not in found:
|
|
139
|
+
found[key] = asset_id(value) if kind == "asset" else entry_id(ref, value)
|
|
140
|
+
return found[key]
|
|
141
|
+
|
|
142
|
+
out: list[tuple[str, Any]] = []
|
|
143
|
+
for path, value in sets:
|
|
144
|
+
kind = kinds.get(path.split(".")[0])
|
|
145
|
+
if kind is not None:
|
|
146
|
+
if isinstance(value, list):
|
|
147
|
+
value = [resolve(kind, v) for v in value]
|
|
148
|
+
else:
|
|
149
|
+
value = resolve(kind, value)
|
|
150
|
+
out.append((path, value))
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _names_slug(value: Any) -> bool:
|
|
155
|
+
values = value if isinstance(value, list) else [value]
|
|
156
|
+
return any(isinstance(v, str) and v.startswith("@") for v in values)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _is_uuid(value: str) -> bool:
|
|
160
|
+
try:
|
|
161
|
+
UUID(value)
|
|
162
|
+
except ValueError:
|
|
163
|
+
return False
|
|
164
|
+
return True
|
coho_cli/_io.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Reading JSON from files, stdin or inline strings — for --file and --fields."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._state import Usage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read_json(source: str | None, *, what: str = "JSON") -> Any:
|
|
14
|
+
"""``-`` reads stdin; a path reads a file; anything starting with ``{`` or ``[`` is inline."""
|
|
15
|
+
if source is None:
|
|
16
|
+
raise Usage(f"{what} is required")
|
|
17
|
+
text: str
|
|
18
|
+
if source == "-":
|
|
19
|
+
text = sys.stdin.read()
|
|
20
|
+
elif source.lstrip().startswith(("{", "[")):
|
|
21
|
+
text = source
|
|
22
|
+
else:
|
|
23
|
+
path = Path(source)
|
|
24
|
+
if not path.exists():
|
|
25
|
+
raise Usage(f"{what}: no such file: {source}")
|
|
26
|
+
text = path.read_text()
|
|
27
|
+
try:
|
|
28
|
+
return json.loads(text)
|
|
29
|
+
except ValueError as exc:
|
|
30
|
+
raise Usage(f"{what} is not valid JSON: {exc}") from exc
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def set_path(doc: dict[str, Any], dotted: str, value: Any) -> None:
|
|
34
|
+
"""Set ``a.b.c`` in a nested dict, creating levels. Used by ``--set``."""
|
|
35
|
+
parts = dotted.split(".")
|
|
36
|
+
cur = doc
|
|
37
|
+
for p in parts[:-1]:
|
|
38
|
+
nxt = cur.get(p)
|
|
39
|
+
if not isinstance(nxt, dict):
|
|
40
|
+
nxt = {}
|
|
41
|
+
cur[p] = nxt
|
|
42
|
+
cur = nxt
|
|
43
|
+
cur[parts[-1]] = value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_set(spec: str) -> tuple[str, Any]:
|
|
47
|
+
"""``path=value``; the value is JSON if it parses, else a string."""
|
|
48
|
+
if "=" not in spec:
|
|
49
|
+
raise Usage(f"--set needs path=value, got '{spec}'")
|
|
50
|
+
path, raw = spec.split("=", 1)
|
|
51
|
+
try:
|
|
52
|
+
value: Any = json.loads(raw)
|
|
53
|
+
except ValueError:
|
|
54
|
+
value = raw
|
|
55
|
+
return path.strip(), value
|
coho_cli/_output.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Printing: tables for people, JSON for scripts, and the shown-once warnings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Iterable, Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from ._state import State
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
err = Console(stderr=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def emit_json(data: Any) -> None:
|
|
20
|
+
"""Exactly what the contract describes, on stdout."""
|
|
21
|
+
sys.stdout.write(json.dumps(data, indent=2, ensure_ascii=False))
|
|
22
|
+
sys.stdout.write("\n")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def table(
|
|
26
|
+
state: State,
|
|
27
|
+
raw: Any,
|
|
28
|
+
columns: Sequence[str],
|
|
29
|
+
rows: Iterable[Sequence[Any]],
|
|
30
|
+
*,
|
|
31
|
+
title: str | None = None,
|
|
32
|
+
empty: str = "(none)",
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Print ``rows`` as a table, or ``raw`` as JSON when ``--output json``."""
|
|
35
|
+
if state.output == "json":
|
|
36
|
+
emit_json(raw)
|
|
37
|
+
return
|
|
38
|
+
rows = list(rows)
|
|
39
|
+
if not rows:
|
|
40
|
+
console.print(f"[dim]{empty}[/dim]")
|
|
41
|
+
return
|
|
42
|
+
t = Table(title=title, show_lines=False, header_style="bold", box=None, pad_edge=False)
|
|
43
|
+
for c in columns:
|
|
44
|
+
t.add_column(c)
|
|
45
|
+
for r in rows:
|
|
46
|
+
t.add_row(*[_cell(v) for v in r])
|
|
47
|
+
console.print(t)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def record(state: State, raw: Any, pairs: Sequence[tuple[str, Any]]) -> None:
|
|
51
|
+
"""One object as ``key value`` lines, or JSON."""
|
|
52
|
+
if state.output == "json":
|
|
53
|
+
emit_json(raw)
|
|
54
|
+
return
|
|
55
|
+
width = max((len(k) for k, _ in pairs), default=0)
|
|
56
|
+
for k, v in pairs:
|
|
57
|
+
console.print(f"[bold]{k.ljust(width)}[/bold] {_cell(v)}", highlight=False, markup=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def say(state: State, message: str) -> None:
|
|
61
|
+
"""A one-line confirmation for humans; silent under ``--output json`` and ``--quiet``."""
|
|
62
|
+
if state.output != "json" and not state.quiet:
|
|
63
|
+
console.print(message, highlight=False)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def warn(message: str) -> None:
|
|
67
|
+
err.print(f"[yellow]warning:[/yellow] {message}", highlight=False)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def secret_once(
|
|
71
|
+
state: State, raw: Any, label: str, value: str, extra: Sequence[tuple[str, Any]] = ()
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Print a value that the server will never show again.
|
|
74
|
+
|
|
75
|
+
In JSON mode the whole response is printed (scripts need the field); in table mode
|
|
76
|
+
the value is printed on its own line, unmarked-up, so it can be copied, with a
|
|
77
|
+
warning on stderr so the warning is never captured into a file.
|
|
78
|
+
"""
|
|
79
|
+
if state.output == "json":
|
|
80
|
+
emit_json(raw)
|
|
81
|
+
else:
|
|
82
|
+
for k, v in extra:
|
|
83
|
+
console.print(f"[bold]{k}[/bold] {_cell(v)}", highlight=False)
|
|
84
|
+
console.print(f"[bold]{label}[/bold]")
|
|
85
|
+
sys.stdout.write(value + "\n")
|
|
86
|
+
sys.stdout.flush()
|
|
87
|
+
err.print(f"[yellow]This {label.lower()} is shown once and cannot be retrieved again.[/yellow]")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _cell(value: Any) -> str:
|
|
91
|
+
if value is None:
|
|
92
|
+
return "[dim]-[/dim]"
|
|
93
|
+
if isinstance(value, bool):
|
|
94
|
+
return "yes" if value else "no"
|
|
95
|
+
if isinstance(value, list | tuple):
|
|
96
|
+
return ", ".join(_cell(v) for v in value) if value else "[dim]-[/dim]"
|
|
97
|
+
if isinstance(value, dict):
|
|
98
|
+
return json.dumps(value, ensure_ascii=False)
|
|
99
|
+
return str(value)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def short(value: str | None, n: int = 8) -> str:
|
|
103
|
+
return (value or "")[:n]
|
coho_cli/_slugs.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Build slugs from names. The API checks them; this is the one place they are made.
|
|
2
|
+
|
|
3
|
+
A type slug is camelCase (``Blog post`` -> ``blogPost``) because it appears in code:
|
|
4
|
+
``coho entry create -t blogPost`` and ``of: ["teamMember"]``. A content slug is
|
|
5
|
+
kebab-case (``Hello, World!`` -> ``hello-world``) because it appears in URLs.
|
|
6
|
+
|
|
7
|
+
These rules mirror ``coho.model.Slugs`` on the server, which stays the authority: a
|
|
8
|
+
generated slug that the server would refuse is refused here first, with a message
|
|
9
|
+
that says what to pass instead.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import unicodedata
|
|
16
|
+
|
|
17
|
+
from ._state import Usage
|
|
18
|
+
|
|
19
|
+
_TYPE_SLUG = re.compile(r"^[a-z][a-zA-Z0-9]*$")
|
|
20
|
+
_CONTENT_SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _words(name: str) -> list[str]:
|
|
24
|
+
# Accents fold to their base letter (Über -> Uber). Anything else that is not
|
|
25
|
+
# ASCII letter or digit is a separator, so punctuation and spacing both break words.
|
|
26
|
+
folded = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii")
|
|
27
|
+
return re.findall(r"[A-Za-z0-9]+", folded)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def type_slug(name: str) -> str:
|
|
31
|
+
"""camelCase from a name: the first word lowercased, each later word capitalised."""
|
|
32
|
+
words = _words(name)
|
|
33
|
+
if not words:
|
|
34
|
+
raise Usage(
|
|
35
|
+
f"cannot build a type slug from {name!r}: it has no letters or digits. "
|
|
36
|
+
"Pass the slug explicitly."
|
|
37
|
+
)
|
|
38
|
+
head, *tail = words
|
|
39
|
+
slug = head.lower() + "".join(w[:1].upper() + w[1:].lower() for w in tail)
|
|
40
|
+
if not _TYPE_SLUG.match(slug):
|
|
41
|
+
raise Usage(
|
|
42
|
+
f"cannot build a type slug from {name!r}: it must start with a letter. "
|
|
43
|
+
"Pass the slug explicitly."
|
|
44
|
+
)
|
|
45
|
+
return slug
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def content_slug(name: str) -> str:
|
|
49
|
+
"""kebab-case from a name: every word lowercased and joined with hyphens."""
|
|
50
|
+
words = _words(name)
|
|
51
|
+
if not words:
|
|
52
|
+
raise Usage(
|
|
53
|
+
f"cannot build a content slug from {name!r}: it has no letters or digits. "
|
|
54
|
+
"Pass --slug explicitly."
|
|
55
|
+
)
|
|
56
|
+
slug = "-".join(w.lower() for w in words)
|
|
57
|
+
if not _CONTENT_SLUG.match(slug): # pragma: no cover - the rule holds by construction
|
|
58
|
+
raise Usage(f"cannot build a content slug from {name!r}. Pass --slug explicitly.")
|
|
59
|
+
return slug
|
coho_cli/_state.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""What every command needs: the config, the profile, a client, and the context.
|
|
2
|
+
|
|
3
|
+
Resolution order for the three context values (account, project, ref):
|
|
4
|
+
``--account/--project/--ref`` flags → ``COHO_ACCOUNT/COHO_PROJECT/COHO_REF`` →
|
|
5
|
+
the profile's saved context (``coho use``). A missing value is a usage error that
|
|
6
|
+
names the flag and the ``use`` command, not a stack trace.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from functools import cached_property
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from coho_management_sdk import Account, Coho, Config, Profile, Project, Ref
|
|
17
|
+
from coho_management_sdk.models import Membership
|
|
18
|
+
from coho_management_sdk.profiles import Context
|
|
19
|
+
|
|
20
|
+
EXIT_ERROR = 1
|
|
21
|
+
EXIT_USAGE = 2
|
|
22
|
+
EXIT_AUTH = 3
|
|
23
|
+
EXIT_CONFLICT = 4
|
|
24
|
+
EXIT_NOT_FOUND = 5
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Usage(Exception):
|
|
28
|
+
"""A usage error: printed as one line by ``main()``, exit code 2, no traceback."""
|
|
29
|
+
|
|
30
|
+
exit_code = EXIT_USAGE
|
|
31
|
+
|
|
32
|
+
def __init__(self, message: str) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.message = message
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class State:
|
|
39
|
+
profile_name: str | None = None
|
|
40
|
+
output: str = "table"
|
|
41
|
+
url: str | None = None
|
|
42
|
+
overrides: Context = field(default_factory=Context)
|
|
43
|
+
quiet: bool = False
|
|
44
|
+
yes: bool = False
|
|
45
|
+
# Set when the account came from the saved context rather than `--account`, so an
|
|
46
|
+
# ACCOUNT_NOT_FOUND can say the saved one may be gone: deleted, or a reset environment.
|
|
47
|
+
used_saved_account: bool = field(default=False, init=False)
|
|
48
|
+
# What to say after a 403, when the command knows better than the server's message
|
|
49
|
+
# what the person can do instead.
|
|
50
|
+
forbidden_hint: str | None = field(default=None, init=False)
|
|
51
|
+
|
|
52
|
+
@cached_property
|
|
53
|
+
def config(self) -> Config:
|
|
54
|
+
return Config.load()
|
|
55
|
+
|
|
56
|
+
@cached_property
|
|
57
|
+
def profile(self) -> Profile:
|
|
58
|
+
try:
|
|
59
|
+
return self.config.profile(self.profile_name)
|
|
60
|
+
except KeyError as exc:
|
|
61
|
+
name = exc.args[0]
|
|
62
|
+
raise Usage(
|
|
63
|
+
f"no profile named '{name}'. Create it: coho configure --profile {name} --url https://…"
|
|
64
|
+
) from exc
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def context(self) -> Context:
|
|
68
|
+
saved = self.profile.context.with_env()
|
|
69
|
+
return Context(
|
|
70
|
+
account=self.overrides.account or saved.account,
|
|
71
|
+
project=self.overrides.project or saved.project,
|
|
72
|
+
ref=self.overrides.ref or saved.ref,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@cached_property
|
|
76
|
+
def coho(self) -> Coho:
|
|
77
|
+
url = self.url or self.config.effective_url(self.profile)
|
|
78
|
+
if not url:
|
|
79
|
+
raise Usage(
|
|
80
|
+
f"profile '{self.profile.name}' has no url. Set one: "
|
|
81
|
+
f"coho configure --profile {self.profile.name} --url https://…"
|
|
82
|
+
)
|
|
83
|
+
return Coho(url, profile=self.profile)
|
|
84
|
+
|
|
85
|
+
# -- context resolution ----------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def account(self) -> Account:
|
|
88
|
+
# Named on the command line or in COHO_ACCOUNT: used as given, never second-guessed.
|
|
89
|
+
if self.overrides.account:
|
|
90
|
+
return self.coho.account(self.overrides.account)
|
|
91
|
+
name = self.context.account
|
|
92
|
+
# ⚠️ A saved account can outlive the account: deleted, or an environment reset. Checked
|
|
93
|
+
# against `/me` (one request, cached for the command) so a person with one account is
|
|
94
|
+
# not stuck on one they no longer have. Not with a project token: it has no `/me`.
|
|
95
|
+
if name and not self.coho.uses_project_token:
|
|
96
|
+
memberships = self.coho.accounts()
|
|
97
|
+
if not any(name in (m.account_id, m.account_name) for m in memberships):
|
|
98
|
+
return self._instead_of_gone(name, memberships)
|
|
99
|
+
if name:
|
|
100
|
+
self.used_saved_account = True
|
|
101
|
+
return self.coho.account(name)
|
|
102
|
+
# No account chosen. Someone in exactly one account has nothing to choose,
|
|
103
|
+
# so that one is used; with several, guessing would act on the wrong one.
|
|
104
|
+
memberships = self.coho.accounts()
|
|
105
|
+
if len(memberships) == 1:
|
|
106
|
+
return self.coho.account(memberships[0].account_id)
|
|
107
|
+
if not memberships:
|
|
108
|
+
raise Usage(
|
|
109
|
+
"you are not in any account yet. Create one with `coho signup`, "
|
|
110
|
+
"or ask an account admin to invite you."
|
|
111
|
+
)
|
|
112
|
+
names = ", ".join(sorted(m.account_name for m in memberships))
|
|
113
|
+
raise Usage(
|
|
114
|
+
f"you are in {len(memberships)} accounts ({names}). "
|
|
115
|
+
"Choose one with `coho use <account>` or pass --account."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _instead_of_gone(self, gone: str, memberships: list[Membership]) -> Account:
|
|
119
|
+
"""The saved account is not one of the caller's: their only one if they have one,
|
|
120
|
+
saved in its place, or a usage error that says what to choose."""
|
|
121
|
+
from ._output import warn
|
|
122
|
+
|
|
123
|
+
# Only ever the profile's: `--account` and COHO_ACCOUNT (its envvar) are used as given.
|
|
124
|
+
p = self.profile.name
|
|
125
|
+
if not memberships:
|
|
126
|
+
raise Usage(
|
|
127
|
+
"you are not in any account yet. Create one with `coho signup`, "
|
|
128
|
+
"or ask an account admin to invite you."
|
|
129
|
+
)
|
|
130
|
+
if len(memberships) > 1:
|
|
131
|
+
names = ", ".join(sorted(m.account_name for m in memberships))
|
|
132
|
+
raise Usage(
|
|
133
|
+
f"the account saved for profile '{p}' is not one of yours; it may have been "
|
|
134
|
+
f"deleted. You are in {len(memberships)} accounts ({names}): choose one with "
|
|
135
|
+
"`coho use <account>`."
|
|
136
|
+
)
|
|
137
|
+
only = memberships[0]
|
|
138
|
+
# As `coho use` does when the account changes: the project and ref were the gone account's.
|
|
139
|
+
self.profile.context.account = only.account_id
|
|
140
|
+
self.profile.context.project = None
|
|
141
|
+
self.profile.context.ref = None
|
|
142
|
+
self.save()
|
|
143
|
+
warn(
|
|
144
|
+
f"the account saved for profile '{p}' is not one of yours; it may have been "
|
|
145
|
+
f"deleted. Using {only.account_name}, your only account, from now on."
|
|
146
|
+
)
|
|
147
|
+
return self.coho.account(only.account_id)
|
|
148
|
+
|
|
149
|
+
def project(self) -> Project:
|
|
150
|
+
account = self.account()
|
|
151
|
+
raw = self.context.project
|
|
152
|
+
if not raw:
|
|
153
|
+
raise Usage(
|
|
154
|
+
"no project in context. Run `coho use <account> <project>` or pass --project."
|
|
155
|
+
)
|
|
156
|
+
return account.project(self.resolve_project_id(account, raw))
|
|
157
|
+
|
|
158
|
+
def resolve_project_id(self, account: Account, raw: str) -> str:
|
|
159
|
+
"""A project id from an id, a locally remembered name, or a name on the server.
|
|
160
|
+
|
|
161
|
+
The local registry answers first and without a request; an id passes
|
|
162
|
+
through; anything else is matched by name against the projects the server
|
|
163
|
+
lists for you. Two projects with the same name are refused rather than
|
|
164
|
+
guessed between.
|
|
165
|
+
"""
|
|
166
|
+
known = self.profile.resolve_project(account.id, raw)
|
|
167
|
+
if known != raw or _is_uuid(raw):
|
|
168
|
+
return known
|
|
169
|
+
matches = [p for p in account.projects.list() if p.name.casefold() == raw.casefold()]
|
|
170
|
+
if len(matches) == 1:
|
|
171
|
+
return matches[0].id
|
|
172
|
+
if not matches:
|
|
173
|
+
raise Usage(f"no project named '{raw}' that you can open. See `coho project list`.")
|
|
174
|
+
ids = ", ".join(p.id for p in matches)
|
|
175
|
+
raise Usage(f"{len(matches)} projects are named '{raw}' ({ids}). Use the id.")
|
|
176
|
+
|
|
177
|
+
def ref(self) -> Ref:
|
|
178
|
+
project = self.project()
|
|
179
|
+
name = self.context.ref
|
|
180
|
+
if not name:
|
|
181
|
+
raise Usage("no ref in context. Run `coho ref switch <ref>` or pass --ref.")
|
|
182
|
+
return project.ref(name)
|
|
183
|
+
|
|
184
|
+
def save(self) -> None:
|
|
185
|
+
self.config.save()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# The running command's state, for `main` to read once an error has escaped the command.
|
|
189
|
+
current: State | None = None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def get_state(ctx: typer.Context) -> State:
|
|
193
|
+
state = ctx.find_root().obj
|
|
194
|
+
if not isinstance(state, State): # pragma: no cover - typer wiring
|
|
195
|
+
state = State()
|
|
196
|
+
ctx.find_root().obj = state
|
|
197
|
+
return state
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _is_uuid(value: str) -> bool:
|
|
201
|
+
try:
|
|
202
|
+
UUID(value)
|
|
203
|
+
except ValueError:
|
|
204
|
+
return False
|
|
205
|
+
return True
|