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 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