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,338 @@
1
+ """OpenAPI schema support for the generic `run` command.
2
+
3
+ The CLI tries live API schema first, then cache, then the bundled snapshot.
4
+ This keeps `everypixel run ...` usable offline and up to date when API exists.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import time
11
+ from contextlib import suppress
12
+ from dataclasses import dataclass
13
+ from importlib.resources import files
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from jsonschema import Draft202012Validator
18
+ from jsonschema.exceptions import ValidationError as JSONSchemaValidationError
19
+ from jsonschema.exceptions import best_match
20
+ from referencing import Registry, Resource
21
+ from referencing.jsonschema import DRAFT202012
22
+
23
+ from .config import config_dir
24
+ from .errors import CLIError, EXIT_VALIDATION, FileWriteError
25
+
26
+
27
+ SCHEMA_TTL_SECONDS = 24 * 60 * 60
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Operation:
32
+ """Resolved OpenAPI operation: HTTP method, path, and schema fragment."""
33
+
34
+ method: str
35
+ path: str
36
+ schema: dict[str, Any]
37
+ document: dict[str, Any] | None = None
38
+
39
+
40
+ def schema_cache_path(base_url: str) -> Path:
41
+ """Return the OpenAPI schema cache path for a base URL."""
42
+
43
+ safe = base_url.replace("://", "_").replace("/", "_").replace(":", "_")
44
+ return config_dir() / "schemas" / f"{safe}.json"
45
+
46
+
47
+ def read_cached_schema(
48
+ base_url: str, *, ttl_seconds: int = SCHEMA_TTL_SECONDS
49
+ ) -> dict[str, Any] | None:
50
+ """Read a fresh cached schema or return None."""
51
+
52
+ path = schema_cache_path(base_url)
53
+ try:
54
+ if not path.exists():
55
+ return None
56
+ if time.time() - path.stat().st_mtime > ttl_seconds:
57
+ return None
58
+ return json.loads(path.read_text(encoding="utf-8"))
59
+ except (OSError, ValueError):
60
+ return None
61
+
62
+
63
+ def write_cached_schema(base_url: str, schema: dict[str, Any]) -> Path:
64
+ """Save an OpenAPI schema to local cache."""
65
+
66
+ path = schema_cache_path(base_url)
67
+ try:
68
+ path.parent.mkdir(parents=True, exist_ok=True)
69
+ path.write_text(
70
+ json.dumps(schema, ensure_ascii=False, indent=2),
71
+ encoding="utf-8",
72
+ )
73
+ except OSError as exc:
74
+ raise FileWriteError(
75
+ "Unable to write OpenAPI schema cache",
76
+ details={"path": str(path)},
77
+ ) from exc
78
+ return path
79
+
80
+
81
+ def bundled_schema() -> dict[str, Any]:
82
+ """Read the bundled OpenAPI snapshot from package resources."""
83
+
84
+ resource = files("everypixel_cli.resources").joinpath("openapi.json")
85
+ return json.loads(resource.read_text(encoding="utf-8"))
86
+
87
+
88
+ def load_schema(client, *, refresh: bool = False) -> tuple[dict[str, Any], str]:
89
+ """Load schema from cache/live/bundled and return its source."""
90
+
91
+ if not refresh:
92
+ cached = read_cached_schema(client.base_url)
93
+ if cached is not None:
94
+ return cached, "cache"
95
+ try:
96
+ live = client.request("GET", "/v1/openapi.json", auth_required=False)
97
+ except CLIError:
98
+ cached = read_cached_schema(
99
+ client.base_url, ttl_seconds=10 * 365 * 24 * 60 * 60
100
+ )
101
+ if cached is not None:
102
+ return cached, "cache_stale"
103
+ return bundled_schema(), "bundled"
104
+ with suppress(FileWriteError):
105
+ write_cached_schema(client.base_url, live)
106
+ return live, "live"
107
+
108
+
109
+ def refresh_schema(client) -> tuple[dict[str, Any], Path]:
110
+ """Force-load live OpenAPI schema and update cache."""
111
+
112
+ schema = client.request("GET", "/v1/openapi.json", auth_required=False)
113
+ path = write_cached_schema(client.base_url, schema)
114
+ return schema, path
115
+
116
+
117
+ def find_operation(
118
+ schema: dict[str, Any], endpoint: str, requested_method: str | None = None
119
+ ) -> Operation | None:
120
+ """Find an operation by path, short endpoint name, or operationId."""
121
+
122
+ paths = schema.get("paths")
123
+ if not isinstance(paths, dict):
124
+ return None
125
+
126
+ normalized_endpoint = endpoint.strip()
127
+ if normalized_endpoint.startswith("/"):
128
+ candidates = [normalized_endpoint]
129
+ else:
130
+ candidates = [f"/v1/{normalized_endpoint}", normalized_endpoint]
131
+
132
+ requested = requested_method.lower() if requested_method else None
133
+ for path, operations in paths.items():
134
+ if not isinstance(operations, dict):
135
+ continue
136
+ for method, operation_schema in operations.items():
137
+ if requested and method.lower() != requested:
138
+ continue
139
+ if (
140
+ path in candidates
141
+ or path.rstrip("/").split("/")[-1] == normalized_endpoint
142
+ ):
143
+ return Operation(
144
+ method=method.upper(),
145
+ path=path,
146
+ schema=operation_schema,
147
+ document=schema,
148
+ )
149
+ operation_id = (
150
+ operation_schema.get("operationId")
151
+ if isinstance(operation_schema, dict)
152
+ else None
153
+ )
154
+ if operation_id == normalized_endpoint:
155
+ return Operation(
156
+ method=method.upper(),
157
+ path=path,
158
+ schema=operation_schema,
159
+ document=schema,
160
+ )
161
+ return None
162
+
163
+
164
+ def operation_help(
165
+ operation: Operation | None,
166
+ *,
167
+ endpoint: str,
168
+ method: str,
169
+ path: str,
170
+ schema_source: str | None,
171
+ ) -> dict[str, Any]:
172
+ """Build a help payload for a resolved OpenAPI operation."""
173
+
174
+ schema = operation.schema if operation else None
175
+ return {
176
+ "endpoint": endpoint,
177
+ "schema_source": schema_source,
178
+ "method": method,
179
+ "path": path,
180
+ "summary": schema.get("summary") if isinstance(schema, dict) else None,
181
+ "operation_id": schema.get("operationId") if isinstance(schema, dict) else None,
182
+ "parameters": summarize_parameters(schema),
183
+ "request_body": summarize_request_body(schema),
184
+ "schema": schema,
185
+ }
186
+
187
+
188
+ def summarize_parameters(schema: dict[str, Any] | None) -> list[dict[str, Any]]:
189
+ """Simplify OpenAPI parameters for user output."""
190
+
191
+ if not isinstance(schema, dict) or not isinstance(schema.get("parameters"), list):
192
+ return []
193
+ parameters = []
194
+ for item in schema["parameters"]:
195
+ if isinstance(item, dict):
196
+ parameters.append(
197
+ {
198
+ "name": item.get("name"),
199
+ "in": item.get("in"),
200
+ "required": item.get("required", False),
201
+ "schema": item.get("schema"),
202
+ "description": item.get("description"),
203
+ }
204
+ )
205
+ return parameters
206
+
207
+
208
+ def summarize_request_body(schema: dict[str, Any] | None) -> dict[str, Any] | None:
209
+ """Simplify requestBody description for user output."""
210
+
211
+ if not isinstance(schema, dict):
212
+ return None
213
+ request_body = schema.get("requestBody")
214
+ if not isinstance(request_body, dict):
215
+ return None
216
+ content = request_body.get("content")
217
+ if not isinstance(content, dict):
218
+ return {"required": request_body.get("required", False), "content_types": []}
219
+ return {
220
+ "required": request_body.get("required", False),
221
+ "content_types": [
222
+ {
223
+ "content_type": content_type,
224
+ "schema": content_schema.get("schema")
225
+ if isinstance(content_schema, dict)
226
+ else None,
227
+ }
228
+ for content_type, content_schema in content.items()
229
+ ],
230
+ }
231
+
232
+
233
+ def validate_payload_against_operation(
234
+ operation: Operation | None, payload: dict[str, Any]
235
+ ) -> None:
236
+ """Validate a payload against an OpenAPI 3.1 JSON request schema."""
237
+
238
+ if operation is None:
239
+ return
240
+ schema = json_request_body_schema(operation.schema)
241
+ if schema is None:
242
+ return
243
+ validator = openapi_validator(schema, operation.document)
244
+ error = best_match(validator.iter_errors(payload))
245
+ if error is None:
246
+ return
247
+ message, details = format_openapi_validation_error(error)
248
+ raise CLIError(
249
+ message,
250
+ code="validation_error",
251
+ exit_code=EXIT_VALIDATION,
252
+ details=details,
253
+ )
254
+
255
+
256
+ def json_request_body_schema(operation_schema: dict[str, Any]) -> dict[str, Any] | None:
257
+ """Return the application/json schema for an OpenAPI operation."""
258
+
259
+ request_body = operation_schema.get("requestBody")
260
+ if not isinstance(request_body, dict):
261
+ return None
262
+ content = request_body.get("content")
263
+ if not isinstance(content, dict):
264
+ return None
265
+ json_content = content.get("application/json")
266
+ if not isinstance(json_content, dict):
267
+ return None
268
+ schema = json_content.get("schema")
269
+ return schema if isinstance(schema, dict) else None
270
+
271
+
272
+ def openapi_validator(
273
+ schema: dict[str, Any], document: dict[str, Any] | None
274
+ ) -> Draft202012Validator:
275
+ """Build a JSON Schema validator with local OpenAPI references available."""
276
+
277
+ if document is None:
278
+ return Draft202012Validator(schema)
279
+ document_uri = "urn:everypixel:openapi"
280
+ resource = Resource.from_contents(
281
+ document,
282
+ default_specification=DRAFT202012,
283
+ )
284
+ registry = Registry().with_resource(document_uri, resource)
285
+ return Draft202012Validator(
286
+ absolute_document_refs(schema, document_uri),
287
+ registry=registry,
288
+ )
289
+
290
+
291
+ def absolute_document_refs(value: Any, document_uri: str) -> Any:
292
+ """Point fragment refs from a detached request schema at its document."""
293
+
294
+ if isinstance(value, dict):
295
+ result = {
296
+ key: absolute_document_refs(item, document_uri)
297
+ for key, item in value.items()
298
+ }
299
+ ref = result.get("$ref")
300
+ if isinstance(ref, str) and ref.startswith("#/"):
301
+ result["$ref"] = f"{document_uri}{ref}"
302
+ return result
303
+ if isinstance(value, list):
304
+ return [absolute_document_refs(item, document_uri) for item in value]
305
+ return value
306
+
307
+
308
+ def format_openapi_validation_error(
309
+ error: JSONSchemaValidationError,
310
+ ) -> tuple[str, dict[str, Any]]:
311
+ """Return a stable error without echoing arbitrary user payload values."""
312
+
313
+ path = ".".join(str(part) for part in error.absolute_path)
314
+ label = path or "payload"
315
+ rule = str(error.validator)
316
+ if error.validator == "required" and isinstance(error.validator_value, list):
317
+ instance = error.instance if isinstance(error.instance, dict) else {}
318
+ missing = [key for key in error.validator_value if key not in instance]
319
+ message = f"Missing required input(s): {', '.join(map(str, missing))}"
320
+ elif error.validator == "type":
321
+ message = f"Invalid type for input '{label}': expected {error.validator_value}"
322
+ elif error.validator == "enum":
323
+ message = f"Invalid value for input '{label}': expected an allowed enum value"
324
+ elif error.validator in {"oneOf", "anyOf"}:
325
+ message = "Payload does not match an allowed request schema"
326
+ elif error.validator in {
327
+ "minimum",
328
+ "maximum",
329
+ "exclusiveMinimum",
330
+ "exclusiveMaximum",
331
+ }:
332
+ message = f"Input '{label}' is outside the allowed range"
333
+ else:
334
+ message = f"Input '{label}' does not satisfy the OpenAPI schema"
335
+ details = {"rule": rule}
336
+ if path:
337
+ details["path"] = path
338
+ return message, details
@@ -0,0 +1,159 @@
1
+ """CLI output rendering in JSON or human-readable mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+
12
+ from .errors import CLIError, JqExpressionError, SerializationError, serialize_error
13
+
14
+
15
+ def apply_jq(data: Any, expr: str | None) -> Any:
16
+ """Apply a jq expression or simple dot selector fallback."""
17
+
18
+ if not expr:
19
+ return data
20
+ try:
21
+ import jq # type: ignore
22
+ except ModuleNotFoundError:
23
+ return apply_simple_selector(data, expr)
24
+ try:
25
+ return jq.compile(expr).input(data).first()
26
+ except Exception as exc: # jq does not expose stable public exception classes.
27
+ raise JqExpressionError(
28
+ "Unable to apply jq expression", details={"expression": expr}
29
+ ) from exc
30
+
31
+
32
+ def apply_simple_selector(data: Any, expr: str) -> Any:
33
+ """Minimal fallback for `.field.subfield` expressions."""
34
+
35
+ if not expr.startswith("."):
36
+ raise JqExpressionError(
37
+ "Unable to apply jq expression", details={"expression": expr}
38
+ )
39
+ current = data
40
+ try:
41
+ for part in expr[1:].split("."):
42
+ if not part:
43
+ continue
44
+ if not isinstance(current, dict):
45
+ raise TypeError("selector target is not an object")
46
+ current = current[part]
47
+ return current
48
+ except (KeyError, TypeError) as exc:
49
+ raise JqExpressionError(
50
+ "Unable to apply jq expression", details={"expression": expr}
51
+ ) from exc
52
+
53
+
54
+ def emit_json(data: Any, jq_expr: str | None = None) -> None:
55
+ """Print JSON output."""
56
+
57
+ try:
58
+ selected = apply_jq(data, jq_expr)
59
+ print(json.dumps(selected, ensure_ascii=False, indent=2))
60
+ except CLIError:
61
+ raise
62
+ except (TypeError, ValueError) as exc:
63
+ raise SerializationError("Unable to serialize CLI response") from exc
64
+
65
+
66
+ def emit_human(data: Any, *, title: str | None = None, no_color: bool = False) -> None:
67
+ """Print output using Rich tables or a simple fallback."""
68
+
69
+ console = Console(no_color=no_color)
70
+ if title:
71
+ console.print(f"[bold]{title}[/bold]")
72
+ if isinstance(data, dict) and render_known_table(console, data):
73
+ return
74
+ if isinstance(data, dict):
75
+ table = Table(show_header=False, box=None)
76
+ table.add_column("Key", style="cyan")
77
+ table.add_column("Value")
78
+ for key, value in data.items():
79
+ table.add_row(
80
+ str(key),
81
+ json.dumps(value, ensure_ascii=False)
82
+ if isinstance(value, (dict, list))
83
+ else str(value),
84
+ )
85
+ console.print(table)
86
+ else:
87
+ console.print(data)
88
+
89
+
90
+ def render_known_table(console: Console, data: dict[str, Any]) -> bool:
91
+ """Render specialized tables for known API responses."""
92
+
93
+ if isinstance(data.get("keywords"), list):
94
+ table = Table(title="Keywords")
95
+ table.add_column("Keyword", style="cyan")
96
+ table.add_column("Score", justify="right")
97
+ for item in data["keywords"]:
98
+ if isinstance(item, dict):
99
+ table.add_row(
100
+ str(item.get("keyword", "")), format_score(item.get("score"))
101
+ )
102
+ console.print(table)
103
+ return True
104
+
105
+ if isinstance(data.get("faces"), list):
106
+ table = Table(title="Faces")
107
+ table.add_column("#", justify="right")
108
+ table.add_column("Score", justify="right")
109
+ table.add_column("BBox")
110
+ table.add_column("Age")
111
+ table.add_column("Gender")
112
+ for index, item in enumerate(data["faces"], start=1):
113
+ if isinstance(item, dict):
114
+ table.add_row(
115
+ str(index),
116
+ format_score(item.get("score")),
117
+ json.dumps(item.get("bbox", ""), ensure_ascii=False),
118
+ str(item.get("age", "")),
119
+ str(item.get("gender", "")),
120
+ )
121
+ console.print(table)
122
+ return True
123
+
124
+ if isinstance(data.get("quality"), dict):
125
+ table = Table(title="Quality")
126
+ table.add_column("Metric", style="cyan")
127
+ table.add_column("Value", justify="right")
128
+ for key, value in data["quality"].items():
129
+ table.add_row(
130
+ str(key),
131
+ format_score(value) if isinstance(value, float) else str(value),
132
+ )
133
+ console.print(table)
134
+ return True
135
+
136
+ return False
137
+
138
+
139
+ def format_score(value: Any) -> str:
140
+ """Format score/metric values for human output."""
141
+
142
+ if isinstance(value, float):
143
+ return f"{value:.4f}"
144
+ if value is None:
145
+ return ""
146
+ return str(value)
147
+
148
+
149
+ def emit_error(error: CLIError, *, output_json: bool, no_color: bool = False) -> None:
150
+ """Print a JSON error to stdout or a human error to stderr."""
151
+
152
+ if output_json:
153
+ # JSON output has one machine-readable stream, including failures.
154
+ print(serialize_error(error))
155
+ return
156
+ console = Console(stderr=True, no_color=no_color)
157
+ console.print(Text(f"Error [{error.code}]: {error.message}", style="red"))
158
+ for key, value in error.details.items():
159
+ console.print(f"{key.replace('_', ' ').title()}: {value}")
@@ -0,0 +1 @@
1
+ """Bundled package resources."""