datavo-cli 0.22.0__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.
- datavo_cli/__init__.py +3 -0
- datavo_cli/client_factory.py +172 -0
- datavo_cli/commands/__init__.py +36 -0
- datavo_cli/commands/auth.py +94 -0
- datavo_cli/commands/config.py +93 -0
- datavo_cli/commands/dataset.py +198 -0
- datavo_cli/commands/events.py +48 -0
- datavo_cli/commands/jobpool.py +50 -0
- datavo_cli/commands/sample.py +29 -0
- datavo_cli/commands/sample_store.py +97 -0
- datavo_cli/commands/source_import.py +38 -0
- datavo_cli/commands/split_set.py +79 -0
- datavo_cli/commands/stats.py +22 -0
- datavo_cli/commands/team.py +191 -0
- datavo_cli/commands/user.py +67 -0
- datavo_cli/config_store.py +200 -0
- datavo_cli/diagnostics.py +77 -0
- datavo_cli/errors.py +49 -0
- datavo_cli/main.py +90 -0
- datavo_cli/parsers.py +411 -0
- datavo_cli/render.py +390 -0
- datavo_cli/utils.py +55 -0
- datavo_cli-0.22.0.dist-info/METADATA +51 -0
- datavo_cli-0.22.0.dist-info/RECORD +28 -0
- datavo_cli-0.22.0.dist-info/WHEEL +5 -0
- datavo_cli-0.22.0.dist-info/entry_points.txt +2 -0
- datavo_cli-0.22.0.dist-info/licenses/LICENSE +201 -0
- datavo_cli-0.22.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from datavo_sdk import SampleStream, get_default_shard_cache
|
|
8
|
+
from datavo_sdk.jobpool import render_dataset_cache
|
|
9
|
+
|
|
10
|
+
from ..client_factory import build_client
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_tier(spec: str) -> dict[str, object]:
|
|
14
|
+
"""Parse a ``--tier TYPE[,max_bytes][,name]`` value into a tier mapping."""
|
|
15
|
+
parts = [p.strip() for p in spec.split(",") if p.strip()]
|
|
16
|
+
if not parts:
|
|
17
|
+
raise ValueError("empty --tier value")
|
|
18
|
+
tier: dict[str, object] = {"type": parts[0]}
|
|
19
|
+
if len(parts) > 1:
|
|
20
|
+
tier["max_bytes"] = parts[1]
|
|
21
|
+
if len(parts) > 2:
|
|
22
|
+
tier["name"] = parts[2]
|
|
23
|
+
return tier
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def handle_render_dataset_cache_command(args) -> int:
|
|
27
|
+
tiers = [_parse_tier(spec) for spec in args.tiers]
|
|
28
|
+
rendered = render_dataset_cache(
|
|
29
|
+
disk=args.disk,
|
|
30
|
+
host_path=args.host_path,
|
|
31
|
+
mount=args.mount,
|
|
32
|
+
tiers=tiers,
|
|
33
|
+
token_alias=args.token_alias,
|
|
34
|
+
)
|
|
35
|
+
print(yaml.safe_dump(rendered, sort_keys=False).rstrip())
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def handle_warm_command(args) -> int:
|
|
40
|
+
cache = get_default_shard_cache(args.cache_config)
|
|
41
|
+
with build_client() as client:
|
|
42
|
+
dataset = SampleStream.from_dataset(
|
|
43
|
+
args.dataset,
|
|
44
|
+
keys=list(args.keys) if args.keys else None,
|
|
45
|
+
client=client,
|
|
46
|
+
cache=cache,
|
|
47
|
+
)
|
|
48
|
+
count = sum(1 for _ in dataset)
|
|
49
|
+
print(f"warmed {count} samples into the cache for {args.dataset!r}", file=sys.stderr)
|
|
50
|
+
return 0
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from ..client_factory import build_client
|
|
6
|
+
from ..render import render_output
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _parse_datetime(value: str | None) -> datetime | None:
|
|
10
|
+
return datetime.fromisoformat(value) if value else None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def handle_sample_command(args) -> int:
|
|
14
|
+
output_format = getattr(args, "format", "plain")
|
|
15
|
+
with build_client() as client:
|
|
16
|
+
if args.sample_command == "get":
|
|
17
|
+
response = client.get_sample(args.sample_id)
|
|
18
|
+
render_output(response, output_format=output_format)
|
|
19
|
+
return 0
|
|
20
|
+
if args.sample_command == "history":
|
|
21
|
+
response = client.get_sample_history(
|
|
22
|
+
args.sample_id,
|
|
23
|
+
key=args.key,
|
|
24
|
+
as_of=_parse_datetime(args.as_of),
|
|
25
|
+
producer_id=args.producer_id,
|
|
26
|
+
)
|
|
27
|
+
render_output(response, output_format=output_format)
|
|
28
|
+
return 0
|
|
29
|
+
raise RuntimeError("Missing sample subcommand.")
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datavo_sdk import (
|
|
4
|
+
CopySamplesToNewStoreRequest,
|
|
5
|
+
HasKeys,
|
|
6
|
+
SampleStoreCreateRequest,
|
|
7
|
+
SampleStoreTransferRequest,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from ..client_factory import build_client
|
|
11
|
+
from ..render import render_output
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def handle_sample_store_command(args) -> int:
|
|
15
|
+
output_format = getattr(args, "format", "plain")
|
|
16
|
+
with build_client() as client:
|
|
17
|
+
if args.sample_store_command == "create":
|
|
18
|
+
response = client.create_store(
|
|
19
|
+
SampleStoreCreateRequest(
|
|
20
|
+
name=args.name,
|
|
21
|
+
catalog_visibility="hidden" if args.hidden else "visible",
|
|
22
|
+
team=getattr(args, "team", None),
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
render_output(response, output_format=output_format)
|
|
26
|
+
return 0
|
|
27
|
+
if args.sample_store_command == "list":
|
|
28
|
+
response = client.list_stores(
|
|
29
|
+
query=args.query,
|
|
30
|
+
include_archived=args.include_archived,
|
|
31
|
+
include_hidden=args.include_hidden,
|
|
32
|
+
)
|
|
33
|
+
render_output(response, output_format=output_format)
|
|
34
|
+
return 0
|
|
35
|
+
if args.sample_store_command == "get":
|
|
36
|
+
response = client.get_store(args.name)
|
|
37
|
+
render_output(response, output_format=output_format)
|
|
38
|
+
return 0
|
|
39
|
+
if args.sample_store_command == "delete":
|
|
40
|
+
response = client.delete_store(args.name)
|
|
41
|
+
render_output(response, output_format=output_format)
|
|
42
|
+
return 0
|
|
43
|
+
if args.sample_store_command == "copy":
|
|
44
|
+
source_filter = None
|
|
45
|
+
if args.source_store and args.has_keys:
|
|
46
|
+
source_filter = [HasKeys(args.has_keys)]
|
|
47
|
+
response = client.copy_samples_to_new_store(
|
|
48
|
+
CopySamplesToNewStoreRequest(
|
|
49
|
+
target_sample_store=args.target,
|
|
50
|
+
catalog_visibility="hidden" if args.hidden else "visible",
|
|
51
|
+
source_sample_store=args.source_store,
|
|
52
|
+
filter=source_filter,
|
|
53
|
+
source_dataset=args.source_dataset,
|
|
54
|
+
keys=args.keys or None,
|
|
55
|
+
limit=args.limit,
|
|
56
|
+
randomize=args.randomize,
|
|
57
|
+
seed=args.seed,
|
|
58
|
+
reason=args.reason,
|
|
59
|
+
idempotency_key=args.idempotency_key,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
render_output(response, output_format=output_format)
|
|
63
|
+
return 0
|
|
64
|
+
if args.sample_store_command == "transfer":
|
|
65
|
+
response = client.transfer_store(
|
|
66
|
+
args.source,
|
|
67
|
+
SampleStoreTransferRequest(
|
|
68
|
+
target_sample_store=args.target,
|
|
69
|
+
delete_source_store=not args.keep_source,
|
|
70
|
+
reason=args.reason,
|
|
71
|
+
created_event_id=args.created_event_id,
|
|
72
|
+
idempotency_key=args.idempotency_key,
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
render_output(response, output_format=output_format)
|
|
76
|
+
return 0
|
|
77
|
+
if args.sample_store_command == "archive":
|
|
78
|
+
response = client.archive_store(args.name)
|
|
79
|
+
render_output(response, output_format=output_format)
|
|
80
|
+
return 0
|
|
81
|
+
if args.sample_store_command == "restore":
|
|
82
|
+
response = client.restore_store(args.name)
|
|
83
|
+
render_output(response, output_format=output_format)
|
|
84
|
+
return 0
|
|
85
|
+
if args.sample_store_command == "operations":
|
|
86
|
+
if args.operations_command == "list":
|
|
87
|
+
response = client.list_store_operations(
|
|
88
|
+
args.name, offset=args.offset, limit=args.limit, order=args.order
|
|
89
|
+
)
|
|
90
|
+
render_output(response, output_format=output_format)
|
|
91
|
+
return 0
|
|
92
|
+
if args.operations_command == "remove":
|
|
93
|
+
response = client.remove_store_operation(args.name, args.op_id)
|
|
94
|
+
render_output(response, output_format=output_format)
|
|
95
|
+
return 0
|
|
96
|
+
raise RuntimeError("Missing sample-store operations subcommand.")
|
|
97
|
+
raise RuntimeError("Missing sample-store subcommand.")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from ..client_factory import build_client
|
|
6
|
+
from ..render import render_output
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _resolve_source_import_id(client, args) -> str:
|
|
10
|
+
if args.source_import_id:
|
|
11
|
+
return args.source_import_id
|
|
12
|
+
operation_id = getattr(args, "operation_id", None)
|
|
13
|
+
source_ref = getattr(args, "source_ref", None)
|
|
14
|
+
if args.sample_store and operation_id:
|
|
15
|
+
return client.get_source_import_by_operation_id(args.sample_store, operation_id).source_import_id
|
|
16
|
+
if args.sample_store and source_ref:
|
|
17
|
+
# Deprecated spelling: reads both columns, so an import written before the
|
|
18
|
+
# cutover still resolves by the value its caller sent. The notice goes to stderr —
|
|
19
|
+
# stdout carries the command's output, which callers parse as JSON.
|
|
20
|
+
print("--source-ref is deprecated; use --operation-id.", file=sys.stderr)
|
|
21
|
+
return client.get_source_import_by_source_ref(args.sample_store, source_ref).source_import_id
|
|
22
|
+
raise RuntimeError("Provide --id, or both --sample-store and --operation-id, to identify the source import.")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def handle_source_import_command(args) -> int:
|
|
26
|
+
output_format = getattr(args, "format", "plain")
|
|
27
|
+
with build_client() as client:
|
|
28
|
+
if args.source_import_command == "delete":
|
|
29
|
+
source_import_id = _resolve_source_import_id(client, args)
|
|
30
|
+
# Default to a dry run unless --yes is given, so an accidental
|
|
31
|
+
# invocation reports the blast radius instead of deleting.
|
|
32
|
+
dry_run = args.dry_run or not args.yes
|
|
33
|
+
response = client.delete_source_import(source_import_id, dry_run=dry_run)
|
|
34
|
+
render_output(response, output_format=output_format)
|
|
35
|
+
if response.dry_run and not args.dry_run:
|
|
36
|
+
print("\nDry run only — re-run with --yes to actually delete.")
|
|
37
|
+
return 0
|
|
38
|
+
raise RuntimeError("Missing source-import subcommand.")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datavo_sdk import (
|
|
4
|
+
DatasetSplitDraftRequest,
|
|
5
|
+
DatasetSplitSetCreateRequest,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
from ..client_factory import build_client
|
|
9
|
+
from ..render import render_output
|
|
10
|
+
from ..utils import load_model
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def handle_split_set_command(args) -> int:
|
|
14
|
+
output_format = getattr(args, "format", "plain")
|
|
15
|
+
with build_client() as client:
|
|
16
|
+
if args.split_set_command == "preview":
|
|
17
|
+
response = client.preview_dataset_split_set(
|
|
18
|
+
args.dataset, load_model(args.spec, DatasetSplitDraftRequest)
|
|
19
|
+
)
|
|
20
|
+
render_output(response, output_format=output_format)
|
|
21
|
+
return 0
|
|
22
|
+
if args.split_set_command == "create":
|
|
23
|
+
response = client.create_dataset_split_set(
|
|
24
|
+
args.dataset, load_model(args.spec, DatasetSplitSetCreateRequest)
|
|
25
|
+
)
|
|
26
|
+
render_output(response, output_format=output_format)
|
|
27
|
+
return 0
|
|
28
|
+
if args.split_set_command == "get":
|
|
29
|
+
response = client.get_dataset_split_set(args.dataset, args.split_set)
|
|
30
|
+
render_output(response, output_format=output_format)
|
|
31
|
+
return 0
|
|
32
|
+
if args.split_set_command == "archive":
|
|
33
|
+
response = client.archive_dataset_split_set(args.dataset, args.split_set)
|
|
34
|
+
render_output(response, output_format=output_format)
|
|
35
|
+
return 0
|
|
36
|
+
if args.split_set_command == "restore":
|
|
37
|
+
response = client.restore_dataset_split_set(args.dataset, args.split_set)
|
|
38
|
+
render_output(response, output_format=output_format)
|
|
39
|
+
return 0
|
|
40
|
+
if args.split_set_command == "delete":
|
|
41
|
+
response = client.delete_dataset_split_set(args.dataset, args.split_set)
|
|
42
|
+
render_output(response, output_format=output_format)
|
|
43
|
+
return 0
|
|
44
|
+
if args.split_set_command == "list":
|
|
45
|
+
response = client.list_dataset_split_sets(
|
|
46
|
+
args.dataset, include_archived=args.include_archived
|
|
47
|
+
)
|
|
48
|
+
render_output(response, output_format=output_format)
|
|
49
|
+
return 0
|
|
50
|
+
if args.split_set_command == "splits":
|
|
51
|
+
response = client.list_dataset_split_sets(
|
|
52
|
+
args.dataset, include_archived=args.include_archived
|
|
53
|
+
)
|
|
54
|
+
if args.split_set is not None and args.split_set not in {item.name for item in response.items}:
|
|
55
|
+
raise RuntimeError(f"dataset {args.dataset} has no split set {args.split_set}")
|
|
56
|
+
split_datasets = []
|
|
57
|
+
for item in response.items:
|
|
58
|
+
if args.split_set is not None and item.name != args.split_set:
|
|
59
|
+
continue
|
|
60
|
+
for dataset in item.datasets:
|
|
61
|
+
split_datasets.append(
|
|
62
|
+
{
|
|
63
|
+
"split_set": item.name,
|
|
64
|
+
"split_name": dataset.split_name,
|
|
65
|
+
"dataset_name": dataset.dataset_name,
|
|
66
|
+
"sample_count": dataset.sample_count,
|
|
67
|
+
"shard_count": dataset.shard_count,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
render_output(
|
|
71
|
+
{
|
|
72
|
+
"parent_dataset": response.dataset_name,
|
|
73
|
+
"split_set": args.split_set,
|
|
74
|
+
"split_datasets": split_datasets,
|
|
75
|
+
},
|
|
76
|
+
output_format=output_format,
|
|
77
|
+
)
|
|
78
|
+
return 0
|
|
79
|
+
raise RuntimeError("Missing split-set subcommand.")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datavo_sdk import StatsRebuildRequest
|
|
4
|
+
|
|
5
|
+
from ..client_factory import build_client
|
|
6
|
+
from ..render import render_output
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def handle_stats_command(args) -> int:
|
|
10
|
+
output_format = getattr(args, "format", "plain")
|
|
11
|
+
with build_client() as client:
|
|
12
|
+
if args.stats_command == "rebuild":
|
|
13
|
+
response = client.rebuild_stats(
|
|
14
|
+
StatsRebuildRequest(
|
|
15
|
+
sample_store=args.sample_store,
|
|
16
|
+
dataset=args.dataset,
|
|
17
|
+
all=bool(args.all),
|
|
18
|
+
)
|
|
19
|
+
)
|
|
20
|
+
render_output(response, output_format=output_format)
|
|
21
|
+
return 0
|
|
22
|
+
raise RuntimeError("Missing stats subcommand.")
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""``datavo teams``, ``datavo share`` / ``unshare``, and ``datavo transfer``.
|
|
2
|
+
|
|
3
|
+
Three things here are deliberate rather than incidental:
|
|
4
|
+
|
|
5
|
+
**Transfer refuses locally.** ``datavo transfer`` will not send the request without an
|
|
6
|
+
acknowledgement — it prints the cascade set first, then requires either
|
|
7
|
+
``--yes-i-understand-this-is-irreversible`` or a typed confirmation of the resource name.
|
|
8
|
+
Letting the server 400 would technically work, but the point of the acknowledgement is
|
|
9
|
+
that the person typing the command has seen what it moves; a round trip that fails is not
|
|
10
|
+
the same as a prompt that informs.
|
|
11
|
+
|
|
12
|
+
**Capability check before send.** The team endpoints do not exist on an older server, so
|
|
13
|
+
without a check the command dies on a bare 404 from a path that was never there. Reading
|
|
14
|
+
``/config`` first turns version skew into a sentence.
|
|
15
|
+
|
|
16
|
+
**Sharing is spelled share/unshare, ownership is spelled transfer.** They are different
|
|
17
|
+
verbs on purpose: one is reversible, the other is not.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from datavo_sdk import TeamCreateRequest, TeamUpdateRequest
|
|
23
|
+
|
|
24
|
+
from ..client_factory import build_client
|
|
25
|
+
from ..render import render_output
|
|
26
|
+
|
|
27
|
+
_CAPABILITY = "teams-and-sharing"
|
|
28
|
+
|
|
29
|
+
# `datavo share store NAME`, `... dataset NAME`, `... split-set NAME --dataset PARENT`.
|
|
30
|
+
_RESOURCE_KINDS = {
|
|
31
|
+
"store": "sample_store",
|
|
32
|
+
"dataset": "dataset",
|
|
33
|
+
"split-set": "split_set",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _require_teams_capability(client) -> None:
|
|
38
|
+
"""Refuse up front when the server predates this capability.
|
|
39
|
+
|
|
40
|
+
A missing route answers 404, which reads as "no such store" rather than "this server
|
|
41
|
+
is older than your CLI" — so the check happens here, once, before any command sends.
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
config = client.get_public_config()
|
|
45
|
+
except Exception:
|
|
46
|
+
# If /config itself is unreachable the command's own error is the better message.
|
|
47
|
+
return
|
|
48
|
+
capabilities = list(getattr(config, "capabilities", None) or [])
|
|
49
|
+
if _CAPABILITY in capabilities:
|
|
50
|
+
return
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
f"this server does not support teams and sharing (missing the {_CAPABILITY!r} "
|
|
53
|
+
f"capability; server version {getattr(config, 'release_version', 'unknown')}). "
|
|
54
|
+
"Upgrade the server, or use a CLI matching it."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resource_target(args) -> tuple[str, str, str | None]:
|
|
59
|
+
"""``(resource_kind, name, parent)`` from the shared positional shape."""
|
|
60
|
+
kind = _RESOURCE_KINDS.get(args.resource)
|
|
61
|
+
if kind is None:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
f"unknown resource {args.resource!r}; expected one of {', '.join(_RESOURCE_KINDS)}"
|
|
64
|
+
)
|
|
65
|
+
parent = getattr(args, "dataset", None)
|
|
66
|
+
if kind == "split_set" and not parent:
|
|
67
|
+
raise RuntimeError("a split set is addressed under its parent dataset: pass --dataset")
|
|
68
|
+
return kind, args.name, parent
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def handle_teams_command(args) -> int:
|
|
72
|
+
output_format = getattr(args, "format", "plain")
|
|
73
|
+
with build_client() as client:
|
|
74
|
+
_require_teams_capability(client)
|
|
75
|
+
command = args.teams_command
|
|
76
|
+
if command == "list":
|
|
77
|
+
render_output(
|
|
78
|
+
client.list_teams(include_archived=args.include_archived),
|
|
79
|
+
output_format=output_format,
|
|
80
|
+
)
|
|
81
|
+
return 0
|
|
82
|
+
if command == "show":
|
|
83
|
+
render_output(client.get_team(args.team), output_format=output_format)
|
|
84
|
+
return 0
|
|
85
|
+
if command == "create":
|
|
86
|
+
request = TeamCreateRequest(id=args.team, display_name=args.display_name)
|
|
87
|
+
render_output(client.create_team(request), output_format=output_format)
|
|
88
|
+
return 0
|
|
89
|
+
if command == "rename":
|
|
90
|
+
request = TeamUpdateRequest(display_name=args.display_name)
|
|
91
|
+
render_output(client.rename_team(args.team, request), output_format=output_format)
|
|
92
|
+
return 0
|
|
93
|
+
if command == "archive":
|
|
94
|
+
render_output(client.archive_team(args.team), output_format=output_format)
|
|
95
|
+
return 0
|
|
96
|
+
if command == "members":
|
|
97
|
+
if args.members_command == "add":
|
|
98
|
+
render_output(
|
|
99
|
+
client.add_team_member(args.team, args.user),
|
|
100
|
+
output_format=output_format,
|
|
101
|
+
)
|
|
102
|
+
return 0
|
|
103
|
+
if args.members_command == "remove":
|
|
104
|
+
render_output(
|
|
105
|
+
client.remove_team_member(args.team, args.user),
|
|
106
|
+
output_format=output_format,
|
|
107
|
+
)
|
|
108
|
+
return 0
|
|
109
|
+
raise RuntimeError(f"unknown members subcommand: {args.members_command}")
|
|
110
|
+
raise RuntimeError(f"unknown teams subcommand: {command}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def handle_share_command(args) -> int:
|
|
114
|
+
output_format = getattr(args, "format", "plain")
|
|
115
|
+
with build_client() as client:
|
|
116
|
+
_require_teams_capability(client)
|
|
117
|
+
kind, name, parent = _resource_target(args)
|
|
118
|
+
if getattr(args, "list", False):
|
|
119
|
+
render_output(
|
|
120
|
+
client.list_resource_grants(kind, name, parent=parent),
|
|
121
|
+
output_format=output_format,
|
|
122
|
+
)
|
|
123
|
+
return 0
|
|
124
|
+
response = client.grant_resource_access(
|
|
125
|
+
kind, name, team=args.to, level=args.level, parent=parent
|
|
126
|
+
)
|
|
127
|
+
render_output(response, output_format=output_format)
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def handle_unshare_command(args) -> int:
|
|
132
|
+
output_format = getattr(args, "format", "plain")
|
|
133
|
+
with build_client() as client:
|
|
134
|
+
_require_teams_capability(client)
|
|
135
|
+
kind, name, parent = _resource_target(args)
|
|
136
|
+
response = client.revoke_resource_access(kind, name, team=args.to, parent=parent)
|
|
137
|
+
render_output(response, output_format=output_format)
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def handle_transfer_command(args) -> int:
|
|
142
|
+
"""Hand a resource to another team, after showing what moves and confirming."""
|
|
143
|
+
output_format = getattr(args, "format", "plain")
|
|
144
|
+
with build_client() as client:
|
|
145
|
+
_require_teams_capability(client)
|
|
146
|
+
kind, name, parent = _resource_target(args)
|
|
147
|
+
|
|
148
|
+
preview = client.preview_resource_ownership_transfer(kind, name, parent=parent)
|
|
149
|
+
_print_transfer_preview(preview, destination=args.to)
|
|
150
|
+
if not _transfer_acknowledged(args, name=name):
|
|
151
|
+
# Refused locally rather than sent and 400'd: the acknowledgement exists so the
|
|
152
|
+
# person running this has seen the cascade, and a failed round trip does not
|
|
153
|
+
# achieve that.
|
|
154
|
+
print(
|
|
155
|
+
"Refusing to transfer without acknowledgement. Re-run with "
|
|
156
|
+
"--yes-i-understand-this-is-irreversible, or confirm the resource name "
|
|
157
|
+
"when prompted."
|
|
158
|
+
)
|
|
159
|
+
return 1
|
|
160
|
+
|
|
161
|
+
response = client.transfer_resource_ownership(
|
|
162
|
+
kind, name, team=args.to, acknowledge_irreversible=True, parent=parent
|
|
163
|
+
)
|
|
164
|
+
render_output(response, output_format=output_format)
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _print_transfer_preview(preview, *, destination: str) -> None:
|
|
169
|
+
moving = list(getattr(preview, "would_transfer", None) or [])
|
|
170
|
+
current = getattr(preview, "owner_team_id", None)
|
|
171
|
+
print(f"Transfer to team: {destination}")
|
|
172
|
+
print(f"Current owning team: {current or '(none)'}")
|
|
173
|
+
print(f"Resources that will move ({len(moving)}):")
|
|
174
|
+
for entry in moving:
|
|
175
|
+
label = entry.name or entry.resource_id
|
|
176
|
+
print(f" - {entry.resource_kind} {label}")
|
|
177
|
+
print(
|
|
178
|
+
"\nThis is irreversible. The current owning team keeps NOTHING — no ownership and "
|
|
179
|
+
"no grant. Only the destination team can grant access back."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _transfer_acknowledged(args, *, name: str) -> bool:
|
|
184
|
+
if getattr(args, "yes_i_understand_this_is_irreversible", False):
|
|
185
|
+
return True
|
|
186
|
+
try:
|
|
187
|
+
typed = input(f"Type the resource name ({name}) to confirm: ").strip()
|
|
188
|
+
except (EOFError, KeyboardInterrupt):
|
|
189
|
+
print()
|
|
190
|
+
return False
|
|
191
|
+
return typed == name
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""``datavo users`` — the admin surface over who exists and the role each holds.
|
|
2
|
+
|
|
3
|
+
Two things mirror ``datavo teams``:
|
|
4
|
+
|
|
5
|
+
**Capability check before send.** The user routes do not exist on an older server, so
|
|
6
|
+
without a check the command dies on a bare 404 from a path that was never there. Reading
|
|
7
|
+
``/config`` first turns version skew into a sentence.
|
|
8
|
+
|
|
9
|
+
**A role is an override.** ``set-role`` replaces the Entra baseline everywhere, not only
|
|
10
|
+
here; ``clear-role`` returns the user to their directory role. The server refuses an
|
|
11
|
+
admin's attempt to remove their own admin (self-demotion) with a 409 — surfaced as the
|
|
12
|
+
request error rather than second-guessed locally, because unlike a transfer a role change
|
|
13
|
+
is reversible.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from ..client_factory import build_client
|
|
19
|
+
from ..render import render_output
|
|
20
|
+
|
|
21
|
+
_CAPABILITY = "user-management"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _require_users_capability(client) -> None:
|
|
25
|
+
"""Refuse up front when the server predates this capability.
|
|
26
|
+
|
|
27
|
+
A missing route answers 404, which reads as "no such user" rather than "this server
|
|
28
|
+
is older than your CLI" — so the check happens here, once, before any command sends.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
config = client.get_public_config()
|
|
32
|
+
except Exception:
|
|
33
|
+
# If /config itself is unreachable the command's own error is the better message.
|
|
34
|
+
return
|
|
35
|
+
capabilities = list(getattr(config, "capabilities", None) or [])
|
|
36
|
+
if _CAPABILITY in capabilities:
|
|
37
|
+
return
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
f"this server does not support user management (missing the {_CAPABILITY!r} "
|
|
40
|
+
f"capability; server version {getattr(config, 'release_version', 'unknown')}). "
|
|
41
|
+
"Upgrade the server, or use a CLI matching it."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def handle_users_command(args) -> int:
|
|
46
|
+
output_format = getattr(args, "format", "plain")
|
|
47
|
+
with build_client() as client:
|
|
48
|
+
_require_users_capability(client)
|
|
49
|
+
command = args.users_command
|
|
50
|
+
if command == "list":
|
|
51
|
+
render_output(
|
|
52
|
+
client.list_users(query=args.query, offset=args.offset, limit=args.limit),
|
|
53
|
+
output_format=output_format,
|
|
54
|
+
)
|
|
55
|
+
return 0
|
|
56
|
+
if command == "show":
|
|
57
|
+
render_output(client.get_user(args.user), output_format=output_format)
|
|
58
|
+
return 0
|
|
59
|
+
if command == "set-role":
|
|
60
|
+
render_output(
|
|
61
|
+
client.set_user_role(args.user, args.role), output_format=output_format
|
|
62
|
+
)
|
|
63
|
+
return 0
|
|
64
|
+
if command == "clear-role":
|
|
65
|
+
render_output(client.clear_user_role(args.user), output_format=output_format)
|
|
66
|
+
return 0
|
|
67
|
+
raise RuntimeError(f"unknown users subcommand: {command}")
|