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 ADDED
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.22.0"
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import os
6
+ import time
7
+ from dataclasses import dataclass, replace
8
+
9
+ from datavo_sdk import (
10
+ DatavoClient,
11
+ EntraConfig,
12
+ EntraTokenProvider,
13
+ PublicServerConfig,
14
+ default_msal_cache_path,
15
+ get_server_config,
16
+ )
17
+
18
+ from .config_store import DatavoConfig, DatavoProfile, apply_env_overrides, load_config, resolve_profile, update_profile
19
+
20
+
21
+ ENTRA_AUTH_PROVIDERS = {"entra", "azure", "azuread", "aad", "easyauth"}
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class RuntimeContext:
26
+ profile_name: str
27
+ profile: DatavoProfile
28
+ server_config: PublicServerConfig | None
29
+ auth_provider: str
30
+
31
+
32
+ def _jwt_expiry(token_text: str) -> int | None:
33
+ parts = token_text.strip().split(".")
34
+ if len(parts) < 2:
35
+ return None
36
+ payload = parts[1]
37
+ payload += "=" * (-len(payload) % 4)
38
+ try:
39
+ decoded = base64.urlsafe_b64decode(payload.encode("utf-8"))
40
+ data = json.loads(decoded.decode("utf-8"))
41
+ except Exception:
42
+ return None
43
+ exp = data.get("exp") if isinstance(data, dict) else None
44
+ return int(exp) if isinstance(exp, (int, float)) else None
45
+
46
+
47
+ def _token_needs_refresh(profile: DatavoProfile) -> bool:
48
+ token = profile.auth_token
49
+ if not isinstance(token, str) or not token.strip():
50
+ return True
51
+ expiry = _jwt_expiry(token)
52
+ if expiry is None:
53
+ return False
54
+ return int(time.time()) >= expiry
55
+
56
+
57
+ def try_silent_refresh(profile_name: str, profile: DatavoProfile) -> DatavoProfile | None:
58
+ if not profile.msal_client_id or not profile.msal_tenant_id or not profile.msal_scopes:
59
+ return None
60
+ if not default_msal_cache_path().exists():
61
+ return None
62
+ try:
63
+ provider = EntraTokenProvider(
64
+ EntraConfig(
65
+ tenant_id=profile.msal_tenant_id,
66
+ client_id=profile.msal_client_id,
67
+ scopes=list(profile.msal_scopes),
68
+ )
69
+ )
70
+ token = provider.acquire_token_silent()
71
+ if not token:
72
+ return None
73
+ update_profile(profile_name, auth_token=token)
74
+ return replace(profile, auth_token=token)
75
+ except Exception:
76
+ return None
77
+
78
+
79
+ def resolve_runtime_context(
80
+ *,
81
+ profile_name: str | None = None,
82
+ config: DatavoConfig | None = None,
83
+ require_url: bool = True,
84
+ ) -> RuntimeContext:
85
+ config = config or load_config()
86
+ selected_name, selected_profile = resolve_profile(config, profile_name=profile_name)
87
+ effective_profile = apply_env_overrides(selected_profile)
88
+ if require_url and not effective_profile.api_base_url:
89
+ raise RuntimeError("This command requires an API URL. Run `datavo config set <profile> --url <url>`.")
90
+ server_config = None
91
+ auth_provider = (os.environ.get("DATAVO_AUTH_PROVIDER") or "").strip().lower()
92
+ if effective_profile.api_base_url:
93
+ try:
94
+ server_config = get_server_config(effective_profile.api_base_url)
95
+ except Exception:
96
+ server_config = None
97
+ if not auth_provider and server_config is not None:
98
+ auth_provider = (server_config.auth_provider or "").strip().lower()
99
+ if not auth_provider:
100
+ auth_provider = "easyauth"
101
+ if (
102
+ not os.environ.get("DATAVO_AUTH_TOKEN")
103
+ and _token_needs_refresh(effective_profile)
104
+ and selected_name in config.profiles
105
+ ):
106
+ refreshed = try_silent_refresh(selected_name, effective_profile)
107
+ if refreshed is not None:
108
+ effective_profile = refreshed
109
+ return RuntimeContext(
110
+ profile_name=selected_name,
111
+ profile=effective_profile,
112
+ server_config=server_config,
113
+ auth_provider=auth_provider,
114
+ )
115
+
116
+
117
+ def build_client(*, profile_name: str | None = None, config: DatavoConfig | None = None) -> DatavoClient:
118
+ runtime = resolve_runtime_context(profile_name=profile_name, config=config, require_url=True)
119
+ return DatavoClient(
120
+ server_url=runtime.profile.api_base_url or "",
121
+ auth_token=runtime.profile.auth_token,
122
+ auto_login=False,
123
+ )
124
+
125
+
126
+ def derive_scopes(
127
+ *,
128
+ requested_scopes: list[str] | None,
129
+ server_config: PublicServerConfig | None,
130
+ ) -> list[str]:
131
+ if requested_scopes:
132
+ return [scope.strip() for scope in requested_scopes if scope and scope.strip()]
133
+ env_scopes = (os.environ.get("DATAVO_ENTRA_SCOPES") or "").strip()
134
+ if env_scopes:
135
+ return [scope.strip() for scope in env_scopes.split(",") if scope.strip()]
136
+ audience = (os.environ.get("DATAVO_ENTRA_AUDIENCE") or "").strip()
137
+ if not audience and server_config is not None:
138
+ audience = (server_config.entra_audience or "").strip()
139
+ if not audience:
140
+ raise RuntimeError("No Entra audience available. Provide --scope or configure entra_audience on the server.")
141
+ if audience.startswith("api://"):
142
+ return [f"{audience}/access_as_user"]
143
+ return [f"api://{audience}/access_as_user"]
144
+
145
+
146
+ def derive_msal_profile(
147
+ *,
148
+ base_profile: DatavoProfile,
149
+ server_config: PublicServerConfig | None,
150
+ tenant_id: str | None,
151
+ client_id: str | None,
152
+ scopes: list[str] | None,
153
+ auth_token: str,
154
+ ) -> DatavoProfile:
155
+ resolved_tenant_id = (tenant_id or os.environ.get("DATAVO_ENTRA_TENANT_ID") or "").strip()
156
+ if not resolved_tenant_id and server_config is not None:
157
+ resolved_tenant_id = (server_config.entra_tenant_id or "").strip()
158
+ if not resolved_tenant_id:
159
+ raise RuntimeError("No Entra tenant id available. Provide --tenant-id or configure entra_tenant_id on the server.")
160
+ resolved_client_id = (client_id or os.environ.get("DATAVO_ENTRA_CLIENT_ID") or "").strip()
161
+ if not resolved_client_id and server_config is not None:
162
+ resolved_client_id = (server_config.entra_public_client_id or "").strip()
163
+ if not resolved_client_id:
164
+ raise RuntimeError("No Entra public client id available. Provide --client-id or configure entra_public_client_id on the server.")
165
+ resolved_scopes = derive_scopes(requested_scopes=scopes, server_config=server_config)
166
+ return replace(
167
+ base_profile,
168
+ auth_token=auth_token,
169
+ msal_tenant_id=resolved_tenant_id,
170
+ msal_client_id=resolved_client_id,
171
+ msal_scopes=resolved_scopes,
172
+ )
@@ -0,0 +1,36 @@
1
+ from .auth import handle_auth_command
2
+ from .config import handle_config_command
3
+ from .dataset import handle_dataset_command
4
+ from .events import handle_events_command
5
+ from .jobpool import handle_render_dataset_cache_command, handle_warm_command
6
+ from .sample import handle_sample_command
7
+ from .sample_store import handle_sample_store_command
8
+ from .source_import import handle_source_import_command
9
+ from .split_set import handle_split_set_command
10
+ from .stats import handle_stats_command
11
+ from .team import (
12
+ handle_share_command,
13
+ handle_teams_command,
14
+ handle_transfer_command,
15
+ handle_unshare_command,
16
+ )
17
+ from .user import handle_users_command
18
+
19
+ __all__ = [
20
+ "handle_auth_command",
21
+ "handle_config_command",
22
+ "handle_dataset_command",
23
+ "handle_events_command",
24
+ "handle_render_dataset_cache_command",
25
+ "handle_sample_command",
26
+ "handle_sample_store_command",
27
+ "handle_source_import_command",
28
+ "handle_split_set_command",
29
+ "handle_share_command",
30
+ "handle_stats_command",
31
+ "handle_teams_command",
32
+ "handle_transfer_command",
33
+ "handle_unshare_command",
34
+ "handle_users_command",
35
+ "handle_warm_command",
36
+ ]
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import replace
4
+
5
+ from datavo_sdk import EntraConfig, EntraTokenProvider, default_msal_cache_path
6
+
7
+ from ..client_factory import ENTRA_AUTH_PROVIDERS, derive_msal_profile, resolve_runtime_context
8
+ from ..config_store import save_profile, update_profile
9
+ from ..render import render_output
10
+
11
+
12
+ def _clear_msal_cache() -> None:
13
+ path = default_msal_cache_path()
14
+ if path.exists():
15
+ path.unlink()
16
+
17
+
18
+ def _run_msal_login(*, tenant_id: str, client_id: str, scopes: list[str], force_browser: bool) -> str:
19
+ provider = EntraTokenProvider(
20
+ EntraConfig(tenant_id=tenant_id, client_id=client_id, scopes=list(scopes))
21
+ )
22
+ return provider.login(force_browser=force_browser)
23
+
24
+
25
+ def handle_auth_command(args) -> int:
26
+ output_format = getattr(args, "format", "plain")
27
+ runtime = resolve_runtime_context(require_url=False)
28
+ if args.auth_command == "status":
29
+ render_output(
30
+ {
31
+ "profile": runtime.profile_name,
32
+ "api_base_url": runtime.profile.api_base_url,
33
+ "auth_provider": runtime.auth_provider,
34
+ "auth_token_present": bool(runtime.profile.auth_token),
35
+ "msal_configured": bool(runtime.profile.msal_client_id and runtime.profile.msal_tenant_id and runtime.profile.msal_scopes),
36
+ },
37
+ output_format=output_format,
38
+ )
39
+ return 0
40
+ if args.auth_command == "logout":
41
+ update_profile(runtime.profile_name, auth_token=None)
42
+ _clear_msal_cache()
43
+ render_output({"profile": runtime.profile_name, "logged_out": True}, output_format=output_format)
44
+ return 0
45
+ if args.auth_command != "login":
46
+ raise RuntimeError("Missing auth subcommand.")
47
+
48
+ if args.token:
49
+ updated = replace(runtime.profile, auth_token=args.token.strip())
50
+ save_profile(runtime.profile_name, updated)
51
+ render_output(
52
+ {
53
+ "profile": runtime.profile_name,
54
+ "auth_provider": runtime.auth_provider,
55
+ "auth_token_present": True,
56
+ },
57
+ output_format=output_format,
58
+ )
59
+ return 0
60
+
61
+ use_msal = bool(args.msal or args.msal_browser)
62
+ if not use_msal and runtime.auth_provider in ENTRA_AUTH_PROVIDERS:
63
+ use_msal = True
64
+ if not use_msal:
65
+ raise RuntimeError("Provide --token or use MSAL against an Entra/EasyAuth-enabled Datavo server.")
66
+
67
+ msal_profile = derive_msal_profile(
68
+ base_profile=runtime.profile,
69
+ server_config=runtime.server_config,
70
+ tenant_id=args.tenant_id,
71
+ client_id=args.client_id,
72
+ scopes=args.scope,
73
+ auth_token="",
74
+ )
75
+ token = _run_msal_login(
76
+ tenant_id=msal_profile.msal_tenant_id or "",
77
+ client_id=msal_profile.msal_client_id or "",
78
+ scopes=msal_profile.msal_scopes or [],
79
+ force_browser=bool(args.msal_browser),
80
+ )
81
+ msal_profile = replace(msal_profile, auth_token=token)
82
+ save_profile(runtime.profile_name, msal_profile)
83
+ render_output(
84
+ {
85
+ "profile": runtime.profile_name,
86
+ "auth_provider": runtime.auth_provider,
87
+ "auth_token_present": True,
88
+ "msal_tenant_id": msal_profile.msal_tenant_id,
89
+ "msal_client_id": msal_profile.msal_client_id,
90
+ "msal_scopes": msal_profile.msal_scopes,
91
+ },
92
+ output_format=output_format,
93
+ )
94
+ return 0
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from urllib.parse import urlparse
5
+
6
+ from datavo_sdk import fetch_server_config, save_server_cache
7
+
8
+ from ..config_store import DatavoProfile, get_active_profile_name, load_config, remove_profile, save_profile, set_active_profile
9
+ from ..render import render_output
10
+
11
+
12
+ def _derive_profile_name(url: str) -> str:
13
+ parsed = urlparse(url if "://" in url else f"https://{url}")
14
+ hostname = parsed.hostname
15
+ if not hostname:
16
+ raise RuntimeError(f"Cannot derive a profile name from URL {url!r}; pass an explicit profile name.")
17
+ return hostname
18
+
19
+
20
+ def handle_config_command(args) -> int:
21
+ output_format = getattr(args, "format", "plain")
22
+ if args.config_command == "show":
23
+ config = load_config()
24
+ profile_name = args.profile or get_active_profile_name(config)
25
+ if not profile_name:
26
+ raise RuntimeError("No active Datavo profile.")
27
+ if profile_name not in config.profiles:
28
+ raise RuntimeError(f"Unknown Datavo profile: {profile_name}")
29
+ profile = config.profiles[profile_name]
30
+ render_output(
31
+ {
32
+ "active_profile": config.active_profile,
33
+ "profile": profile_name,
34
+ "api_base_url": profile.api_base_url,
35
+ "auth_token_present": bool(profile.auth_token),
36
+ "msal_tenant_id": profile.msal_tenant_id,
37
+ "msal_client_id": profile.msal_client_id,
38
+ "msal_scopes": profile.msal_scopes,
39
+ },
40
+ output_format=output_format,
41
+ )
42
+ return 0
43
+ if args.config_command == "list":
44
+ config = load_config()
45
+ render_output(
46
+ {
47
+ "profiles": [
48
+ {
49
+ "name": name,
50
+ "api_base_url": profile.api_base_url,
51
+ "active": name == config.active_profile,
52
+ }
53
+ for name, profile in sorted(config.profiles.items())
54
+ ]
55
+ },
56
+ output_format=output_format,
57
+ )
58
+ return 0
59
+ if args.config_command == "use":
60
+ updated = set_active_profile(args.profile)
61
+ render_output({"active_profile": updated.active_profile}, output_format=output_format)
62
+ return 0
63
+ if args.config_command == "set":
64
+ profile_name = args.profile or _derive_profile_name(args.url)
65
+ config = load_config()
66
+ existing = config.profiles.get(profile_name, DatavoProfile())
67
+ profile = DatavoProfile(
68
+ api_base_url=args.url.rstrip("/"),
69
+ auth_token=existing.auth_token,
70
+ msal_tenant_id=existing.msal_tenant_id,
71
+ msal_client_id=existing.msal_client_id,
72
+ msal_scopes=existing.msal_scopes,
73
+ )
74
+ save_profile(profile_name, profile, set_active=True)
75
+ try:
76
+ public_config = fetch_server_config(profile.api_base_url or "")
77
+ save_server_cache(profile.api_base_url or "", public_config)
78
+ except Exception as exc:
79
+ print(f"warning: failed to fetch public config from {profile.api_base_url}: {exc}", file=sys.stderr)
80
+ render_output(
81
+ {
82
+ "active_profile": profile_name,
83
+ "profile": profile_name,
84
+ "api_base_url": profile.api_base_url,
85
+ },
86
+ output_format=output_format,
87
+ )
88
+ return 0
89
+ if args.config_command == "remove":
90
+ updated = remove_profile(args.profile)
91
+ render_output({"active_profile": updated.active_profile}, output_format=output_format)
92
+ return 0
93
+ raise RuntimeError("Missing config subcommand.")
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from datavo_sdk import (
6
+ AddKeyGroupRequest,
7
+ DatasetCreateRequest,
8
+ DatasetPreviewRequest,
9
+ ShardPlan,
10
+ SourceSpec,
11
+ WorksetCreateRequest,
12
+ WorksetIssueStreamTicketsRequest,
13
+ WorksetOpenRequest,
14
+ WorksetShardSelector,
15
+ )
16
+
17
+ from ..client_factory import build_client
18
+ from ..render import render_output
19
+ from ..utils import load_model
20
+
21
+
22
+ def _is_workset_expressible(request: DatasetCreateRequest) -> bool:
23
+ """Whether this composition can be selected as a workset first.
24
+
25
+ Only store-backed sources can. A workset takes its scope from its first source's
26
+ ``sample_store``, so a ``dataset_scope`` source makes a workset that is a dataset
27
+ *read* — explicitly not convertible. It has no workset representation, so it keeps
28
+ going straight to ``/datasets``.
29
+ """
30
+ return bool(request.sources) and all(
31
+ source.kind == "sample_store" for source in request.sources
32
+ )
33
+
34
+
35
+ def _create_dataset_workset_first(client, request: DatasetCreateRequest):
36
+ """Create a dataset the canonical way: build the workset, then convert it.
37
+
38
+ The spec file keeps its shape, but a composition in it is no longer POSTed to
39
+ ``/datasets`` directly — the CLI selects it as a workset first and keeps that
40
+ selection with ``convert_workset_to_dataset``, which is the path a user is told
41
+ to take by hand.
42
+
43
+ Three details make the round-trip lossless. A spec that already names
44
+ ``from_workset`` is a convert request as written, so it passes straight through.
45
+ The workset carries the composition only, so ``shard_plan.sort_by`` is held back
46
+ and applied at convert time — a workset does not order its shards, and its
47
+ validator rejects the field. And a composition a workset cannot express falls
48
+ back to the direct create (see :func:`_is_workset_expressible`).
49
+ """
50
+ if request.from_workset is not None:
51
+ return client.convert_workset_to_dataset(
52
+ request.from_workset,
53
+ request.name,
54
+ shard_plan=request.shard_plan,
55
+ key_groups=request.key_groups or None,
56
+ scalars=request.scalars or None,
57
+ ttl_days=request.ttl_days,
58
+ )
59
+ if not _is_workset_expressible(request):
60
+ return client.create_dataset(request)
61
+
62
+ selection_plan = request.shard_plan.model_copy(update={"sort_by": []})
63
+ workset = client.create_workset(
64
+ WorksetCreateRequest(
65
+ sources=request.sources,
66
+ key_groups=request.key_groups,
67
+ shard_plan=selection_plan,
68
+ )
69
+ )
70
+ client.wait_for_workset(workset.workset_id)
71
+ return client.convert_workset_to_dataset(
72
+ workset.workset_id,
73
+ request.name,
74
+ shard_plan=request.shard_plan,
75
+ key_groups=request.key_groups or None,
76
+ scalars=request.scalars or None,
77
+ ttl_days=request.ttl_days,
78
+ )
79
+
80
+
81
+ def handle_dataset_command(args) -> int:
82
+ output_format = getattr(args, "format", "plain")
83
+ with build_client() as client:
84
+ if args.dataset_command == "preview":
85
+ response = client.preview_dataset(load_model(args.spec, DatasetPreviewRequest))
86
+ render_output(response, output_format=output_format)
87
+ return 0
88
+ if args.dataset_command == "create":
89
+ # Creation is worker-driven for every source kind: the POST returns a
90
+ # `planning` dataset and the worker materializes it asynchronously.
91
+ # --wait polls to `ready` for callers that want the materialized dataset.
92
+ response = _create_dataset_workset_first(
93
+ client, load_model(args.spec, DatasetCreateRequest)
94
+ )
95
+ if getattr(args, "wait", False):
96
+ response = client.wait_for_dataset(response.name)
97
+ render_output(response, output_format=output_format)
98
+ return 0
99
+ if args.dataset_command == "list":
100
+ response = client.list_datasets(
101
+ query=args.query,
102
+ keys=args.keys,
103
+ include_archived=args.include_archived,
104
+ include_split_children=args.include_split_children,
105
+ offset=args.offset, limit=args.limit,
106
+ )
107
+ render_output(response, output_format=output_format)
108
+ return 0
109
+ if args.dataset_command == "get":
110
+ response = client.get_dataset(args.name)
111
+ render_output(response, output_format=output_format)
112
+ return 0
113
+ if args.dataset_command == "archive":
114
+ response = client.archive_dataset(args.name)
115
+ render_output(response, output_format=output_format)
116
+ return 0
117
+ if args.dataset_command == "restore":
118
+ response = client.restore_dataset(args.name)
119
+ render_output(response, output_format=output_format)
120
+ return 0
121
+ if args.dataset_command == "delete":
122
+ response = client.delete_dataset(args.name)
123
+ render_output(response, output_format=output_format)
124
+ return 0
125
+ if args.dataset_command == "add-key-group":
126
+ response = client.add_key_group(args.name, load_model(args.spec, AddKeyGroupRequest))
127
+ render_output(response, output_format=output_format)
128
+ return 0
129
+ if args.dataset_command == "pull":
130
+ output_dir = Path(args.output_dir)
131
+ dataset = client.get_dataset(args.name)
132
+ key_groups = args.key_groups or [group.name for group in dataset.key_groups]
133
+ key_group_by_name = {group.name: group for group in dataset.key_groups}
134
+ all_shards = []
135
+ for key_group in key_groups:
136
+ group = key_group_by_name.get(key_group)
137
+ if group is None:
138
+ raise RuntimeError(f"dataset {args.name} does not define key group {key_group}")
139
+ workset = client.create_workset(
140
+ # retire-workset-scope: a dataset read is a `dataset_scope` source.
141
+ WorksetCreateRequest(
142
+ sources=[
143
+ SourceSpec(
144
+ kind="dataset_scope",
145
+ dataset_name=args.name,
146
+ keys=list(group.keys),
147
+ )
148
+ ],
149
+ shard_plan=ShardPlan(shard_size=100),
150
+ )
151
+ )
152
+ client.wait_for_workset(workset.workset_id)
153
+ batch_ordinal = 0
154
+ while True:
155
+ opened = client.open_workset_stream(
156
+ workset.workset_id,
157
+ WorksetOpenRequest(batch_ordinal=batch_ordinal, limit=200),
158
+ )
159
+ if not opened.shards:
160
+ break
161
+ for shard_ref in opened.shards:
162
+ issued = client.issue_stream_tickets(
163
+ workset.workset_id,
164
+ WorksetIssueStreamTicketsRequest(
165
+ shards=[
166
+ WorksetShardSelector(
167
+ shard_index=shard_ref.shard_index,
168
+ key_group=shard_ref.key_group,
169
+ )
170
+ ],
171
+ ),
172
+ )
173
+ if len(issued.tickets) != 1:
174
+ raise RuntimeError(f"Datavo did not issue a stream ticket for shard {shard_ref.shard_index}")
175
+ shard = issued.tickets[0]
176
+ if shard.content_hash != shard_ref.content_hash:
177
+ raise RuntimeError(
178
+ f"Datavo stream ticket for shard {shard_ref.shard_index} has unexpected content hash"
179
+ )
180
+ filename = f"{shard_ref.shard_index:05d}-{shard_ref.content_hash}.tar"
181
+ destination = output_dir / key_group / filename
182
+ client.download_file(shard.download_url, destination, overwrite=args.overwrite)
183
+ all_shards.append(
184
+ {
185
+ "key_group": key_group,
186
+ **shard.model_dump(mode="json", by_alias=True),
187
+ }
188
+ )
189
+ batch_ordinal += 1
190
+ render_output(
191
+ {
192
+ "dataset_name": args.name,
193
+ "shards": all_shards,
194
+ },
195
+ output_format=output_format,
196
+ )
197
+ return 0
198
+ raise RuntimeError("Missing dataset subcommand.")
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from datavo_sdk import DatavoEventCreateRequest
4
+
5
+ from ..client_factory import build_client
6
+ from ..render import render_output
7
+
8
+
9
+ def _parse_params(values: list[str]) -> dict[str, str]:
10
+ params: dict[str, str] = {}
11
+ for value in values:
12
+ if "=" not in value:
13
+ raise ValueError("--param values must use key=value")
14
+ key, param_value = value.split("=", 1)
15
+ key = key.strip()
16
+ if not key:
17
+ raise ValueError("--param keys must not be empty")
18
+ params[key] = param_value
19
+ return params
20
+
21
+
22
+ def handle_events_command(args) -> int:
23
+ output_format = getattr(args, "format", "plain")
24
+ with build_client() as client:
25
+ if args.events_command == "create":
26
+ response = client.create_event(
27
+ DatavoEventCreateRequest(
28
+ event_type=args.event_type,
29
+ source=args.source,
30
+ jobpool_run_id=args.jobpool_run_id,
31
+ idempotency_key=args.idempotency_key,
32
+ params=_parse_params(args.param),
33
+ )
34
+ )
35
+ render_output(response, output_format=output_format)
36
+ return 0
37
+ if args.events_command == "list":
38
+ response = client.list_events(
39
+ event_type=args.event_type,
40
+ param_filters=_parse_params(args.param),
41
+ )
42
+ render_output(response, output_format=output_format)
43
+ return 0
44
+ if args.events_command == "get":
45
+ response = client.get_event(args.event_id)
46
+ render_output(response, output_format=output_format)
47
+ return 0
48
+ raise RuntimeError("Missing events subcommand.")