asp-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.
- asp_cli/__init__.py +1 -0
- asp_cli/api_client.py +118 -0
- asp_cli/config.py +176 -0
- asp_cli/errors.py +26 -0
- asp_cli/main.py +1461 -0
- asp_cli/output.py +66 -0
- asp_cli/py.typed +0 -0
- asp_cli/spec/__init__.py +0 -0
- asp_cli/spec/operations.json +404 -0
- asp_cli-0.1.0.dist-info/METADATA +54 -0
- asp_cli-0.1.0.dist-info/RECORD +14 -0
- asp_cli-0.1.0.dist-info/WHEEL +4 -0
- asp_cli-0.1.0.dist-info/entry_points.txt +2 -0
- asp_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
asp_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
asp_cli/api_client.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .config import redact_secret
|
|
11
|
+
from .errors import (
|
|
12
|
+
CliError,
|
|
13
|
+
EXIT_AUTH,
|
|
14
|
+
EXIT_NETWORK,
|
|
15
|
+
EXIT_NOT_FOUND,
|
|
16
|
+
EXIT_PERMISSION,
|
|
17
|
+
EXIT_SERVER,
|
|
18
|
+
EXIT_USAGE,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AspClient:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
api_url: str,
|
|
27
|
+
api_key: str | None = None,
|
|
28
|
+
verbose: bool = False,
|
|
29
|
+
console: Console | None = None,
|
|
30
|
+
timeout: float = 20.0,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.base_url = _normalize_base_url(api_url)
|
|
33
|
+
self.api_key = api_key
|
|
34
|
+
self.verbose = verbose
|
|
35
|
+
self.console = console or Console(stderr=True)
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
|
|
38
|
+
def health(self) -> dict[str, Any]:
|
|
39
|
+
return self.request("GET", "/api/health/", authenticated=False)
|
|
40
|
+
|
|
41
|
+
def version(self) -> dict[str, Any]:
|
|
42
|
+
return self.request("GET", "/api/agent/v1/version/")
|
|
43
|
+
|
|
44
|
+
def request(self, method: str, path: str, *, authenticated: bool = True, json: Any = None, files: Any = None) -> dict[str, Any]:
|
|
45
|
+
if authenticated and not self.api_key:
|
|
46
|
+
raise CliError("missing_api_key", "API key is required", {}, EXIT_AUTH)
|
|
47
|
+
|
|
48
|
+
headers = {
|
|
49
|
+
"Accept": "application/json",
|
|
50
|
+
"User-Agent": f"asp-cli/{__version__}",
|
|
51
|
+
}
|
|
52
|
+
if authenticated and self.api_key:
|
|
53
|
+
headers["Authorization"] = f"Api-Key {self.api_key}"
|
|
54
|
+
|
|
55
|
+
url = f"{self.base_url}{path}"
|
|
56
|
+
started = time.perf_counter()
|
|
57
|
+
try:
|
|
58
|
+
response = httpx.request(method, url, headers=headers, json=json, files=files, timeout=self.timeout)
|
|
59
|
+
except httpx.HTTPError as exc:
|
|
60
|
+
raise CliError("network_error", f"Unable to reach ASP server: {exc}", {"url": _redact_url(url)}, EXIT_NETWORK) from exc
|
|
61
|
+
|
|
62
|
+
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
63
|
+
if self.verbose:
|
|
64
|
+
self.console.print(f"{method} {path} -> {response.status_code} ({elapsed_ms}ms)", style="dim")
|
|
65
|
+
|
|
66
|
+
if response.status_code >= 400:
|
|
67
|
+
self._raise_http_error(response, path)
|
|
68
|
+
|
|
69
|
+
if not response.content:
|
|
70
|
+
return {}
|
|
71
|
+
try:
|
|
72
|
+
payload = response.json()
|
|
73
|
+
except ValueError as exc:
|
|
74
|
+
raise CliError("invalid_response", "Server returned non-JSON response", {"status_code": response.status_code}, EXIT_SERVER) from exc
|
|
75
|
+
if not isinstance(payload, dict):
|
|
76
|
+
raise CliError("invalid_response", "Server response must be a JSON object", {"status_code": response.status_code}, EXIT_SERVER)
|
|
77
|
+
return payload
|
|
78
|
+
|
|
79
|
+
def _raise_http_error(self, response: httpx.Response, path: str) -> None:
|
|
80
|
+
message = _response_message(response)
|
|
81
|
+
details = {"status_code": response.status_code, "path": path}
|
|
82
|
+
if response.status_code == 400:
|
|
83
|
+
raise CliError("bad_request", message, details, EXIT_USAGE)
|
|
84
|
+
if response.status_code == 401:
|
|
85
|
+
raise CliError("authentication_failed", message, details, EXIT_AUTH)
|
|
86
|
+
if response.status_code == 403:
|
|
87
|
+
raise CliError("permission_denied", message, details, EXIT_PERMISSION)
|
|
88
|
+
if response.status_code == 404:
|
|
89
|
+
raise CliError("not_found", message, details, EXIT_NOT_FOUND)
|
|
90
|
+
raise CliError("server_error", message, details, EXIT_SERVER)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _normalize_base_url(api_url: str) -> str:
|
|
94
|
+
base = api_url.strip().rstrip("/")
|
|
95
|
+
if base.endswith("/api"):
|
|
96
|
+
base = base[:-4]
|
|
97
|
+
if not base:
|
|
98
|
+
raise CliError("missing_api_url", "ASP API URL is required", {}, EXIT_USAGE)
|
|
99
|
+
return base
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _response_message(response: httpx.Response) -> str:
|
|
103
|
+
try:
|
|
104
|
+
payload = response.json()
|
|
105
|
+
except ValueError:
|
|
106
|
+
return response.text.strip() or f"HTTP {response.status_code}"
|
|
107
|
+
if isinstance(payload, dict):
|
|
108
|
+
detail = payload.get("detail")
|
|
109
|
+
if isinstance(detail, str):
|
|
110
|
+
return detail
|
|
111
|
+
error = payload.get("error")
|
|
112
|
+
if isinstance(error, dict) and isinstance(error.get("message"), str):
|
|
113
|
+
return error["message"]
|
|
114
|
+
return f"HTTP {response.status_code}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _redact_url(url: str) -> str:
|
|
118
|
+
return url.replace(redact_secret(url), "****") if "asp_" in url else url
|
asp_cli/config.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .errors import CliError, EXIT_CONFIG
|
|
10
|
+
|
|
11
|
+
GLOBAL_SETTINGS_PATH = Path.home() / ".asp" / "settings.json"
|
|
12
|
+
LOCAL_SETTINGS_DIR = ".asp"
|
|
13
|
+
SETTINGS_FILENAME = "settings.json"
|
|
14
|
+
SUPPORTED_KEYS = {"api_url", "api_key"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ResolvedConfig:
|
|
19
|
+
api_url: str | None
|
|
20
|
+
api_key: str | None
|
|
21
|
+
sources: dict[str, str]
|
|
22
|
+
global_path: Path
|
|
23
|
+
local_path: Path | None
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def has_auth(self) -> bool:
|
|
27
|
+
return bool(self.api_url and self.api_key)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_config(*, cwd: Path | None = None, api_url: str | None = None, api_key: str | None = None) -> ResolvedConfig:
|
|
31
|
+
cwd = (cwd or Path.cwd()).resolve()
|
|
32
|
+
global_settings = read_settings(GLOBAL_SETTINGS_PATH)
|
|
33
|
+
local_path = find_local_settings(cwd)
|
|
34
|
+
local_settings = read_settings(local_path) if local_path else {}
|
|
35
|
+
values: dict[str, Any] = {}
|
|
36
|
+
sources: dict[str, str] = {}
|
|
37
|
+
|
|
38
|
+
_merge(values, sources, global_settings, "global")
|
|
39
|
+
if local_path:
|
|
40
|
+
_merge(values, sources, local_settings, "local")
|
|
41
|
+
_merge(
|
|
42
|
+
values,
|
|
43
|
+
sources,
|
|
44
|
+
{
|
|
45
|
+
"api_url": os.environ.get("ASP_API_URL"),
|
|
46
|
+
"api_key": os.environ.get("ASP_API_KEY"),
|
|
47
|
+
},
|
|
48
|
+
"env",
|
|
49
|
+
)
|
|
50
|
+
_merge(values, sources, {"api_url": api_url, "api_key": api_key}, "flags")
|
|
51
|
+
|
|
52
|
+
return ResolvedConfig(
|
|
53
|
+
api_url=_clean(values.get("api_url")),
|
|
54
|
+
api_key=_clean(values.get("api_key")),
|
|
55
|
+
sources=sources,
|
|
56
|
+
global_path=GLOBAL_SETTINGS_PATH,
|
|
57
|
+
local_path=local_path,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def auth_settings_path(*, local: bool, cwd: Path | None = None) -> Path:
|
|
62
|
+
if local:
|
|
63
|
+
return (cwd or Path.cwd()).resolve() / LOCAL_SETTINGS_DIR / SETTINGS_FILENAME
|
|
64
|
+
return GLOBAL_SETTINGS_PATH
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def save_auth(*, api_url: str, api_key: str, local: bool = False, cwd: Path | None = None) -> Path:
|
|
68
|
+
path = auth_settings_path(local=local, cwd=cwd)
|
|
69
|
+
settings = read_settings(path)
|
|
70
|
+
settings["api_url"] = api_url.rstrip("/")
|
|
71
|
+
settings["api_key"] = api_key
|
|
72
|
+
write_settings(path, settings)
|
|
73
|
+
return path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def clear_auth(*, local: bool = False, cwd: Path | None = None) -> Path:
|
|
77
|
+
path = auth_settings_path(local=local, cwd=cwd)
|
|
78
|
+
settings = read_settings(path)
|
|
79
|
+
settings.pop("api_url", None)
|
|
80
|
+
settings.pop("api_key", None)
|
|
81
|
+
write_settings(path, settings)
|
|
82
|
+
return path
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def set_config_value(key: str, value: str, *, local: bool = False, cwd: Path | None = None) -> Path:
|
|
86
|
+
if key not in SUPPORTED_KEYS:
|
|
87
|
+
raise CliError("invalid_config_key", f"Unsupported config key: {key}", {"supported": sorted(SUPPORTED_KEYS)}, EXIT_CONFIG)
|
|
88
|
+
path = auth_settings_path(local=local, cwd=cwd)
|
|
89
|
+
settings = read_settings(path)
|
|
90
|
+
settings[key] = value.rstrip("/") if key == "api_url" else value
|
|
91
|
+
write_settings(path, settings)
|
|
92
|
+
return path
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_config_value(key: str, *, cwd: Path | None = None, api_url: str | None = None, api_key: str | None = None) -> tuple[str | None, str | None]:
|
|
96
|
+
if key not in SUPPORTED_KEYS:
|
|
97
|
+
raise CliError("invalid_config_key", f"Unsupported config key: {key}", {"supported": sorted(SUPPORTED_KEYS)}, EXIT_CONFIG)
|
|
98
|
+
config = resolve_config(cwd=cwd, api_url=api_url, api_key=api_key)
|
|
99
|
+
return getattr(config, key), config.sources.get(key)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def read_settings(path: Path | None) -> dict[str, Any]:
|
|
103
|
+
if path is None or not path.exists():
|
|
104
|
+
return {}
|
|
105
|
+
try:
|
|
106
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
107
|
+
payload = json.load(handle)
|
|
108
|
+
except json.JSONDecodeError as exc:
|
|
109
|
+
raise CliError("invalid_config", f"Invalid JSON in settings file: {path}", {"path": str(path)}, EXIT_CONFIG) from exc
|
|
110
|
+
if not isinstance(payload, dict):
|
|
111
|
+
raise CliError("invalid_config", f"Settings file must contain a JSON object: {path}", {"path": str(path)}, EXIT_CONFIG)
|
|
112
|
+
return payload
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def write_settings(path: Path, settings: dict[str, Any]) -> None:
|
|
116
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
with path.open("w", encoding="utf-8") as handle:
|
|
118
|
+
json.dump(settings, handle, indent=2, sort_keys=True)
|
|
119
|
+
handle.write("\n")
|
|
120
|
+
_restrict_permissions(path)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def find_local_settings(cwd: Path) -> Path | None:
|
|
124
|
+
git_root = find_git_root(cwd)
|
|
125
|
+
if git_root is None:
|
|
126
|
+
candidate = cwd / LOCAL_SETTINGS_DIR / SETTINGS_FILENAME
|
|
127
|
+
return candidate if candidate.exists() else None
|
|
128
|
+
|
|
129
|
+
current = cwd
|
|
130
|
+
while True:
|
|
131
|
+
candidate = current / LOCAL_SETTINGS_DIR / SETTINGS_FILENAME
|
|
132
|
+
if candidate.exists():
|
|
133
|
+
return candidate
|
|
134
|
+
if current == git_root:
|
|
135
|
+
return None
|
|
136
|
+
current = current.parent
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def find_git_root(cwd: Path) -> Path | None:
|
|
140
|
+
current = cwd
|
|
141
|
+
while True:
|
|
142
|
+
if (current / ".git").exists():
|
|
143
|
+
return current
|
|
144
|
+
if current == current.parent:
|
|
145
|
+
return None
|
|
146
|
+
current = current.parent
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def redact_secret(value: str | None) -> str:
|
|
150
|
+
if not value:
|
|
151
|
+
return ""
|
|
152
|
+
if len(value) <= 8:
|
|
153
|
+
return "****"
|
|
154
|
+
return f"{value[:4]}...{value[-4:]}"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _merge(values: dict[str, Any], sources: dict[str, str], incoming: dict[str, Any], source: str) -> None:
|
|
158
|
+
for key in SUPPORTED_KEYS:
|
|
159
|
+
value = _clean(incoming.get(key))
|
|
160
|
+
if value:
|
|
161
|
+
values[key] = value
|
|
162
|
+
sources[key] = source
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _clean(value: Any) -> str | None:
|
|
166
|
+
if value is None:
|
|
167
|
+
return None
|
|
168
|
+
text = str(value).strip()
|
|
169
|
+
return text or None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _restrict_permissions(path: Path) -> None:
|
|
173
|
+
try:
|
|
174
|
+
os.chmod(path, 0o600)
|
|
175
|
+
except OSError:
|
|
176
|
+
return
|
asp_cli/errors.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
EXIT_USAGE = 2
|
|
8
|
+
EXIT_CONFIG = 3
|
|
9
|
+
EXIT_AUTH = 4
|
|
10
|
+
EXIT_PERMISSION = 5
|
|
11
|
+
EXIT_NOT_FOUND = 6
|
|
12
|
+
EXIT_CONFLICT = 7
|
|
13
|
+
EXIT_VERSION = 8
|
|
14
|
+
EXIT_NETWORK = 70
|
|
15
|
+
EXIT_SERVER = 75
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class CliError(Exception):
|
|
20
|
+
code: str
|
|
21
|
+
message: str
|
|
22
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
exit_code: int = EXIT_USAGE
|
|
24
|
+
|
|
25
|
+
def __str__(self) -> str:
|
|
26
|
+
return self.message
|