everypixel-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.
- everypixel_cli/__init__.py +1 -0
- everypixel_cli/__main__.py +5 -0
- everypixel_cli/application/__init__.py +10 -0
- everypixel_cli/application/models.py +59 -0
- everypixel_cli/application/serialization.py +20 -0
- everypixel_cli/application/services.py +1376 -0
- everypixel_cli/cli.py +1520 -0
- everypixel_cli/client.py +159 -0
- everypixel_cli/config.py +221 -0
- everypixel_cli/errors.py +262 -0
- everypixel_cli/files.py +298 -0
- everypixel_cli/mcp_server.py +828 -0
- everypixel_cli/openapi.py +338 -0
- everypixel_cli/output.py +159 -0
- everypixel_cli/resources/__init__.py +1 -0
- everypixel_cli/resources/openapi.json +5402 -0
- everypixel_cli/schemas.py +846 -0
- everypixel_cli-0.1.0.dist-info/METADATA +454 -0
- everypixel_cli-0.1.0.dist-info/RECORD +22 -0
- everypixel_cli-0.1.0.dist-info/WHEEL +4 -0
- everypixel_cli-0.1.0.dist-info/entry_points.txt +3 -0
- everypixel_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
everypixel_cli/client.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Everypixel API HTTP transport.
|
|
2
|
+
|
|
3
|
+
The client stores connection settings, reuses one httpx connection pool, and
|
|
4
|
+
normalizes remote failures into typed application errors.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .errors import (
|
|
14
|
+
APIRequestError,
|
|
15
|
+
APIResponseError,
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
http_exit_code,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class APIClient:
|
|
22
|
+
"""Thin HTTP API wrapper with Basic Auth and normalized errors."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
base_url: str,
|
|
28
|
+
client_id: str | None,
|
|
29
|
+
client_secret: str | None,
|
|
30
|
+
timeout: float = 30.0,
|
|
31
|
+
):
|
|
32
|
+
self.base_url = base_url.rstrip("/")
|
|
33
|
+
self.client_id = client_id
|
|
34
|
+
self.client_secret = client_secret
|
|
35
|
+
self.timeout = timeout
|
|
36
|
+
self._http = httpx.Client(timeout=timeout, follow_redirects=True)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def auth(self) -> tuple[str, str] | None:
|
|
40
|
+
"""Return a Basic Auth pair or None."""
|
|
41
|
+
|
|
42
|
+
if self.client_id and self.client_secret:
|
|
43
|
+
return self.client_id, self.client_secret
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def request(
|
|
47
|
+
self,
|
|
48
|
+
method: str,
|
|
49
|
+
path: str,
|
|
50
|
+
*,
|
|
51
|
+
json: dict[str, Any] | None = None,
|
|
52
|
+
params: dict[str, Any] | None = None,
|
|
53
|
+
files: dict[str, Any] | None = None,
|
|
54
|
+
auth_required: bool = True,
|
|
55
|
+
) -> Any:
|
|
56
|
+
"""Perform an HTTP request and return an arbitrary decoded JSON value."""
|
|
57
|
+
|
|
58
|
+
if auth_required and not self.auth:
|
|
59
|
+
raise AuthenticationError(
|
|
60
|
+
"Credentials are not configured", code="auth_missing"
|
|
61
|
+
)
|
|
62
|
+
url = self.base_url + (path if path.startswith("/") else f"/{path}")
|
|
63
|
+
try:
|
|
64
|
+
response = self._http.request(
|
|
65
|
+
method,
|
|
66
|
+
url,
|
|
67
|
+
json=json,
|
|
68
|
+
params=drop_none(params),
|
|
69
|
+
files=files,
|
|
70
|
+
auth=self.auth,
|
|
71
|
+
)
|
|
72
|
+
except httpx.TimeoutException as exc:
|
|
73
|
+
raise APIRequestError("API request timed out", code="api_timeout") from exc
|
|
74
|
+
except httpx.RequestError as exc:
|
|
75
|
+
raise APIRequestError(
|
|
76
|
+
"Unable to connect to API", code="network_error"
|
|
77
|
+
) from exc
|
|
78
|
+
if response.status_code >= 400:
|
|
79
|
+
detail = extract_error_detail(response)
|
|
80
|
+
exit_code, code = http_exit_code(response.status_code, detail)
|
|
81
|
+
error_type = (
|
|
82
|
+
AuthenticationError
|
|
83
|
+
if response.status_code in (401, 403)
|
|
84
|
+
else APIRequestError
|
|
85
|
+
)
|
|
86
|
+
raise error_type(
|
|
87
|
+
"API request failed",
|
|
88
|
+
code=code,
|
|
89
|
+
exit_code=exit_code,
|
|
90
|
+
details={
|
|
91
|
+
"status_code": response.status_code,
|
|
92
|
+
"api_message": detail[:500],
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
if not response.content:
|
|
96
|
+
return {}
|
|
97
|
+
try:
|
|
98
|
+
return response.json()
|
|
99
|
+
except ValueError as exc:
|
|
100
|
+
raise APIResponseError("API returned invalid JSON response") from exc
|
|
101
|
+
|
|
102
|
+
def get_status(self, task_id: str) -> Any:
|
|
103
|
+
"""Fetch async task state by task_id."""
|
|
104
|
+
|
|
105
|
+
return self.request(
|
|
106
|
+
"GET",
|
|
107
|
+
"/v1/status",
|
|
108
|
+
params={"task_id": task_id},
|
|
109
|
+
auth_required=False,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def check_auth(self) -> None:
|
|
113
|
+
"""Check that Basic Auth is accepted by the API."""
|
|
114
|
+
|
|
115
|
+
if not self.auth:
|
|
116
|
+
raise AuthenticationError(
|
|
117
|
+
"Credentials are not configured", code="auth_missing"
|
|
118
|
+
)
|
|
119
|
+
self.request("GET", "/v1/auth/check")
|
|
120
|
+
|
|
121
|
+
def close(self) -> None:
|
|
122
|
+
"""Close the shared HTTP connection pool."""
|
|
123
|
+
|
|
124
|
+
self._http.close()
|
|
125
|
+
|
|
126
|
+
def __enter__(self) -> "APIClient":
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def __exit__(self, *_exc_info: Any) -> None:
|
|
130
|
+
self.close()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def drop_none(data: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
134
|
+
"""Drop None values from query params."""
|
|
135
|
+
|
|
136
|
+
if data is None:
|
|
137
|
+
return None
|
|
138
|
+
return {key: value for key, value in data.items() if value is not None}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def extract_error_detail(response: httpx.Response) -> str:
|
|
142
|
+
"""Extract a human-readable error from an API response."""
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
data = response.json()
|
|
146
|
+
except ValueError:
|
|
147
|
+
return response.text
|
|
148
|
+
detail = data.get("detail") or data.get("message") or data.get("error") or data
|
|
149
|
+
return format_error_detail(detail)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def format_error_detail(detail: Any) -> str:
|
|
153
|
+
"""Normalize different API error shapes into one string."""
|
|
154
|
+
|
|
155
|
+
if isinstance(detail, list):
|
|
156
|
+
return "; ".join(format_error_detail(item) for item in detail)
|
|
157
|
+
if isinstance(detail, dict):
|
|
158
|
+
return str(detail.get("message") or detail.get("msg") or detail)
|
|
159
|
+
return str(detail)
|
everypixel_cli/config.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Local CLI configuration and credential storage.
|
|
2
|
+
|
|
3
|
+
The module keeps public client_id values in the JSON profile config and stores
|
|
4
|
+
client_secret in the system keyring when possible. If keyring is unavailable,
|
|
5
|
+
config.json is used as a fallback so the CLI still works in minimal setups.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import keyring
|
|
17
|
+
from platformdirs import user_config_dir
|
|
18
|
+
from pydantic import BaseModel, Field, HttpUrl
|
|
19
|
+
|
|
20
|
+
from .errors import ConfigurationError, FileWriteError, ValidationCLIError, mask_secret
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_URL = "https://api.everypixel.com"
|
|
24
|
+
SERVICE_NAME = "everypixel-cli"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProfileConfig(BaseModel):
|
|
28
|
+
"""Settings for one Everypixel API profile."""
|
|
29
|
+
|
|
30
|
+
base_url: str = DEFAULT_BASE_URL
|
|
31
|
+
client_id: str | None = None
|
|
32
|
+
client_secret: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AppConfig(BaseModel):
|
|
36
|
+
"""Full local CLI configuration."""
|
|
37
|
+
|
|
38
|
+
current_profile: str = "default"
|
|
39
|
+
profiles: dict[str, ProfileConfig] = Field(
|
|
40
|
+
default_factory=lambda: {"default": ProfileConfig()}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def config_dir() -> Path:
|
|
45
|
+
"""Return the user config directory."""
|
|
46
|
+
|
|
47
|
+
return Path(user_config_dir("everypixel-cli", "Everypixel"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def config_path() -> Path:
|
|
51
|
+
"""Return the config.json path."""
|
|
52
|
+
|
|
53
|
+
return config_dir() / "config.json"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_config() -> AppConfig:
|
|
57
|
+
"""Read config.json or return defaults."""
|
|
58
|
+
|
|
59
|
+
path = config_path()
|
|
60
|
+
if not path.exists():
|
|
61
|
+
return AppConfig()
|
|
62
|
+
try:
|
|
63
|
+
return AppConfig.model_validate_json(path.read_text(encoding="utf-8"))
|
|
64
|
+
except (OSError, ValueError) as exc:
|
|
65
|
+
raise ConfigurationError(
|
|
66
|
+
"Unable to read configuration",
|
|
67
|
+
code="config_read_error",
|
|
68
|
+
details={"path": str(path)},
|
|
69
|
+
) from exc
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def save_config(config: AppConfig) -> None:
|
|
73
|
+
"""Save config and try to restrict file permissions."""
|
|
74
|
+
|
|
75
|
+
path = config_path()
|
|
76
|
+
try:
|
|
77
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
path.write_text(
|
|
79
|
+
json.dumps(config.model_dump(), indent=2, ensure_ascii=False),
|
|
80
|
+
encoding="utf-8",
|
|
81
|
+
)
|
|
82
|
+
with suppress(OSError):
|
|
83
|
+
path.chmod(0o600)
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
raise FileWriteError(
|
|
86
|
+
"Unable to write configuration", details={"path": str(path)}
|
|
87
|
+
) from exc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_profile_name(
|
|
91
|
+
config: AppConfig,
|
|
92
|
+
profile: str | None = None,
|
|
93
|
+
dev: bool = False,
|
|
94
|
+
) -> str:
|
|
95
|
+
"""Resolve the active profile name."""
|
|
96
|
+
|
|
97
|
+
env_profile = os.getenv("EVERYPIXEL_PROFILE")
|
|
98
|
+
if dev:
|
|
99
|
+
return "dev"
|
|
100
|
+
return profile or env_profile or config.current_profile
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_or_create_profile(config: AppConfig, name: str) -> ProfileConfig:
|
|
104
|
+
"""Return an existing profile or create it."""
|
|
105
|
+
|
|
106
|
+
if name not in config.profiles:
|
|
107
|
+
config.profiles[name] = ProfileConfig()
|
|
108
|
+
return config.profiles[name]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def keyring_name(profile: str) -> str:
|
|
112
|
+
"""Build a keyring service name for a profile."""
|
|
113
|
+
|
|
114
|
+
return f"{SERVICE_NAME}:{profile}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def save_credentials(profile: str, client_id: str, client_secret: str) -> bool:
|
|
118
|
+
"""Save profile credentials.
|
|
119
|
+
|
|
120
|
+
Returns True when the secret was stored in keyring. False means the secret
|
|
121
|
+
was stored in config.json as a fallback.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
config = load_config()
|
|
125
|
+
profile_config = get_or_create_profile(config, profile)
|
|
126
|
+
profile_config.client_id = client_id
|
|
127
|
+
profile_config.client_secret = None
|
|
128
|
+
try:
|
|
129
|
+
keyring.set_password(keyring_name(profile), client_id, client_secret)
|
|
130
|
+
keyring_ok = True
|
|
131
|
+
except Exception: # noqa: BLE001
|
|
132
|
+
profile_config.client_secret = client_secret
|
|
133
|
+
keyring_ok = False
|
|
134
|
+
save_config(config)
|
|
135
|
+
return keyring_ok
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def delete_credentials(profile: str) -> None:
|
|
139
|
+
"""Delete profile client_id/client_secret from config and keyring."""
|
|
140
|
+
|
|
141
|
+
config = load_config()
|
|
142
|
+
profile_config = get_or_create_profile(config, profile)
|
|
143
|
+
client_id = profile_config.client_id
|
|
144
|
+
if client_id:
|
|
145
|
+
# Keyring backends differ in how they report an already-missing entry.
|
|
146
|
+
with suppress(Exception):
|
|
147
|
+
keyring.delete_password(keyring_name(profile), client_id)
|
|
148
|
+
profile_config.client_id = None
|
|
149
|
+
profile_config.client_secret = None
|
|
150
|
+
save_config(config)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def resolved_settings(
|
|
154
|
+
profile: str | None = None,
|
|
155
|
+
dev: bool = False,
|
|
156
|
+
base_url: str | None = None,
|
|
157
|
+
) -> dict[str, Any]:
|
|
158
|
+
"""Resolve effective settings for one run.
|
|
159
|
+
|
|
160
|
+
Source priority is CLI options, environment variables, then saved profile.
|
|
161
|
+
The secret comes from env/config or is fetched from keyring.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
config = load_config()
|
|
165
|
+
profile_name = resolve_profile_name(config, profile=profile, dev=dev)
|
|
166
|
+
profile_config = get_or_create_profile(config, profile_name)
|
|
167
|
+
|
|
168
|
+
client_id = os.getenv("EVERYPIXEL_CLIENT_ID") or profile_config.client_id
|
|
169
|
+
client_secret = (
|
|
170
|
+
os.getenv("EVERYPIXEL_CLIENT_SECRET") or profile_config.client_secret
|
|
171
|
+
)
|
|
172
|
+
if client_id and not client_secret:
|
|
173
|
+
try:
|
|
174
|
+
client_secret = keyring.get_password(keyring_name(profile_name), client_id)
|
|
175
|
+
except Exception:
|
|
176
|
+
client_secret = None
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
"profile": profile_name,
|
|
180
|
+
"base_url": (
|
|
181
|
+
base_url or os.getenv("EVERYPIXEL_BASE_URL") or profile_config.base_url
|
|
182
|
+
),
|
|
183
|
+
"client_id": client_id,
|
|
184
|
+
"client_secret": client_secret,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def set_config_value(profile: str, key: str, value: str) -> None:
|
|
189
|
+
"""Set a supported profile option."""
|
|
190
|
+
|
|
191
|
+
config = load_config()
|
|
192
|
+
profile_config = get_or_create_profile(config, profile)
|
|
193
|
+
if key != "base_url":
|
|
194
|
+
raise ValidationCLIError("Unsupported configuration key", details={"key": key})
|
|
195
|
+
profile_config.base_url = str(HttpUrl(value))
|
|
196
|
+
save_config(config)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def use_profile(profile: str) -> None:
|
|
200
|
+
"""Make a profile active."""
|
|
201
|
+
|
|
202
|
+
config = load_config()
|
|
203
|
+
get_or_create_profile(config, profile)
|
|
204
|
+
config.current_profile = profile
|
|
205
|
+
save_config(config)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def safe_config_payload(config: AppConfig) -> dict[str, Any]:
|
|
209
|
+
"""Return config data without exposing secrets."""
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
"current_profile": config.current_profile,
|
|
213
|
+
"profiles": {
|
|
214
|
+
name: {
|
|
215
|
+
"base_url": profile.base_url,
|
|
216
|
+
"client_id": profile.client_id,
|
|
217
|
+
"client_secret": mask_secret(profile.client_secret),
|
|
218
|
+
}
|
|
219
|
+
for name, profile in config.profiles.items()
|
|
220
|
+
},
|
|
221
|
+
}
|
everypixel_cli/errors.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Application error model and stable CLI exit codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
EXIT_GENERAL = 1
|
|
12
|
+
EXIT_VALIDATION = 2
|
|
13
|
+
EXIT_AUTH = 3
|
|
14
|
+
EXIT_API = 4
|
|
15
|
+
EXIT_FILE = 5
|
|
16
|
+
EXIT_RESULT = 6
|
|
17
|
+
|
|
18
|
+
# Backwards-compatible names used by the existing public Python API.
|
|
19
|
+
EXIT_RATE_LIMIT = EXIT_API
|
|
20
|
+
EXIT_NOT_FOUND = EXIT_API
|
|
21
|
+
EXIT_BILLING = EXIT_API
|
|
22
|
+
EXIT_TIMEOUT = EXIT_API
|
|
23
|
+
EXIT_API_VALIDATION = EXIT_API
|
|
24
|
+
EXIT_TASK_FAILURE = EXIT_API
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EverypixelCLIError(Exception):
|
|
28
|
+
"""Base error rendered consistently by the CLI boundary."""
|
|
29
|
+
|
|
30
|
+
code = "cli_error"
|
|
31
|
+
exit_code = EXIT_GENERAL
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
message: str,
|
|
36
|
+
*,
|
|
37
|
+
code: str | None = None,
|
|
38
|
+
exit_code: int | None = None,
|
|
39
|
+
details: dict[str, Any] | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.message = message
|
|
43
|
+
self.code = code or self.code
|
|
44
|
+
self.exit_code = exit_code if exit_code is not None else self.exit_code
|
|
45
|
+
self.details = safe_details(details or {})
|
|
46
|
+
|
|
47
|
+
def to_payload(self) -> dict[str, Any]:
|
|
48
|
+
"""Return the stable JSON error contract."""
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
"ok": False,
|
|
52
|
+
"error": {
|
|
53
|
+
"type": type(self).__name__,
|
|
54
|
+
"code": self.code,
|
|
55
|
+
"message": self.message,
|
|
56
|
+
"details": self.details,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# CLIError remains an alias so third-party callers and existing tests keep working.
|
|
62
|
+
CLIError = EverypixelCLIError
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ConfigurationError(EverypixelCLIError):
|
|
66
|
+
code = "configuration_error"
|
|
67
|
+
exit_code = EXIT_AUTH
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AuthenticationError(EverypixelCLIError):
|
|
71
|
+
code = "authentication_error"
|
|
72
|
+
exit_code = EXIT_AUTH
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ValidationCLIError(EverypixelCLIError):
|
|
76
|
+
code = "validation_error"
|
|
77
|
+
exit_code = EXIT_VALIDATION
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class InputParsingError(ValidationCLIError):
|
|
81
|
+
code = "input_parsing_error"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class FileReadError(EverypixelCLIError):
|
|
85
|
+
code = "file_read_error"
|
|
86
|
+
exit_code = EXIT_FILE
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class FileWriteError(EverypixelCLIError):
|
|
90
|
+
code = "file_write_error"
|
|
91
|
+
exit_code = EXIT_FILE
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PayloadPreparationError(ValidationCLIError):
|
|
95
|
+
code = "payload_preparation_error"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class APIRequestError(EverypixelCLIError):
|
|
99
|
+
code = "api_request_error"
|
|
100
|
+
exit_code = EXIT_API
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class APIResponseError(EverypixelCLIError):
|
|
104
|
+
code = "api_response_error"
|
|
105
|
+
exit_code = EXIT_API
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TaskPollingError(APIRequestError):
|
|
109
|
+
code = "task_polling_error"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TaskFailedError(EverypixelCLIError):
|
|
113
|
+
"""A task reached the API-level FAILURE terminal state."""
|
|
114
|
+
|
|
115
|
+
code = "task_failed"
|
|
116
|
+
exit_code = EXIT_API
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
message: str,
|
|
121
|
+
*,
|
|
122
|
+
task_id: str,
|
|
123
|
+
status: str = "FAILURE",
|
|
124
|
+
details: dict[str, Any] | None = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
self.task_id = task_id
|
|
127
|
+
self.status = status
|
|
128
|
+
super().__init__(
|
|
129
|
+
message, details={"task_id": task_id, "status": status, **(details or {})}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def to_payload(self) -> dict[str, Any]:
|
|
133
|
+
"""Include task state alongside the shared error contract."""
|
|
134
|
+
|
|
135
|
+
payload = super().to_payload()
|
|
136
|
+
payload["task"] = {"id": self.task_id, "status": self.status}
|
|
137
|
+
return payload
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class DownloadError(FileWriteError):
|
|
141
|
+
code = "download_error"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class JqExpressionError(EverypixelCLIError):
|
|
145
|
+
code = "jq_expression_error"
|
|
146
|
+
exit_code = EXIT_RESULT
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class SerializationError(EverypixelCLIError):
|
|
150
|
+
code = "serialization_error"
|
|
151
|
+
exit_code = EXIT_RESULT
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class UnsupportedResultError(EverypixelCLIError):
|
|
155
|
+
code = "unsupported_result_error"
|
|
156
|
+
exit_code = EXIT_RESULT
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class InternalCLIError(EverypixelCLIError):
|
|
160
|
+
code = "internal_error"
|
|
161
|
+
exit_code = EXIT_GENERAL
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def safe_details(value: dict[str, Any]) -> dict[str, Any]:
|
|
165
|
+
"""Make details JSON-safe and remove credential-like keys."""
|
|
166
|
+
|
|
167
|
+
sensitive = {
|
|
168
|
+
"authorization",
|
|
169
|
+
"client_secret",
|
|
170
|
+
"api_key",
|
|
171
|
+
"secret",
|
|
172
|
+
"token",
|
|
173
|
+
"password",
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
def sanitize(item: Any, key: str | None = None) -> Any:
|
|
177
|
+
if key and key.lower().replace("-", "_") in sensitive:
|
|
178
|
+
return "***"
|
|
179
|
+
if isinstance(item, dict):
|
|
180
|
+
return {str(name): sanitize(raw, str(name)) for name, raw in item.items()}
|
|
181
|
+
if isinstance(item, (list, tuple)):
|
|
182
|
+
return [sanitize(raw) for raw in item]
|
|
183
|
+
if isinstance(item, str) and item.startswith(("http://", "https://")):
|
|
184
|
+
return item.split("?", 1)[0]
|
|
185
|
+
if isinstance(item, (str, int, float, bool)) or item is None:
|
|
186
|
+
return item
|
|
187
|
+
return str(item)
|
|
188
|
+
|
|
189
|
+
return sanitize(value)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def fallback_serialization_error() -> SerializationError:
|
|
193
|
+
"""Return the only error safe to emit when JSON serialization itself fails."""
|
|
194
|
+
|
|
195
|
+
return SerializationError("Unable to serialize CLI response")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def serialize_error(error: EverypixelCLIError) -> str:
|
|
199
|
+
"""Serialize an error without allowing JSON failures to leak a traceback."""
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
return json.dumps(error.to_payload(), ensure_ascii=False)
|
|
203
|
+
except (TypeError, ValueError):
|
|
204
|
+
return json.dumps(fallback_serialization_error().to_payload())
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def http_exit_code(status_code: int, detail: str = "") -> tuple[int, str]:
|
|
208
|
+
"""Map API status codes to stable remote-service categories."""
|
|
209
|
+
|
|
210
|
+
normalized = detail.lower()
|
|
211
|
+
if status_code in (401, 403):
|
|
212
|
+
return EXIT_AUTH, "auth_error"
|
|
213
|
+
if status_code == 404:
|
|
214
|
+
return EXIT_API, "not_found"
|
|
215
|
+
if status_code == 429:
|
|
216
|
+
return EXIT_API, "rate_limit"
|
|
217
|
+
if "billing" in normalized or "quota" in normalized or "limit" in normalized:
|
|
218
|
+
return EXIT_API, "billing_or_limit_error"
|
|
219
|
+
if status_code in (400, 422):
|
|
220
|
+
return EXIT_API, "api_validation_error"
|
|
221
|
+
return EXIT_API, "api_error"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def mask_secret(value: str | None, visible: int = 4) -> str | None:
|
|
225
|
+
"""Mask a secret for safe console output."""
|
|
226
|
+
|
|
227
|
+
if value is None:
|
|
228
|
+
return None
|
|
229
|
+
if len(value) <= visible:
|
|
230
|
+
return "*" * len(value)
|
|
231
|
+
return f"{value[:visible]}{'*' * 8}"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def format_validation_errors(errors: list[Any]) -> str:
|
|
235
|
+
"""Format Pydantic validation errors for users."""
|
|
236
|
+
|
|
237
|
+
messages: list[str] = []
|
|
238
|
+
for item in errors:
|
|
239
|
+
location = ".".join(
|
|
240
|
+
str(part) for part in item.get("loc", ()) if part != "__root__"
|
|
241
|
+
)
|
|
242
|
+
message = str(item.get("msg", "Invalid value"))
|
|
243
|
+
if message.startswith("Value error, "):
|
|
244
|
+
message = message.removeprefix("Value error, ")
|
|
245
|
+
messages.append(f"{location}: {message}" if location else message)
|
|
246
|
+
return "; ".join(messages)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def normalize_exception(exc: Exception) -> EverypixelCLIError:
|
|
250
|
+
"""Convert expected technical exceptions into stable application errors."""
|
|
251
|
+
|
|
252
|
+
if isinstance(exc, EverypixelCLIError):
|
|
253
|
+
return exc
|
|
254
|
+
if isinstance(exc, ValidationError):
|
|
255
|
+
return ValidationCLIError(format_validation_errors(exc.errors()))
|
|
256
|
+
if isinstance(exc, json.JSONDecodeError):
|
|
257
|
+
return InputParsingError("Unable to parse JSON input")
|
|
258
|
+
if isinstance(exc, (FileNotFoundError, PermissionError, OSError)):
|
|
259
|
+
return FileReadError("Unable to access local file")
|
|
260
|
+
if isinstance(exc, ValueError):
|
|
261
|
+
return PayloadPreparationError("Unable to prepare request payload")
|
|
262
|
+
return InternalCLIError("Unexpected internal error")
|