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
datavo_cli/__init__.py
ADDED
|
@@ -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.")
|