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.
@@ -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)
@@ -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
+ }
@@ -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")