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.
@@ -0,0 +1,200 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass, field, replace
6
+ from pathlib import Path
7
+ from datavo_sdk.client._transport import DEFAULT_SERVER_URL
8
+ from datavo_sdk.server_config import default_config_dir
9
+
10
+
11
+ def config_path() -> Path:
12
+ # ``~/.datavo`` is resolved once, in the SDK (entra/plan_cache/server_config use it).
13
+ return default_config_dir() / "config.json"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class DatavoProfile:
18
+ api_base_url: str | None = None
19
+ auth_token: str | None = None
20
+ msal_tenant_id: str | None = None
21
+ msal_client_id: str | None = None
22
+ msal_scopes: list[str] | None = None
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class DatavoConfig:
27
+ active_profile: str | None = None
28
+ profiles: dict[str, DatavoProfile] = field(default_factory=dict)
29
+
30
+
31
+ def _parse_profile(value: object) -> DatavoProfile:
32
+ if not isinstance(value, dict):
33
+ return DatavoProfile()
34
+ api_base_url = value.get("api_base_url")
35
+ auth_token = value.get("auth_token")
36
+ msal_tenant_id = value.get("msal_tenant_id")
37
+ msal_client_id = value.get("msal_client_id")
38
+ msal_scopes = value.get("msal_scopes")
39
+ if not isinstance(api_base_url, str):
40
+ api_base_url = None
41
+ if not isinstance(auth_token, str):
42
+ auth_token = None
43
+ if not isinstance(msal_tenant_id, str):
44
+ msal_tenant_id = None
45
+ if not isinstance(msal_client_id, str):
46
+ msal_client_id = None
47
+ if isinstance(msal_scopes, list):
48
+ msal_scopes = [scope for scope in msal_scopes if isinstance(scope, str) and scope.strip()]
49
+ else:
50
+ msal_scopes = None
51
+ return DatavoProfile(
52
+ api_base_url=api_base_url,
53
+ auth_token=auth_token,
54
+ msal_tenant_id=msal_tenant_id,
55
+ msal_client_id=msal_client_id,
56
+ msal_scopes=msal_scopes,
57
+ )
58
+
59
+
60
+ def load_config() -> DatavoConfig:
61
+ path = config_path()
62
+ if not path.exists():
63
+ return DatavoConfig()
64
+ try:
65
+ payload = json.loads(path.read_text(encoding="utf-8"))
66
+ except (json.JSONDecodeError, OSError):
67
+ return DatavoConfig()
68
+ if not isinstance(payload, dict):
69
+ return DatavoConfig()
70
+ active_profile = payload.get("active_profile")
71
+ if not isinstance(active_profile, str):
72
+ active_profile = None
73
+ raw_profiles = payload.get("profiles")
74
+ profiles: dict[str, DatavoProfile] = {}
75
+ if isinstance(raw_profiles, dict):
76
+ for name, value in raw_profiles.items():
77
+ if isinstance(name, str) and name:
78
+ profiles[name] = _parse_profile(value)
79
+ return DatavoConfig(active_profile=active_profile, profiles=profiles)
80
+
81
+
82
+ def save_config(config: DatavoConfig) -> None:
83
+ path = config_path()
84
+ path.parent.mkdir(parents=True, exist_ok=True)
85
+ payload = {
86
+ "active_profile": config.active_profile,
87
+ "profiles": {
88
+ name: {
89
+ key: value
90
+ for key, value in {
91
+ "api_base_url": profile.api_base_url,
92
+ "auth_token": profile.auth_token,
93
+ "msal_tenant_id": profile.msal_tenant_id,
94
+ "msal_client_id": profile.msal_client_id,
95
+ "msal_scopes": profile.msal_scopes,
96
+ }.items()
97
+ if value is not None
98
+ }
99
+ for name, profile in sorted(config.profiles.items())
100
+ },
101
+ }
102
+ path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
103
+
104
+
105
+ #: Where an unconfigured CLI points — the SDK's default, imported rather than restated
106
+ #: so the two cannot drift. Override via ``DATAVO_API_BASE_URL`` / ``datavo config set``.
107
+ DEFAULT_API_BASE_URL = DEFAULT_SERVER_URL
108
+
109
+
110
+ def get_active_profile_name(config: DatavoConfig | None = None) -> str | None:
111
+ config = config or load_config()
112
+ env_name = (os.environ.get("DATAVO_PROFILE") or "").strip()
113
+ if env_name:
114
+ return env_name
115
+ return config.active_profile
116
+
117
+
118
+ def resolve_profile(config: DatavoConfig | None = None, *, profile_name: str | None = None) -> tuple[str, DatavoProfile]:
119
+ config = config or load_config()
120
+ profile_name = profile_name or get_active_profile_name(config)
121
+ if profile_name and profile_name in config.profiles:
122
+ return profile_name, config.profiles[profile_name]
123
+ env_url = (os.environ.get("DATAVO_API_BASE_URL") or "").strip()
124
+ if profile_name and not env_url:
125
+ # A named profile that does not exist is a typo, not an unconfigured CLI: fail
126
+ # rather than land somewhere the caller did not name.
127
+ raise RuntimeError(f"Unknown Datavo profile: {profile_name}")
128
+ # Unconfigured, or pointed by env. Start from the default instance and let
129
+ # apply_env_overrides layer DATAVO_API_BASE_URL over it — so the CLI runs out of the
130
+ # box, and one construction path serves both cases.
131
+ name = profile_name or ("env" if env_url else "prod")
132
+ return name, apply_env_overrides(DatavoProfile(api_base_url=DEFAULT_API_BASE_URL))
133
+
134
+
135
+ def apply_env_overrides(profile: DatavoProfile) -> DatavoProfile:
136
+ api_base_url = (os.environ.get("DATAVO_API_BASE_URL") or "").strip() or profile.api_base_url
137
+ auth_token = (os.environ.get("DATAVO_AUTH_TOKEN") or "").strip() or profile.auth_token
138
+ msal_tenant_id = (os.environ.get("DATAVO_ENTRA_TENANT_ID") or "").strip() or profile.msal_tenant_id
139
+ msal_client_id = (os.environ.get("DATAVO_ENTRA_CLIENT_ID") or "").strip() or profile.msal_client_id
140
+ env_audience = (os.environ.get("DATAVO_ENTRA_AUDIENCE") or "").strip()
141
+ env_scopes_raw = (os.environ.get("DATAVO_ENTRA_SCOPES") or "").strip()
142
+ msal_scopes = profile.msal_scopes
143
+ if env_scopes_raw:
144
+ msal_scopes = [scope.strip() for scope in env_scopes_raw.split(",") if scope.strip()]
145
+ elif env_audience:
146
+ msal_scopes = [f"{env_audience}/access_as_user" if env_audience.startswith("api://") else f"api://{env_audience}/access_as_user"]
147
+ return DatavoProfile(
148
+ api_base_url=api_base_url,
149
+ auth_token=auth_token,
150
+ msal_tenant_id=msal_tenant_id,
151
+ msal_client_id=msal_client_id,
152
+ msal_scopes=msal_scopes,
153
+ )
154
+
155
+
156
+ def save_profile(name: str, profile: DatavoProfile, *, set_active: bool = False) -> DatavoConfig:
157
+ config = load_config()
158
+ profiles = dict(config.profiles)
159
+ profiles[name] = profile
160
+ updated = DatavoConfig(
161
+ active_profile=name if set_active or config.active_profile is None else config.active_profile,
162
+ profiles=profiles,
163
+ )
164
+ save_config(updated)
165
+ return updated
166
+
167
+
168
+ def update_profile(name: str, **changes) -> DatavoConfig:
169
+ config = load_config()
170
+ if name not in config.profiles:
171
+ raise RuntimeError(f"Unknown Datavo profile: {name}")
172
+ profile = replace(config.profiles[name], **changes)
173
+ profiles = dict(config.profiles)
174
+ profiles[name] = profile
175
+ updated = DatavoConfig(active_profile=config.active_profile, profiles=profiles)
176
+ save_config(updated)
177
+ return updated
178
+
179
+
180
+ def set_active_profile(name: str) -> DatavoConfig:
181
+ config = load_config()
182
+ if name not in config.profiles:
183
+ raise RuntimeError(f"Unknown Datavo profile: {name}")
184
+ updated = DatavoConfig(active_profile=name, profiles=dict(config.profiles))
185
+ save_config(updated)
186
+ return updated
187
+
188
+
189
+ def remove_profile(name: str) -> DatavoConfig:
190
+ config = load_config()
191
+ if name not in config.profiles:
192
+ raise RuntimeError(f"Unknown Datavo profile: {name}")
193
+ profiles = dict(config.profiles)
194
+ profiles.pop(name, None)
195
+ active_profile = config.active_profile
196
+ if active_profile == name:
197
+ active_profile = next(iter(sorted(profiles)), None)
198
+ updated = DatavoConfig(active_profile=active_profile, profiles=profiles)
199
+ save_config(updated)
200
+ return updated
@@ -0,0 +1,77 @@
1
+ """Version-skew diagnostics for the CLI error path.
2
+
3
+ A stale CLI meeting a newer server is the most likely thing to go wrong for a
4
+ user who installed once and came back later. When a command fails with a 422 or
5
+ a response the client cannot parse, the handler appends one actionable line
6
+ comparing the client's version to the server's ``release_version`` (fetched only
7
+ on the error path, so the happy path stays at one round trip).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib.metadata
13
+ from urllib.parse import urlsplit, urlunsplit
14
+
15
+ from datavo_sdk import fetch_server_config
16
+
17
+ _CLI_DISTRIBUTION = "datavo-cli"
18
+ _UPGRADE_HINT = "uv tool upgrade datavo-cli"
19
+
20
+
21
+ def resolve_client_version() -> str:
22
+ """The CLI's real installed version.
23
+
24
+ Reads the published distribution version (injected at build time), not the
25
+ hard-coded ``datavo_cli.__version__`` placeholder — a skew line is only worth
26
+ printing if the client version is real. Falls back to ``__version__`` when the
27
+ distribution metadata is absent (e.g. running from a source checkout).
28
+ """
29
+ try:
30
+ return importlib.metadata.version(_CLI_DISTRIBUTION)
31
+ except importlib.metadata.PackageNotFoundError:
32
+ from . import __version__
33
+
34
+ return __version__
35
+
36
+
37
+ def server_root_from_request_url(url: str) -> str | None:
38
+ """The server root (where ``/config`` lives) for a failing request URL.
39
+
40
+ Mirrors the transport: the API is mounted under ``/api``, while ``/config``
41
+ sits at the root. Returns ``None`` if the URL is not absolute.
42
+ """
43
+ try:
44
+ parts = urlsplit(url)
45
+ except Exception:
46
+ return None
47
+ if not parts.scheme or not parts.netloc:
48
+ return None
49
+ idx = parts.path.find("/api")
50
+ base_path = parts.path[:idx] if idx != -1 else ""
51
+ return urlunsplit((parts.scheme, parts.netloc, base_path, "", ""))
52
+
53
+
54
+ def version_skew_line(
55
+ server_root: str,
56
+ *,
57
+ client_version: str | None = None,
58
+ timeout: float = 5.0,
59
+ ) -> str | None:
60
+ """One actionable upgrade line when client and server versions differ, else ``None``.
61
+
62
+ Best-effort and never raises: it fetches ``GET /config`` for the server's
63
+ ``release_version`` and returns ``None`` when the versions match or the server
64
+ version cannot be determined, so no misleading upgrade line is printed.
65
+ """
66
+ client = client_version or resolve_client_version()
67
+ try:
68
+ config = fetch_server_config(server_root, timeout=timeout)
69
+ except Exception:
70
+ return None
71
+ server = getattr(config, "release_version", None)
72
+ if not server or server == client:
73
+ return None
74
+ return (
75
+ f"datavo CLI {client} is talking to server {server} — "
76
+ f"upgrade with `{_UPGRADE_HINT}`"
77
+ )
datavo_cli/errors.py ADDED
@@ -0,0 +1,49 @@
1
+ """Rendering CLI errors legibly, and appending a version-skew line when skew is the
2
+ likely cause. Kept out of ``main`` so the entrypoint stays pure dispatch.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import sys
10
+
11
+ from datavo_sdk import DatavoApiError, DatavoResponseParseError
12
+
13
+ from .diagnostics import server_root_from_request_url, version_skew_line
14
+
15
+
16
+ def verbose_enabled(args) -> bool:
17
+ if getattr(args, "verbose", False):
18
+ return True
19
+ return os.environ.get("DATAVO_VERBOSE", "").strip().lower() in ("1", "true", "yes")
20
+
21
+
22
+ def report_cli_error(exc: Exception, *, verbose: bool) -> None:
23
+ """Render a CLI error and, on skew-shaped failures, append a version line.
24
+
25
+ A ``DatavoApiError`` already composes method/url/status in its message (so we
26
+ print the whole message, not the bare detail); a 422 or a client-side
27
+ ``DatavoResponseParseError`` is the case where a client/server version-skew line
28
+ is worth appending.
29
+ """
30
+ skew_url: str | None = None
31
+ if isinstance(exc, DatavoApiError):
32
+ print(str(exc), file=sys.stderr)
33
+ if verbose and exc.raw_detail is not None:
34
+ print(json.dumps(exc.raw_detail, indent=2), file=sys.stderr)
35
+ if exc.status_code == 422:
36
+ skew_url = exc.url
37
+ elif isinstance(exc, DatavoResponseParseError):
38
+ print(str(exc), file=sys.stderr)
39
+ if verbose:
40
+ print(str(exc.error), file=sys.stderr)
41
+ skew_url = exc.url
42
+ else:
43
+ print(str(exc), file=sys.stderr)
44
+
45
+ if skew_url is not None:
46
+ root = server_root_from_request_url(skew_url)
47
+ line = version_skew_line(root) if root else None
48
+ if line:
49
+ print(line, file=sys.stderr)
datavo_cli/main.py ADDED
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ try:
6
+ import argcomplete
7
+ except ImportError: # pragma: no cover
8
+ argcomplete = None
9
+
10
+ from .errors import report_cli_error, verbose_enabled
11
+
12
+ from . import __version__
13
+ from .commands import (
14
+ handle_auth_command,
15
+ handle_config_command,
16
+ handle_dataset_command,
17
+ handle_events_command,
18
+ handle_render_dataset_cache_command,
19
+ handle_sample_command,
20
+ handle_sample_store_command,
21
+ handle_source_import_command,
22
+ handle_split_set_command,
23
+ handle_share_command,
24
+ handle_stats_command,
25
+ handle_teams_command,
26
+ handle_transfer_command,
27
+ handle_unshare_command,
28
+ handle_users_command,
29
+ handle_warm_command,
30
+ )
31
+ from .parsers import build_root_parser
32
+
33
+
34
+ def main() -> int:
35
+ parser = build_root_parser()
36
+ if argcomplete is not None:
37
+ argcomplete.autocomplete(parser)
38
+ args = parser.parse_args()
39
+ if args.command is None:
40
+ parser.print_help()
41
+ return 0
42
+ try:
43
+ if args.command == "config":
44
+ return handle_config_command(args)
45
+ if args.command == "auth":
46
+ return handle_auth_command(args)
47
+ if args.command == "sample-store":
48
+ return handle_sample_store_command(args)
49
+ if args.command == "source-import":
50
+ return handle_source_import_command(args)
51
+ if args.command == "sample":
52
+ return handle_sample_command(args)
53
+ if args.command == "stats":
54
+ return handle_stats_command(args)
55
+ if args.command == "dataset":
56
+ return handle_dataset_command(args)
57
+ if args.command == "split-set":
58
+ return handle_split_set_command(args)
59
+ if args.command == "teams":
60
+ return handle_teams_command(args)
61
+ if args.command == "share":
62
+ return handle_share_command(args)
63
+ if args.command == "unshare":
64
+ return handle_unshare_command(args)
65
+ if args.command == "transfer":
66
+ return handle_transfer_command(args)
67
+ if args.command == "users":
68
+ return handle_users_command(args)
69
+ if args.command == "events":
70
+ return handle_events_command(args)
71
+ if args.command == "render-dataset-cache":
72
+ return handle_render_dataset_cache_command(args)
73
+ if args.command == "warm":
74
+ return handle_warm_command(args)
75
+ if args.command == "version":
76
+ output_format = getattr(args, "format", "plain")
77
+ if output_format == "json":
78
+ print(f'{{"datavo_cli": "{__version__}"}}')
79
+ else:
80
+ print(__version__)
81
+ return 0
82
+ parser.error(f"Unknown command: {args.command}")
83
+ return 2
84
+ except Exception as exc:
85
+ report_cli_error(exc, verbose=verbose_enabled(args))
86
+ return 1
87
+
88
+
89
+ if __name__ == "__main__":
90
+ raise SystemExit(main())