entropy-data 0.3.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,5 @@
1
+ """Entropy Data CLI — command-line interface for Entropy Data."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("entropy-data")
@@ -0,0 +1,3 @@
1
+ from entropy_data.cli import app
2
+
3
+ app()
entropy_data/cli.py ADDED
@@ -0,0 +1,133 @@
1
+ """Entropy Data CLI — main application entry point."""
2
+
3
+ import logging
4
+ import sys
5
+ from typing import Annotated, Optional
6
+
7
+ import typer
8
+ from dotenv import load_dotenv
9
+ from rich.console import Console
10
+
11
+ from entropy_data import __version__
12
+ from entropy_data.client import ApiError, EntropyDataClient
13
+ from entropy_data.config import ConfigurationError, resolve_connection
14
+ from entropy_data.output import OutputFormat
15
+
16
+ # Global state shared across commands
17
+ _connection_name: str | None = None
18
+ _cli_api_key: str | None = None
19
+ _cli_host: str | None = None
20
+ _output_format: OutputFormat = OutputFormat.table
21
+ _debug: bool = False
22
+
23
+ error_console = Console(stderr=True)
24
+
25
+
26
+ def get_client() -> EntropyDataClient:
27
+ """Create an API client from the resolved connection config."""
28
+ config = resolve_connection(
29
+ connection_name=_connection_name,
30
+ cli_api_key=_cli_api_key,
31
+ cli_host=_cli_host,
32
+ )
33
+ return EntropyDataClient(config)
34
+
35
+
36
+ def get_output_format() -> OutputFormat:
37
+ return _output_format
38
+
39
+
40
+ def handle_error(e: Exception) -> None:
41
+ """Handle errors with appropriate output and exit codes."""
42
+ if _debug:
43
+ raise e
44
+ if isinstance(e, ConfigurationError):
45
+ error_console.print(f"[red]Configuration error: {e}[/red]")
46
+ raise SystemExit(2)
47
+ if isinstance(e, ApiError):
48
+ error_console.print(f"[red]API error: {e}[/red]")
49
+ raise SystemExit(1)
50
+ error_console.print(f"[red]Error: {e}[/red]")
51
+ raise SystemExit(1)
52
+
53
+
54
+ def version_callback(value: bool) -> None:
55
+ if value:
56
+ print(f"entropy-data {__version__}")
57
+ raise typer.Exit()
58
+
59
+
60
+ app = typer.Typer(
61
+ name="entropy-data",
62
+ help="CLI for Entropy Data.",
63
+ no_args_is_help=True,
64
+ rich_markup_mode="rich",
65
+ )
66
+
67
+
68
+ @app.callback()
69
+ def main(
70
+ version: Annotated[
71
+ Optional[bool],
72
+ typer.Option("--version", "-v", help="Show version and exit.", callback=version_callback, is_eager=True),
73
+ ] = None,
74
+ connection: Annotated[Optional[str], typer.Option("--connection", "-c", help="Named connection to use.")] = None,
75
+ api_key: Annotated[Optional[str], typer.Option("--api-key", help="API key (overrides config and env).")] = None,
76
+ host: Annotated[Optional[str], typer.Option("--host", help="API host URL (overrides config and env).")] = None,
77
+ output: Annotated[OutputFormat, typer.Option("--output", "-o", help="Output format.")] = OutputFormat.table,
78
+ debug: Annotated[bool, typer.Option("--debug", help="Enable debug output.")] = False,
79
+ ) -> None:
80
+ """Entropy Data CLI — manage your data platform from the command line."""
81
+ global _connection_name, _cli_api_key, _cli_host, _output_format, _debug
82
+ load_dotenv()
83
+ _connection_name = connection
84
+ _cli_api_key = api_key
85
+ _cli_host = host
86
+ _output_format = output
87
+ _debug = debug
88
+ if debug:
89
+ logging.basicConfig(level=logging.DEBUG, stream=sys.stderr)
90
+
91
+
92
+ # Register command groups
93
+ from entropy_data.commands.access import access_app # noqa: E402
94
+ from entropy_data.commands.api_keys import api_keys_app # noqa: E402
95
+ from entropy_data.commands.assets import assets_app # noqa: E402
96
+ from entropy_data.commands.certifications import certifications_app # noqa: E402
97
+ from entropy_data.commands.connection import connection_app # noqa: E402
98
+ from entropy_data.commands.costs import costs_app # noqa: E402
99
+ from entropy_data.commands.datacontracts import datacontracts_app # noqa: E402
100
+ from entropy_data.commands.dataproducts import dataproducts_app # noqa: E402
101
+ from entropy_data.commands.definitions import definitions_app # noqa: E402
102
+ from entropy_data.commands.events import events_app # noqa: E402
103
+ from entropy_data.commands.example_data import example_data_app # noqa: E402
104
+ from entropy_data.commands.import_export import import_app # noqa: E402
105
+ from entropy_data.commands.lineage import lineage_app # noqa: E402
106
+ from entropy_data.commands.search import search_app # noqa: E402
107
+ from entropy_data.commands.settings import settings_app # noqa: E402
108
+ from entropy_data.commands.sourcesystems import sourcesystems_app # noqa: E402
109
+ from entropy_data.commands.tags import tags_app # noqa: E402
110
+ from entropy_data.commands.teams import teams_app # noqa: E402
111
+ from entropy_data.commands.test_results import test_results_app # noqa: E402
112
+ from entropy_data.commands.usage import usage_app # noqa: E402
113
+
114
+ app.add_typer(connection_app, name="connection", help="Manage connections.")
115
+ app.add_typer(teams_app, name="teams", help="Manage teams.")
116
+ app.add_typer(dataproducts_app, name="dataproducts", help="Manage data products.")
117
+ app.add_typer(datacontracts_app, name="datacontracts", help="Manage data contracts.")
118
+ app.add_typer(access_app, name="access", help="Manage access (data usage agreements).")
119
+ app.add_typer(sourcesystems_app, name="sourcesystems", help="Manage source systems.")
120
+ app.add_typer(definitions_app, name="definitions", help="Manage definitions.")
121
+ app.add_typer(certifications_app, name="certifications", help="Manage certifications.")
122
+ app.add_typer(example_data_app, name="example-data", help="Manage example data.")
123
+ app.add_typer(test_results_app, name="test-results", help="Manage test results.")
124
+ app.add_typer(costs_app, name="costs", help="Manage costs.")
125
+ app.add_typer(assets_app, name="assets", help="Manage data assets.")
126
+ app.add_typer(tags_app, name="tags", help="Manage tags.")
127
+ app.add_typer(api_keys_app, name="api-keys", help="Manage API keys.")
128
+ app.add_typer(settings_app, name="settings", help="Manage organization settings.")
129
+ app.add_typer(events_app, name="events", help="Poll events.")
130
+ app.add_typer(lineage_app, name="lineage", help="Manage lineage (OpenLineage events).")
131
+ app.add_typer(search_app, name="search", help="Search across resources.")
132
+ app.add_typer(usage_app, name="usage", help="Manage usage (OpenTelemetry traces).")
133
+ app.add_typer(import_app, name="import", help="Import organization exports.")
entropy_data/client.py ADDED
@@ -0,0 +1,163 @@
1
+ """HTTP client for the Entropy Data API."""
2
+
3
+ import json
4
+ import re
5
+
6
+ import requests
7
+
8
+ from entropy_data.config import ConnectionConfig
9
+
10
+ RESPONSE_HEADER_LOCATION_HTML = "location-html"
11
+ REQUEST_TIMEOUT = 30
12
+
13
+
14
+ class ApiError(Exception):
15
+ """HTTP error from the Entropy Data API."""
16
+
17
+ def __init__(self, status_code: int, message: str, url: str):
18
+ self.status_code = status_code
19
+ self.url = url
20
+ super().__init__(f"HTTP {status_code} from {url}: {message}")
21
+
22
+
23
+ class NotFoundError(ApiError):
24
+ """404 from the API."""
25
+
26
+
27
+ class ValidationError(ApiError):
28
+ """422 from the API."""
29
+
30
+
31
+ def _raise_for_status(response: requests.Response) -> None:
32
+ """Raise appropriate ApiError subclass for non-2xx responses."""
33
+ if response.ok:
34
+ return
35
+ message = response.text
36
+ try:
37
+ body = response.json()
38
+ message = body.get("detail") or body.get("message") or body.get("title") or response.text
39
+ except (json.JSONDecodeError, AttributeError):
40
+ # If the response is HTML, extract a useful message instead of dumping raw markup
41
+ if "<html" in message.lower():
42
+ match = re.search(r"<title>([^<]+)</title>", message, re.IGNORECASE)
43
+ message = match.group(1).strip() if match else response.reason or "Server error"
44
+ if response.status_code == 404:
45
+ raise NotFoundError(response.status_code, message, response.url)
46
+ if response.status_code == 422:
47
+ raise ValidationError(response.status_code, message, response.url)
48
+ raise ApiError(response.status_code, message, response.url)
49
+
50
+
51
+ def _has_next_page(response: requests.Response) -> bool:
52
+ """Check if the Link header contains rel="next"."""
53
+ link = response.headers.get("Link", "")
54
+ return bool(re.search(r'rel="next"', link))
55
+
56
+
57
+ MAX_RESOURCE_ID_LENGTH = 256
58
+
59
+
60
+ def _validate_resource_id(resource_id: str) -> None:
61
+ """Reject empty, too-long, or path-traversal resource IDs."""
62
+ if not resource_id:
63
+ raise ValueError("Resource ID must not be empty.")
64
+ if len(resource_id) > MAX_RESOURCE_ID_LENGTH:
65
+ raise ValueError(f"Resource ID must not exceed {MAX_RESOURCE_ID_LENGTH} characters.")
66
+ if ".." in resource_id.split("/"):
67
+ raise ValueError(f"Resource ID must not contain path traversal: '{resource_id}'")
68
+
69
+
70
+ def _validate_page(page: int) -> None:
71
+ """Reject negative page numbers."""
72
+ if page < 0:
73
+ raise ValueError(f"Page number must not be negative: {page}")
74
+
75
+
76
+ class EntropyDataClient:
77
+ def __init__(self, config: ConnectionConfig):
78
+ self.base_url = config.host.rstrip("/")
79
+ self.session = requests.Session()
80
+ self.session.headers.update(
81
+ {
82
+ "x-api-key": config.api_key,
83
+ "Content-Type": "application/json",
84
+ }
85
+ )
86
+
87
+ def list_resources(self, path: str, params: dict | None = None) -> tuple[list[dict], bool]:
88
+ """GET /api/{path}. Returns (items, has_next_page)."""
89
+ if params and "p" in params:
90
+ _validate_page(int(params["p"]))
91
+ response = self.session.get(f"{self.base_url}/api/{path}", params=params, timeout=REQUEST_TIMEOUT)
92
+ _raise_for_status(response)
93
+ return response.json(), _has_next_page(response)
94
+
95
+ def get_resource(self, path: str, resource_id: str) -> dict:
96
+ """GET /api/{path}/{id}."""
97
+ _validate_resource_id(resource_id)
98
+ response = self.session.get(f"{self.base_url}/api/{path}/{resource_id}", timeout=REQUEST_TIMEOUT)
99
+ _raise_for_status(response)
100
+ return response.json()
101
+
102
+ def put_resource(self, path: str, resource_id: str, body: dict) -> str | None:
103
+ """PUT /api/{path}/{id}. Returns location-html URL if present."""
104
+ _validate_resource_id(resource_id)
105
+ if "id" in body and body["id"] != resource_id:
106
+ body = {**body, "id": resource_id}
107
+ response = self.session.put(f"{self.base_url}/api/{path}/{resource_id}", json=body, timeout=REQUEST_TIMEOUT)
108
+ _raise_for_status(response)
109
+ return response.headers.get(RESPONSE_HEADER_LOCATION_HTML)
110
+
111
+ def delete_resource(self, path: str, resource_id: str) -> None:
112
+ """DELETE /api/{path}/{id}."""
113
+ _validate_resource_id(resource_id)
114
+ response = self.session.delete(f"{self.base_url}/api/{path}/{resource_id}", timeout=REQUEST_TIMEOUT)
115
+ _raise_for_status(response)
116
+
117
+ def post_action(self, path: str, resource_id: str, action: str) -> str | None:
118
+ """POST /api/{path}/{id}/{action}. Returns location-html URL if present."""
119
+ _validate_resource_id(resource_id)
120
+ response = self.session.post(f"{self.base_url}/api/{path}/{resource_id}/{action}", timeout=REQUEST_TIMEOUT)
121
+ _raise_for_status(response)
122
+ return response.headers.get(RESPONSE_HEADER_LOCATION_HTML)
123
+
124
+ def post_action_json(self, path: str, resource_id: str, action: str, params: dict | None = None,
125
+ timeout: int = REQUEST_TIMEOUT) -> dict:
126
+ """POST /api/{path}/{id}/{action} with query params. Returns response JSON."""
127
+ _validate_resource_id(resource_id)
128
+ response = self.session.post(
129
+ f"{self.base_url}/api/{path}/{resource_id}/{action}", params=params, timeout=timeout,
130
+ )
131
+ _raise_for_status(response)
132
+ return response.json()
133
+
134
+ def post_resource(self, path: str, body: dict, params: dict | None = None) -> str | None:
135
+ """POST /api/{path}. Returns location-html URL if present."""
136
+ response = self.session.post(f"{self.base_url}/api/{path}", json=body, params=params, timeout=REQUEST_TIMEOUT)
137
+ _raise_for_status(response)
138
+ return response.headers.get(RESPONSE_HEADER_LOCATION_HTML)
139
+
140
+ def delete_resources(self, path: str, params: dict | None = None) -> dict:
141
+ """DELETE /api/{path} with query params. Returns response JSON."""
142
+ response = self.session.delete(f"{self.base_url}/api/{path}", params=params, timeout=REQUEST_TIMEOUT)
143
+ _raise_for_status(response)
144
+ try:
145
+ return response.json()
146
+ except Exception:
147
+ return {}
148
+
149
+ def get_events(self, last_event_id: str | None = None) -> list[dict]:
150
+ """GET /api/events. Returns list of events."""
151
+ params = {}
152
+ if last_event_id:
153
+ params["lastEventId"] = last_event_id
154
+ response = self.session.get(f"{self.base_url}/api/events", params=params, timeout=REQUEST_TIMEOUT)
155
+ _raise_for_status(response)
156
+ return response.json()
157
+
158
+ def search(self, query: str, **params) -> dict:
159
+ """GET /api/search."""
160
+ params["query"] = query
161
+ response = self.session.get(f"{self.base_url}/api/search", params=params, timeout=REQUEST_TIMEOUT)
162
+ _raise_for_status(response)
163
+ return response.json()
File without changes
@@ -0,0 +1,128 @@
1
+ """Access (data usage agreements) commands."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated, Optional
5
+
6
+ import typer
7
+
8
+ from entropy_data.output import OutputFormat, print_link, print_resource, print_resource_list, print_success
9
+ from entropy_data.util import read_body
10
+
11
+ access_app = typer.Typer(no_args_is_help=True)
12
+ RESOURCE_PATH = "access"
13
+ RESOURCE_TYPE = "access"
14
+
15
+
16
+ @access_app.command("list")
17
+ def list_access(
18
+ page: Annotated[int, typer.Option("--page", "-p", help="Page number (0-indexed).")] = 0,
19
+ output: Annotated[Optional[OutputFormat], typer.Option("--output", "-o", help="Output format.")] = None,
20
+ ) -> None:
21
+ """List all access agreements."""
22
+ from entropy_data.cli import get_client, get_output_format, handle_error
23
+
24
+ fmt = output or get_output_format()
25
+ try:
26
+ client = get_client()
27
+ data, has_next = client.list_resources(RESOURCE_PATH, params={"p": page})
28
+ print_resource_list(data, RESOURCE_TYPE, fmt, has_next_page=has_next, page=page)
29
+ except Exception as e:
30
+ handle_error(e)
31
+
32
+
33
+ @access_app.command("get")
34
+ def get_access(
35
+ id: Annotated[str, typer.Argument(help="Access agreement ID.")],
36
+ output: Annotated[Optional[OutputFormat], typer.Option("--output", "-o", help="Output format.")] = None,
37
+ ) -> None:
38
+ """Get an access agreement by ID."""
39
+ from entropy_data.cli import get_client, get_output_format, handle_error
40
+
41
+ fmt = output or get_output_format()
42
+ try:
43
+ client = get_client()
44
+ data = client.get_resource(RESOURCE_PATH, id)
45
+ print_resource(data, RESOURCE_TYPE, fmt)
46
+ except Exception as e:
47
+ handle_error(e)
48
+
49
+
50
+ @access_app.command("put")
51
+ def put_access(
52
+ id: Annotated[str, typer.Argument(help="Access agreement ID.")],
53
+ file: Annotated[Path, typer.Option("--file", "-f", help="JSON or YAML file (use - for stdin).")] = ...,
54
+ ) -> None:
55
+ """Create or update an access agreement."""
56
+ from entropy_data.cli import get_client, handle_error
57
+
58
+ try:
59
+ body = read_body(file)
60
+ client = get_client()
61
+ location = client.put_resource(RESOURCE_PATH, id, body)
62
+ print_success(f"Access agreement '{id}' saved.")
63
+ print_link(location)
64
+ except Exception as e:
65
+ handle_error(e)
66
+
67
+
68
+ @access_app.command("delete")
69
+ def delete_access(
70
+ id: Annotated[str, typer.Argument(help="Access agreement ID.")],
71
+ ) -> None:
72
+ """Delete an access agreement."""
73
+ from entropy_data.cli import get_client, handle_error
74
+
75
+ try:
76
+ client = get_client()
77
+ client.delete_resource(RESOURCE_PATH, id)
78
+ print_success(f"Access agreement '{id}' deleted.")
79
+ except Exception as e:
80
+ handle_error(e)
81
+
82
+
83
+ @access_app.command("approve")
84
+ def approve_access(
85
+ id: Annotated[str, typer.Argument(help="Access agreement ID.")],
86
+ ) -> None:
87
+ """Approve an access agreement."""
88
+ from entropy_data.cli import get_client, handle_error
89
+
90
+ try:
91
+ client = get_client()
92
+ location = client.post_action(RESOURCE_PATH, id, "approve")
93
+ print_success(f"Access agreement '{id}' approved.")
94
+ print_link(location)
95
+ except Exception as e:
96
+ handle_error(e)
97
+
98
+
99
+ @access_app.command("reject")
100
+ def reject_access(
101
+ id: Annotated[str, typer.Argument(help="Access agreement ID.")],
102
+ ) -> None:
103
+ """Reject an access agreement."""
104
+ from entropy_data.cli import get_client, handle_error
105
+
106
+ try:
107
+ client = get_client()
108
+ location = client.post_action(RESOURCE_PATH, id, "reject")
109
+ print_success(f"Access agreement '{id}' rejected.")
110
+ print_link(location)
111
+ except Exception as e:
112
+ handle_error(e)
113
+
114
+
115
+ @access_app.command("cancel")
116
+ def cancel_access(
117
+ id: Annotated[str, typer.Argument(help="Access agreement ID.")],
118
+ ) -> None:
119
+ """Cancel an access agreement."""
120
+ from entropy_data.cli import get_client, handle_error
121
+
122
+ try:
123
+ client = get_client()
124
+ location = client.post_action(RESOURCE_PATH, id, "cancel")
125
+ print_success(f"Access agreement '{id}' cancelled.")
126
+ print_link(location)
127
+ except Exception as e:
128
+ handle_error(e)
@@ -0,0 +1,65 @@
1
+ """API Keys commands."""
2
+
3
+ import json
4
+ from typing import Annotated, Optional
5
+
6
+ import typer
7
+
8
+ from entropy_data.output import OutputFormat, console, print_success
9
+
10
+ api_keys_app = typer.Typer(no_args_is_help=True)
11
+ RESOURCE_PATH = "api-keys"
12
+
13
+
14
+ @api_keys_app.command("create")
15
+ def create_api_key(
16
+ scope: Annotated[str, typer.Option("--scope", help="Scope: 'team' (read/write) or 'team_read' (read-only).")],
17
+ team_id: Annotated[str, typer.Option("--team-id", help="Team ID to scope the key to.")],
18
+ display_name: Annotated[
19
+ Optional[str], typer.Option("--display-name", help="Human-readable name for the key.")
20
+ ] = None,
21
+ output: Annotated[Optional[OutputFormat], typer.Option("--output", "-o", help="Output format.")] = None,
22
+ ) -> None:
23
+ """Create a team-scoped API key."""
24
+ from entropy_data.cli import get_client, get_output_format, handle_error
25
+
26
+ fmt = output or get_output_format()
27
+ try:
28
+ body = {"scope": scope, "teamId": team_id}
29
+ if display_name:
30
+ body["displayName"] = display_name
31
+ client = get_client()
32
+ response = client.session.post(
33
+ f"{client.base_url}/api/{RESOURCE_PATH}",
34
+ json=body,
35
+ timeout=30,
36
+ )
37
+ from entropy_data.client import _raise_for_status
38
+
39
+ _raise_for_status(response)
40
+ data = response.json()
41
+ if fmt == OutputFormat.json:
42
+ console.print_json(json.dumps(data))
43
+ else:
44
+ print_success(f"API key created: {data.get('organizationApiKeyId')}")
45
+ key = data.get("key")
46
+ if key:
47
+ console.print(f"[bold]Key:[/bold] {key}")
48
+ console.print("[dim]This key is only shown once. Store it securely.[/dim]")
49
+ except Exception as e:
50
+ handle_error(e)
51
+
52
+
53
+ @api_keys_app.command("delete")
54
+ def delete_api_key(
55
+ id: Annotated[str, typer.Argument(help="API key ID.")],
56
+ ) -> None:
57
+ """Delete a team-scoped API key."""
58
+ from entropy_data.cli import get_client, handle_error
59
+
60
+ try:
61
+ client = get_client()
62
+ client.delete_resource(RESOURCE_PATH, id)
63
+ print_success(f"API key '{id}' deleted.")
64
+ except Exception as e:
65
+ handle_error(e)
@@ -0,0 +1,80 @@
1
+ """Assets commands."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated, Optional
5
+
6
+ import typer
7
+
8
+ from entropy_data.output import OutputFormat, print_link, print_resource, print_resource_list, print_success
9
+ from entropy_data.util import read_body
10
+
11
+ assets_app = typer.Typer(no_args_is_help=True)
12
+ RESOURCE_PATH = "assets"
13
+ RESOURCE_TYPE = "assets"
14
+
15
+
16
+ @assets_app.command("list")
17
+ def list_assets(
18
+ page: Annotated[int, typer.Option("--page", "-p", help="Page number (0-indexed).")] = 0,
19
+ output: Annotated[Optional[OutputFormat], typer.Option("--output", "-o", help="Output format.")] = None,
20
+ ) -> None:
21
+ """List all data assets."""
22
+ from entropy_data.cli import get_client, get_output_format, handle_error
23
+
24
+ fmt = output or get_output_format()
25
+ try:
26
+ client = get_client()
27
+ data, has_next = client.list_resources(RESOURCE_PATH, params={"p": page})
28
+ print_resource_list(data, RESOURCE_TYPE, fmt, has_next_page=has_next, page=page)
29
+ except Exception as e:
30
+ handle_error(e)
31
+
32
+
33
+ @assets_app.command("get")
34
+ def get_asset(
35
+ id: Annotated[str, typer.Argument(help="Asset ID.")],
36
+ output: Annotated[Optional[OutputFormat], typer.Option("--output", "-o", help="Output format.")] = None,
37
+ ) -> None:
38
+ """Get a data asset by ID."""
39
+ from entropy_data.cli import get_client, get_output_format, handle_error
40
+
41
+ fmt = output or get_output_format()
42
+ try:
43
+ client = get_client()
44
+ data = client.get_resource(RESOURCE_PATH, id)
45
+ print_resource(data, RESOURCE_TYPE, fmt)
46
+ except Exception as e:
47
+ handle_error(e)
48
+
49
+
50
+ @assets_app.command("put")
51
+ def put_asset(
52
+ id: Annotated[str, typer.Argument(help="Asset ID.")],
53
+ file: Annotated[Path, typer.Option("--file", "-f", help="JSON or YAML file (use - for stdin).")] = ...,
54
+ ) -> None:
55
+ """Create or update a data asset."""
56
+ from entropy_data.cli import get_client, handle_error
57
+
58
+ try:
59
+ body = read_body(file)
60
+ client = get_client()
61
+ location = client.put_resource(RESOURCE_PATH, id, body)
62
+ print_success(f"Asset '{id}' saved.")
63
+ print_link(location)
64
+ except Exception as e:
65
+ handle_error(e)
66
+
67
+
68
+ @assets_app.command("delete")
69
+ def delete_asset(
70
+ id: Annotated[str, typer.Argument(help="Asset ID.")],
71
+ ) -> None:
72
+ """Delete a data asset."""
73
+ from entropy_data.cli import get_client, handle_error
74
+
75
+ try:
76
+ client = get_client()
77
+ client.delete_resource(RESOURCE_PATH, id)
78
+ print_success(f"Asset '{id}' deleted.")
79
+ except Exception as e:
80
+ handle_error(e)
@@ -0,0 +1,80 @@
1
+ """Certifications commands."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated, Optional
5
+
6
+ import typer
7
+
8
+ from entropy_data.output import OutputFormat, print_link, print_resource, print_resource_list, print_success
9
+ from entropy_data.util import read_body
10
+
11
+ certifications_app = typer.Typer(no_args_is_help=True)
12
+ RESOURCE_PATH = "certifications"
13
+ RESOURCE_TYPE = "certifications"
14
+
15
+
16
+ @certifications_app.command("list")
17
+ def list_certifications(
18
+ page: Annotated[int, typer.Option("--page", "-p", help="Page number (0-indexed).")] = 0,
19
+ output: Annotated[Optional[OutputFormat], typer.Option("--output", "-o", help="Output format.")] = None,
20
+ ) -> None:
21
+ """List all certifications."""
22
+ from entropy_data.cli import get_client, get_output_format, handle_error
23
+
24
+ fmt = output or get_output_format()
25
+ try:
26
+ client = get_client()
27
+ data, has_next = client.list_resources(RESOURCE_PATH, params={"p": page})
28
+ print_resource_list(data, RESOURCE_TYPE, fmt, has_next_page=has_next, page=page)
29
+ except Exception as e:
30
+ handle_error(e)
31
+
32
+
33
+ @certifications_app.command("get")
34
+ def get_certification(
35
+ id: Annotated[str, typer.Argument(help="Certification ID.")],
36
+ output: Annotated[Optional[OutputFormat], typer.Option("--output", "-o", help="Output format.")] = None,
37
+ ) -> None:
38
+ """Get a certification by ID."""
39
+ from entropy_data.cli import get_client, get_output_format, handle_error
40
+
41
+ fmt = output or get_output_format()
42
+ try:
43
+ client = get_client()
44
+ data = client.get_resource(RESOURCE_PATH, id)
45
+ print_resource(data, RESOURCE_TYPE, fmt)
46
+ except Exception as e:
47
+ handle_error(e)
48
+
49
+
50
+ @certifications_app.command("put")
51
+ def put_certification(
52
+ id: Annotated[str, typer.Argument(help="Certification ID.")],
53
+ file: Annotated[Path, typer.Option("--file", "-f", help="JSON or YAML file (use - for stdin).")] = ...,
54
+ ) -> None:
55
+ """Create or update a certification."""
56
+ from entropy_data.cli import get_client, handle_error
57
+
58
+ try:
59
+ body = read_body(file)
60
+ client = get_client()
61
+ location = client.put_resource(RESOURCE_PATH, id, body)
62
+ print_success(f"Certification '{id}' saved.")
63
+ print_link(location)
64
+ except Exception as e:
65
+ handle_error(e)
66
+
67
+
68
+ @certifications_app.command("delete")
69
+ def delete_certification(
70
+ id: Annotated[str, typer.Argument(help="Certification ID.")],
71
+ ) -> None:
72
+ """Delete a certification."""
73
+ from entropy_data.cli import get_client, handle_error
74
+
75
+ try:
76
+ client = get_client()
77
+ client.delete_resource(RESOURCE_PATH, id)
78
+ print_success(f"Certification '{id}' deleted.")
79
+ except Exception as e:
80
+ handle_error(e)