valdtech-cli 0.1.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.
- valdtech_cli/__init__.py +3 -0
- valdtech_cli/__main__.py +4 -0
- valdtech_cli/auth.py +201 -0
- valdtech_cli/cli.py +738 -0
- valdtech_cli/config.py +196 -0
- valdtech_cli/errors.py +49 -0
- valdtech_cli/http.py +145 -0
- valdtech_cli/output.py +76 -0
- valdtech_cli-0.1.0.dist-info/METADATA +10 -0
- valdtech_cli-0.1.0.dist-info/RECORD +12 -0
- valdtech_cli-0.1.0.dist-info/WHEEL +4 -0
- valdtech_cli-0.1.0.dist-info/entry_points.txt +2 -0
valdtech_cli/config.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import tempfile
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
from .errors import CliError, ExitCode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class Context:
|
|
17
|
+
name: str
|
|
18
|
+
api_url: str
|
|
19
|
+
issuer: str
|
|
20
|
+
client_id: str
|
|
21
|
+
scopes: tuple[str, ...]
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_dict(cls, name: str, value: dict[str, Any]) -> "Context":
|
|
25
|
+
return cls(
|
|
26
|
+
name=name,
|
|
27
|
+
api_url=_validated_base_url(value.get("apiUrl"), "apiUrl"),
|
|
28
|
+
issuer=_validated_base_url(value.get("issuer"), "issuer"),
|
|
29
|
+
client_id=_required_string(value.get("clientId"), "clientId"),
|
|
30
|
+
scopes=tuple(_required_string(item, "scope") for item in value.get("scopes", [])),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def public_dict(self) -> dict[str, Any]:
|
|
34
|
+
return {
|
|
35
|
+
"name": self.name,
|
|
36
|
+
"apiUrl": self.api_url,
|
|
37
|
+
"issuer": self.issuer,
|
|
38
|
+
"clientId": self.client_id,
|
|
39
|
+
"scopes": list(self.scopes),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class AccessToken:
|
|
45
|
+
value: str
|
|
46
|
+
expires_at: datetime
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def expired(self) -> bool:
|
|
50
|
+
return self.expires_at <= datetime.now(timezone.utc)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ConfigStore:
|
|
54
|
+
def __init__(self, root: Path | None = None):
|
|
55
|
+
self.root = root or _default_config_root()
|
|
56
|
+
self.config_path = self.root / "contexts.json"
|
|
57
|
+
self.token_root = self.root / "tokens"
|
|
58
|
+
|
|
59
|
+
def use_context(self, context: Context | None, name: str) -> Context:
|
|
60
|
+
document = self._load_document()
|
|
61
|
+
contexts = document.setdefault("contexts", {})
|
|
62
|
+
if context is not None:
|
|
63
|
+
contexts[name] = {
|
|
64
|
+
"apiUrl": context.api_url,
|
|
65
|
+
"issuer": context.issuer,
|
|
66
|
+
"clientId": context.client_id,
|
|
67
|
+
"scopes": list(context.scopes),
|
|
68
|
+
}
|
|
69
|
+
if name not in contexts:
|
|
70
|
+
raise CliError(
|
|
71
|
+
f"Context '{name}' does not exist; provide its connection options first.",
|
|
72
|
+
ExitCode.VALIDATION,
|
|
73
|
+
"context-not-found",
|
|
74
|
+
)
|
|
75
|
+
document["currentContext"] = name
|
|
76
|
+
self._write_json(self.config_path, document)
|
|
77
|
+
return Context.from_dict(name, contexts[name])
|
|
78
|
+
|
|
79
|
+
def current_context(self, override: str | None = None) -> Context:
|
|
80
|
+
document = self._load_document()
|
|
81
|
+
name = override or document.get("currentContext")
|
|
82
|
+
if not name:
|
|
83
|
+
raise CliError(
|
|
84
|
+
"No current context. Run 'valdtechctl context use' first.",
|
|
85
|
+
ExitCode.VALIDATION,
|
|
86
|
+
"context-required",
|
|
87
|
+
)
|
|
88
|
+
value = document.get("contexts", {}).get(name)
|
|
89
|
+
if value is None:
|
|
90
|
+
raise CliError("The current context is invalid.", ExitCode.VALIDATION, "context-invalid")
|
|
91
|
+
return Context.from_dict(name, value)
|
|
92
|
+
|
|
93
|
+
def list_contexts(self) -> tuple[str | None, list[Context]]:
|
|
94
|
+
document = self._load_document()
|
|
95
|
+
contexts = [Context.from_dict(name, value) for name, value in document.get("contexts", {}).items()]
|
|
96
|
+
return document.get("currentContext"), sorted(contexts, key=lambda item: item.name)
|
|
97
|
+
|
|
98
|
+
def save_token(self, context_name: str, value: str, expires_in: int) -> AccessToken:
|
|
99
|
+
if not value or expires_in <= 0:
|
|
100
|
+
raise CliError("The token response is invalid.", ExitCode.AUTHENTICATION, "invalid-token-response")
|
|
101
|
+
expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
|
102
|
+
self._write_json(
|
|
103
|
+
self.token_root / f"{_safe_context_filename(context_name)}.json",
|
|
104
|
+
{"accessToken": value, "expiresAt": expires_at.isoformat()},
|
|
105
|
+
)
|
|
106
|
+
return AccessToken(value, expires_at)
|
|
107
|
+
|
|
108
|
+
def load_token(self, context_name: str) -> AccessToken | None:
|
|
109
|
+
environment_token = os.environ.get("VALDTECH_ACCESS_TOKEN")
|
|
110
|
+
if environment_token:
|
|
111
|
+
return AccessToken(environment_token, datetime.max.replace(tzinfo=timezone.utc))
|
|
112
|
+
path = self.token_root / f"{_safe_context_filename(context_name)}.json"
|
|
113
|
+
if not path.exists():
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
117
|
+
token = _required_string(document.get("accessToken"), "accessToken")
|
|
118
|
+
expires_at = datetime.fromisoformat(_required_string(document.get("expiresAt"), "expiresAt"))
|
|
119
|
+
if expires_at.tzinfo is None:
|
|
120
|
+
raise ValueError("timezone required")
|
|
121
|
+
return AccessToken(token, expires_at.astimezone(timezone.utc))
|
|
122
|
+
except (OSError, ValueError, TypeError, json.JSONDecodeError) as error:
|
|
123
|
+
raise CliError("Stored authentication state is invalid.", ExitCode.AUTHENTICATION, "token-invalid") from error
|
|
124
|
+
|
|
125
|
+
def _load_document(self) -> dict[str, Any]:
|
|
126
|
+
if not self.config_path.exists():
|
|
127
|
+
return {"contractVersion": "valdtechctl.context.v1", "currentContext": None, "contexts": {}}
|
|
128
|
+
try:
|
|
129
|
+
document = json.loads(self.config_path.read_text(encoding="utf-8"))
|
|
130
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
131
|
+
raise CliError("The context file is invalid.", ExitCode.VALIDATION, "context-file-invalid") from error
|
|
132
|
+
if document.get("contractVersion") != "valdtechctl.context.v1" or not isinstance(document.get("contexts"), dict):
|
|
133
|
+
raise CliError("The context file version is unsupported.", ExitCode.VALIDATION, "context-version-unsupported")
|
|
134
|
+
return document
|
|
135
|
+
|
|
136
|
+
def _write_json(self, path: Path, value: dict[str, Any]) -> None:
|
|
137
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
138
|
+
os.chmod(path.parent, 0o700)
|
|
139
|
+
payload = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode()
|
|
140
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
141
|
+
try:
|
|
142
|
+
os.fchmod(descriptor, 0o600)
|
|
143
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
144
|
+
output.write(payload)
|
|
145
|
+
output.flush()
|
|
146
|
+
os.fsync(output.fileno())
|
|
147
|
+
os.replace(temporary_name, path)
|
|
148
|
+
os.chmod(path, 0o600)
|
|
149
|
+
except BaseException:
|
|
150
|
+
try:
|
|
151
|
+
os.unlink(temporary_name)
|
|
152
|
+
except FileNotFoundError:
|
|
153
|
+
pass
|
|
154
|
+
raise
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_context(name: str, api_url: str, issuer: str, client_id: str, scopes: list[str]) -> Context:
|
|
158
|
+
if not scopes or len(scopes) > 32:
|
|
159
|
+
raise CliError("Between 1 and 32 scopes are required.", ExitCode.VALIDATION, "scope-required")
|
|
160
|
+
return Context(
|
|
161
|
+
name=_required_string(name, "name"),
|
|
162
|
+
api_url=_validated_base_url(api_url, "apiUrl"),
|
|
163
|
+
issuer=_validated_base_url(issuer, "issuer"),
|
|
164
|
+
client_id=_required_string(client_id, "clientId"),
|
|
165
|
+
scopes=tuple(dict.fromkeys(_required_string(scope, "scope") for scope in scopes)),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _default_config_root() -> Path:
|
|
170
|
+
explicit = os.environ.get("VALDTECH_CONFIG_HOME")
|
|
171
|
+
if explicit:
|
|
172
|
+
return Path(explicit).expanduser()
|
|
173
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
174
|
+
return (Path(xdg).expanduser() if xdg else Path.home() / ".config") / "valdtech"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _validated_base_url(value: Any, field: str) -> str:
|
|
178
|
+
text = _required_string(value, field).rstrip("/")
|
|
179
|
+
parsed = urlsplit(text)
|
|
180
|
+
local_http = parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"}
|
|
181
|
+
if (parsed.scheme != "https" and not local_http) or not parsed.netloc:
|
|
182
|
+
raise CliError(f"{field} must use HTTPS (HTTP is allowed only for loopback).", ExitCode.VALIDATION, "unsafe-url")
|
|
183
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
184
|
+
raise CliError(f"{field} must not contain credentials, a query, or a fragment.", ExitCode.VALIDATION, "unsafe-url")
|
|
185
|
+
return text
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _required_string(value: Any, field: str) -> str:
|
|
189
|
+
if not isinstance(value, str) or not value.strip() or len(value) > 2048:
|
|
190
|
+
raise CliError(f"{field} is required and must be bounded.", ExitCode.VALIDATION, "invalid-value")
|
|
191
|
+
return value.strip()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _safe_context_filename(name: str) -> str:
|
|
195
|
+
digest = __import__("hashlib").sha256(name.encode()).hexdigest()[:16]
|
|
196
|
+
return f"context-{digest}"
|
valdtech_cli/errors.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ExitCode(IntEnum):
|
|
9
|
+
SUCCESS = 0
|
|
10
|
+
USAGE = 2
|
|
11
|
+
VALIDATION = 10
|
|
12
|
+
AUTHENTICATION = 11
|
|
13
|
+
CONFLICT = 12
|
|
14
|
+
PARTIAL_EXTERNAL_EFFECT = 13
|
|
15
|
+
SERVER_FAILURE = 14
|
|
16
|
+
NETWORK_FAILURE = 15
|
|
17
|
+
CANCELLED = 16
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class CliError(Exception):
|
|
22
|
+
message: str
|
|
23
|
+
exit_code: ExitCode
|
|
24
|
+
code: str
|
|
25
|
+
details: Any = None
|
|
26
|
+
|
|
27
|
+
def __str__(self) -> str:
|
|
28
|
+
return self.message
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ApiError(CliError):
|
|
32
|
+
def __init__(self, message: str, status: int, details: Any = None):
|
|
33
|
+
super().__init__(
|
|
34
|
+
message=message,
|
|
35
|
+
exit_code=_exit_code_for_status(status),
|
|
36
|
+
code=f"http-{status}",
|
|
37
|
+
details=details,
|
|
38
|
+
)
|
|
39
|
+
self.status = status
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _exit_code_for_status(status: int) -> ExitCode:
|
|
43
|
+
if status in (401, 403):
|
|
44
|
+
return ExitCode.AUTHENTICATION
|
|
45
|
+
if status in (409, 410):
|
|
46
|
+
return ExitCode.CONFLICT
|
|
47
|
+
if status in (400, 404, 422):
|
|
48
|
+
return ExitCode.VALIDATION
|
|
49
|
+
return ExitCode.SERVER_FAILURE
|
valdtech_cli/http.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import json
|
|
5
|
+
import socket
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.error import HTTPError, URLError
|
|
8
|
+
from urllib.parse import urlencode, urljoin
|
|
9
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .errors import ApiError, CliError, ExitCode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class HttpResponse:
|
|
20
|
+
status: int
|
|
21
|
+
content_type: str
|
|
22
|
+
body: bytes
|
|
23
|
+
|
|
24
|
+
def json(self) -> Any:
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(self.body.decode("utf-8"))
|
|
27
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
28
|
+
raise CliError("The server returned invalid JSON.", ExitCode.SERVER_FAILURE, "invalid-server-json") from error
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _NoRedirectHandler(HTTPRedirectHandler):
|
|
32
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HttpClient:
|
|
37
|
+
def __init__(self, base_url: str, timeout: float, access_token: str | None = None):
|
|
38
|
+
self.base_url = base_url.rstrip("/") + "/"
|
|
39
|
+
self.timeout = timeout
|
|
40
|
+
self.access_token = access_token
|
|
41
|
+
self._opener = build_opener(_NoRedirectHandler())
|
|
42
|
+
|
|
43
|
+
def get(self, path_or_url: str, *, authenticated: bool = True) -> HttpResponse:
|
|
44
|
+
return self._send("GET", path_or_url, None, authenticated=authenticated)
|
|
45
|
+
|
|
46
|
+
def post_json(
|
|
47
|
+
self,
|
|
48
|
+
path_or_url: str,
|
|
49
|
+
value: Any,
|
|
50
|
+
*,
|
|
51
|
+
headers: dict[str, str] | None = None,
|
|
52
|
+
authenticated: bool = True,
|
|
53
|
+
sensitive_values: tuple[str, ...] = (),
|
|
54
|
+
) -> HttpResponse:
|
|
55
|
+
payload = json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
56
|
+
return self._send(
|
|
57
|
+
"POST",
|
|
58
|
+
path_or_url,
|
|
59
|
+
payload,
|
|
60
|
+
content_type="application/json",
|
|
61
|
+
headers=headers,
|
|
62
|
+
authenticated=authenticated,
|
|
63
|
+
sensitive_values=sensitive_values,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def post_form(
|
|
67
|
+
self,
|
|
68
|
+
path_or_url: str,
|
|
69
|
+
value: dict[str, str],
|
|
70
|
+
*,
|
|
71
|
+
sensitive_values: tuple[str, ...] = (),
|
|
72
|
+
) -> HttpResponse:
|
|
73
|
+
payload = urlencode(value).encode("ascii")
|
|
74
|
+
return self._send(
|
|
75
|
+
"POST",
|
|
76
|
+
path_or_url,
|
|
77
|
+
payload,
|
|
78
|
+
content_type="application/x-www-form-urlencoded",
|
|
79
|
+
authenticated=False,
|
|
80
|
+
sensitive_values=sensitive_values,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def _send(
|
|
84
|
+
self,
|
|
85
|
+
method: str,
|
|
86
|
+
path_or_url: str,
|
|
87
|
+
payload: bytes | None,
|
|
88
|
+
*,
|
|
89
|
+
content_type: str | None = None,
|
|
90
|
+
headers: dict[str, str] | None = None,
|
|
91
|
+
authenticated: bool,
|
|
92
|
+
sensitive_values: tuple[str, ...] = (),
|
|
93
|
+
) -> HttpResponse:
|
|
94
|
+
url = path_or_url if "://" in path_or_url else urljoin(self.base_url, path_or_url.lstrip("/"))
|
|
95
|
+
request_headers = {"Accept": "application/json", "User-Agent": f"valdtechctl/{__version__}"}
|
|
96
|
+
if content_type:
|
|
97
|
+
request_headers["Content-Type"] = content_type
|
|
98
|
+
if authenticated:
|
|
99
|
+
if not self.access_token:
|
|
100
|
+
raise CliError("Authentication is required. Run 'valdtechctl login'.", ExitCode.AUTHENTICATION, "login-required")
|
|
101
|
+
request_headers["Authorization"] = f"Bearer {self.access_token}"
|
|
102
|
+
if headers:
|
|
103
|
+
request_headers.update(headers)
|
|
104
|
+
request = Request(url, data=payload, method=method, headers=request_headers)
|
|
105
|
+
try:
|
|
106
|
+
with self._opener.open(request, timeout=self.timeout) as response:
|
|
107
|
+
body = _bounded_read(response)
|
|
108
|
+
return HttpResponse(response.status, response.headers.get_content_type(), body)
|
|
109
|
+
except HTTPError as error:
|
|
110
|
+
body = _bounded_read(error)
|
|
111
|
+
details = _safe_error_details(body, sensitive_values) if error.code < 500 else None
|
|
112
|
+
raise ApiError(f"The server rejected the request with HTTP {error.code}.", error.code, details) from None
|
|
113
|
+
except (URLError, TimeoutError, socket.timeout, OSError) as error:
|
|
114
|
+
raise CliError("The server could not be reached within the configured timeout.", ExitCode.NETWORK_FAILURE, "network-failure") from error
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _bounded_read(response) -> bytes: # noqa: ANN001
|
|
118
|
+
body = response.read(MAX_RESPONSE_BYTES + 1)
|
|
119
|
+
if len(body) > MAX_RESPONSE_BYTES:
|
|
120
|
+
raise CliError("The server response exceeded the client limit.", ExitCode.SERVER_FAILURE, "response-too-large")
|
|
121
|
+
return body
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _safe_error_details(body: bytes, sensitive_values: tuple[str, ...]) -> Any:
|
|
125
|
+
try:
|
|
126
|
+
value = json.loads(body.decode("utf-8"))
|
|
127
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
128
|
+
return None
|
|
129
|
+
return redact(value, tuple(item for item in sensitive_values if item))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def redact(value: Any, sensitive_values: tuple[str, ...]) -> Any:
|
|
133
|
+
if isinstance(value, dict):
|
|
134
|
+
return {
|
|
135
|
+
key: "[REDACTED]" if any(marker in key.lower() for marker in ("token", "secret", "password")) else redact(item, sensitive_values)
|
|
136
|
+
for key, item in value.items()
|
|
137
|
+
}
|
|
138
|
+
if isinstance(value, list):
|
|
139
|
+
return [redact(item, sensitive_values) for item in value]
|
|
140
|
+
if isinstance(value, str):
|
|
141
|
+
result = value
|
|
142
|
+
for secret in sensitive_values:
|
|
143
|
+
result = result.replace(secret, "[REDACTED]")
|
|
144
|
+
return result
|
|
145
|
+
return value
|
valdtech_cli/output.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
CONTRACT_VERSION = "valdtechctl.output.v1"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def emit(command: str, result: Any, output_format: str, *, raw_text: str | None = None) -> None:
|
|
12
|
+
if output_format == "text" and raw_text is not None:
|
|
13
|
+
sys.stdout.write(raw_text)
|
|
14
|
+
return
|
|
15
|
+
envelope = {"contractVersion": CONTRACT_VERSION, "command": command, "ok": True, "result": result}
|
|
16
|
+
_write(envelope, output_format)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def emit_error(command: str, code: str, message: str, details: Any, output_format: str) -> None:
|
|
20
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
21
|
+
if output_format == "text":
|
|
22
|
+
return
|
|
23
|
+
envelope = {
|
|
24
|
+
"contractVersion": CONTRACT_VERSION,
|
|
25
|
+
"command": command,
|
|
26
|
+
"ok": False,
|
|
27
|
+
"error": {"code": code, "message": message, "details": details},
|
|
28
|
+
}
|
|
29
|
+
_write(envelope, output_format)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _write(value: Any, output_format: str) -> None:
|
|
33
|
+
if output_format == "yaml":
|
|
34
|
+
sys.stdout.write(_yaml(value))
|
|
35
|
+
else:
|
|
36
|
+
json.dump(value, sys.stdout, indent=2, sort_keys=True, ensure_ascii=False)
|
|
37
|
+
sys.stdout.write("\n")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _yaml(value: Any, indent: int = 0) -> str:
|
|
41
|
+
prefix = " " * indent
|
|
42
|
+
if isinstance(value, dict):
|
|
43
|
+
lines: list[str] = []
|
|
44
|
+
for key in sorted(value):
|
|
45
|
+
item = value[key]
|
|
46
|
+
safe_key = _yaml_scalar(str(key))
|
|
47
|
+
if isinstance(item, (dict, list)):
|
|
48
|
+
lines.append(f"{prefix}{safe_key}:")
|
|
49
|
+
lines.append(_yaml(item, indent + 2).rstrip("\n"))
|
|
50
|
+
else:
|
|
51
|
+
lines.append(f"{prefix}{safe_key}: {_yaml_scalar(item)}")
|
|
52
|
+
return "\n".join(lines) + "\n"
|
|
53
|
+
if isinstance(value, list):
|
|
54
|
+
if not value:
|
|
55
|
+
return f"{prefix}[]\n"
|
|
56
|
+
lines = []
|
|
57
|
+
for item in value:
|
|
58
|
+
if isinstance(item, (dict, list)):
|
|
59
|
+
lines.append(f"{prefix}-")
|
|
60
|
+
lines.append(_yaml(item, indent + 2).rstrip("\n"))
|
|
61
|
+
else:
|
|
62
|
+
lines.append(f"{prefix}- {_yaml_scalar(item)}")
|
|
63
|
+
return "\n".join(lines) + "\n"
|
|
64
|
+
return f"{prefix}{_yaml_scalar(value)}\n"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _yaml_scalar(value: Any) -> str:
|
|
68
|
+
if value is None:
|
|
69
|
+
return "null"
|
|
70
|
+
if value is True:
|
|
71
|
+
return "true"
|
|
72
|
+
if value is False:
|
|
73
|
+
return "false"
|
|
74
|
+
if isinstance(value, (int, float)):
|
|
75
|
+
return str(value)
|
|
76
|
+
return json.dumps(str(value), ensure_ascii=False)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: valdtech-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Thin, automation-safe client for the Valdtech Provisioning API
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Provides-Extra: workload
|
|
7
|
+
Requires-Dist: cryptography==46.0.7; extra == "workload"
|
|
8
|
+
Description-Content-Type: text/plain
|
|
9
|
+
|
|
10
|
+
Thin, automation-safe client for the Valdtech Provisioning API.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
valdtech_cli-0.1.0.dist-info/METADATA,sha256=NpLVqIIPcIV584jSi-JyepwQNLVnqYVY1eJ2EyYBsak,336
|
|
2
|
+
valdtech_cli-0.1.0.dist-info/WHEEL,sha256=1tgGR2fx_p-bU7vlX210gXT1E28A2BPs8cCHPP8CoXI,95
|
|
3
|
+
valdtech_cli-0.1.0.dist-info/entry_points.txt,sha256=3zxXPQDE8HGVFqry2MMU3pQ8tsdlAKp3qs8zIgMUUmE,54
|
|
4
|
+
valdtech_cli/__init__.py,sha256=fS1d3atNEyjmXfBAyTucQijz7avggMpPCXapoW24qWc,56
|
|
5
|
+
valdtech_cli/__main__.py,sha256=zRDmnd5hoEn16bRoQMzXpk9IPud9uPoJlxY2B2AOzJQ,61
|
|
6
|
+
valdtech_cli/auth.py,sha256=wAUc2_oYtwo-Isu8tjoZrKL62LPWBD7SoD67M-b9Bqg,7936
|
|
7
|
+
valdtech_cli/cli.py,sha256=FLgWi89xJNJ0XnlOvWBlpHoilC9MGb7tD0isZ8RL2sM,31963
|
|
8
|
+
valdtech_cli/config.py,sha256=LqKD2SPhfroUFJy1b6veJkXMK51k8ehWZN9BlaSDngs,8370
|
|
9
|
+
valdtech_cli/errors.py,sha256=D9J7Bju9NWm3XcTgcABbU0D3qiLtcRY9Gncv6rHp8Ys,1127
|
|
10
|
+
valdtech_cli/http.py,sha256=gQvOLbFCg3IkY-T9Rf5ImZhsvCnodEAtQRaMZskWmgY,5411
|
|
11
|
+
valdtech_cli/output.py,sha256=477k3FSPUPbvVblffNSRYxDb95_UJGvJcbL1bXebsFY,2438
|
|
12
|
+
valdtech_cli-0.1.0.dist-info/RECORD,,
|