jentic-apitools-cli 0.0.0a4__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,295 @@
1
+ """Shared utilities for CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import logging
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+ from urllib.parse import urlparse
11
+
12
+ import click
13
+
14
+
15
+ # Exit codes
16
+ EXIT_SUCCESS = 0
17
+ EXIT_RUNTIME_ERROR = 1
18
+ EXIT_POLICY_FAILURE = 2
19
+
20
+
21
+ def suppress_logging() -> None:
22
+ """Silence the console log handler so only command output reaches stdout."""
23
+ from jentic.apitools.common.utils.logging import configure_root_logger
24
+
25
+ configure_root_logger(level="CRITICAL")
26
+ root = logging.getLogger()
27
+ for handler in root.handlers:
28
+ if isinstance(handler, logging.StreamHandler) and not isinstance(
29
+ handler, logging.FileHandler
30
+ ):
31
+ handler.setLevel(logging.CRITICAL + 1)
32
+
33
+
34
+ def quiet_option(fn):
35
+ """Shared ``-q / --quiet`` Click option decorator."""
36
+
37
+ @click.option(
38
+ "-q",
39
+ "--quiet",
40
+ is_flag=True,
41
+ default=False,
42
+ help="Suppress log output; only emit command results.",
43
+ )
44
+ @functools.wraps(fn)
45
+ def wrapper(*args, quiet: bool = False, **kwargs):
46
+ if quiet:
47
+ suppress_logging()
48
+ return fn(*args, **kwargs)
49
+
50
+ return wrapper
51
+
52
+
53
+ SEVERITY_ORDER: dict[str, int] = {
54
+ "error": 1,
55
+ "warn": 2,
56
+ "warning": 2,
57
+ "info": 3,
58
+ "information": 3,
59
+ "hint": 4,
60
+ }
61
+
62
+
63
+ def resolve_spec_input(
64
+ spec: str,
65
+ stdin_filepath: str | None = None,
66
+ ) -> tuple[str, Path | None]:
67
+ """Resolve the SPEC argument to a spec URL and optional temp file.
68
+
69
+ Returns (spec_url, temp_path). The caller should clean up temp_path
70
+ if it is not None.
71
+ """
72
+ if spec == "-":
73
+ content = sys.stdin.read()
74
+ if not content.strip():
75
+ raise click.BadParameter("No input received from stdin", param_hint="SPEC")
76
+ ext = ".yaml"
77
+ if stdin_filepath:
78
+ ext = Path(stdin_filepath).suffix or ext
79
+ elif content.strip().startswith(("{", "[")):
80
+ ext = ".json"
81
+ tmp = tempfile.NamedTemporaryFile(suffix=ext, prefix="jentic-stdin-", delete=False)
82
+ tmp.write(content.encode("utf-8"))
83
+ tmp.close()
84
+ return Path(tmp.name).as_uri(), Path(tmp.name)
85
+
86
+ parsed = urlparse(spec)
87
+ if parsed.scheme in ("http", "https"):
88
+ return spec, None
89
+
90
+ path = Path(spec).resolve()
91
+ if not path.exists():
92
+ raise click.BadParameter(f"File not found: {spec}", param_hint="SPEC")
93
+ return path.as_uri(), None
94
+
95
+
96
+ def resolve_label(
97
+ label: str | None,
98
+ vendor: str | None,
99
+ api: str | None,
100
+ spec: str,
101
+ ) -> str:
102
+ """Resolve the label from --label, --vendor/--api, or infer from spec.
103
+
104
+ Raises click.UsageError on conflicting or invalid combinations.
105
+ """
106
+ if label and vendor:
107
+ raise click.UsageError("--label and --vendor are mutually exclusive.")
108
+ if api and not vendor:
109
+ raise click.UsageError("--api requires --vendor.")
110
+ if vendor:
111
+ return f"{vendor}/{api or 'main'}"
112
+ if label:
113
+ return label
114
+ return infer_label(spec)
115
+
116
+
117
+ def infer_label(spec: str) -> str:
118
+ """Derive a vendor/api label from a spec path or URL."""
119
+ parsed = urlparse(spec)
120
+ if parsed.scheme in ("http", "https"):
121
+ host = parsed.hostname or "unknown"
122
+ path_parts = [p for p in parsed.path.strip("/").split("/") if p]
123
+ api_name = path_parts[0] if path_parts else "api"
124
+ return f"{host}/{api_name}"
125
+ path = Path(spec)
126
+ return path.stem
127
+
128
+
129
+ def write_output(content: str, output: str | None) -> None:
130
+ """Write content to a file or stdout."""
131
+ if output:
132
+ Path(output).parent.mkdir(parents=True, exist_ok=True)
133
+ Path(output).write_text(content, encoding="utf-8")
134
+ click.echo(f"Output written to {output}", err=True)
135
+ else:
136
+ click.echo(content)
137
+
138
+
139
+ def format_diagnostics_text(diagnostics: list[dict], input_name: str) -> str:
140
+ """Format diagnostics as human-readable text."""
141
+ severity_icons = {
142
+ "error": "E",
143
+ "warning": "W",
144
+ "information": "I",
145
+ "hint": "H",
146
+ }
147
+
148
+ lines: list[str] = []
149
+ lines.append(f"Analysis of {input_name}")
150
+ lines.append("")
151
+
152
+ if not diagnostics:
153
+ lines.append("No issues found.")
154
+ return "\n".join(lines)
155
+
156
+ counts: dict[str, int] = {}
157
+ for d in diagnostics:
158
+ sev = d.get("severity", "unknown")
159
+ counts[sev] = counts.get(sev, 0) + 1
160
+
161
+ for d in diagnostics:
162
+ sev = d.get("severity", "unknown")
163
+ icon = severity_icons.get(sev, "?")
164
+ source = d.get("source", "")
165
+ code = d.get("code", "")
166
+ msg = d.get("message", "")
167
+ prefix = f"[{icon}]"
168
+ source_str = f" ({source})" if source else ""
169
+ code_str = f" {code}:" if code else ""
170
+ lines.append(f" {prefix}{code_str} {msg}{source_str}")
171
+
172
+ lines.append("")
173
+ summary_parts = []
174
+ for sev in ("error", "warning", "information", "hint"):
175
+ if sev in counts:
176
+ summary_parts.append(f"{counts[sev]} {sev}{'s' if counts[sev] != 1 else ''}")
177
+ lines.append(f"Summary: {', '.join(summary_parts)}" if summary_parts else "Summary: no issues")
178
+ return "\n".join(lines)
179
+
180
+
181
+ def format_score_text(scorecard: dict) -> str:
182
+ """Format a scorecard as human-readable text."""
183
+ lines: list[str] = []
184
+
185
+ summary = scorecard.get("summary", {})
186
+ api_meta = scorecard.get("apiMetadata", {})
187
+
188
+ api_name = api_meta.get("name", "Unknown API")
189
+ version = api_meta.get("apiDescriptionVersion", "")
190
+
191
+ lines.append(f"Score for {api_name}" + (f" ({version})" if version else ""))
192
+ lines.append("")
193
+
194
+ score = summary.get("score", "?")
195
+ grade = summary.get("grade", "?")
196
+ level = summary.get("level", "?")
197
+ lines.append(f" Overall: {score}/100 Grade: {grade} Level: {level}")
198
+ lines.append("")
199
+
200
+ dimensions = summary.get("dimensions", [])
201
+ if dimensions:
202
+ lines.append(" Dimensions:")
203
+ for dim in dimensions:
204
+ dim_name = dim.get("name", dim.get("kind", "?"))
205
+ dim_score = dim.get("score", "?")
206
+ dim_grade = dim.get("grade", "?")
207
+ lines.append(f" {dim_name}: {dim_score}/100 ({dim_grade})")
208
+ lines.append("")
209
+
210
+ return "\n".join(lines)
211
+
212
+
213
+ def serialize_diagnostics(diagnostics: list) -> list[dict]:
214
+ """Convert JenticDiagnostic list to JSON-serializable dicts."""
215
+ from jentic.apitools.common.utils.diagnostic_utils import serialize_diagnostic
216
+
217
+ return [serialize_diagnostic(d) for d in diagnostics]
218
+
219
+
220
+ def count_diagnostics_by_severity(diagnostics: list[dict]) -> dict[str, int]:
221
+ """Count diagnostics by severity level."""
222
+ counts: dict[str, int] = {}
223
+ for d in diagnostics:
224
+ sev = d.get("severity", "unknown")
225
+ counts[sev] = counts.get(sev, 0) + 1
226
+ return counts
227
+
228
+
229
+ def check_fail_on(diagnostics: list[dict], fail_on: str) -> bool:
230
+ """Check if any diagnostic severity meets or exceeds the fail_on threshold.
231
+
232
+ Returns True if the policy is violated (should fail).
233
+ """
234
+ if fail_on == "none":
235
+ return False
236
+ threshold = SEVERITY_ORDER.get(fail_on)
237
+ if threshold is None:
238
+ return False
239
+ for d in diagnostics:
240
+ sev = d.get("severity", "unknown")
241
+ sev_order = SEVERITY_ORDER.get(sev, 99)
242
+ if sev_order <= threshold:
243
+ return True
244
+ return False
245
+
246
+
247
+ # Provider name -> (settings attribute, env var hint)
248
+ _PROVIDER_KEY_MAP: dict[str, tuple[str, str]] = {
249
+ "OPENAI": ("openai_api_key", "OPENAI_API_KEY or JENTIC_OPENAI_API_KEY"),
250
+ "CLAUDE": ("anthropic_api_key", "ANTHROPIC_API_KEY or JENTIC_ANTHROPIC_API_KEY"),
251
+ "ANTHROPIC": ("anthropic_api_key", "ANTHROPIC_API_KEY or JENTIC_ANTHROPIC_API_KEY"),
252
+ "GEMINI": ("gemini_api_key", "GEMINI_API_KEY or JENTIC_GEMINI_API_KEY"),
253
+ }
254
+
255
+
256
+ def validate_llm_credentials() -> None:
257
+ """Validate that LLM credentials are available for the configured providers.
258
+
259
+ Checks both the primary provider (LLM_PROVIDER) and the light provider
260
+ (LIGHT_LLM_PROVIDER). Raises click.UsageError with an actionable message
261
+ if required credentials are missing.
262
+ """
263
+ from jentic.apitools.common.settings import get_settings
264
+
265
+ llm_settings = get_settings().llm
266
+ providers_to_check = {llm_settings.provider.upper(), llm_settings.light_provider.upper()}
267
+
268
+ missing: list[str] = []
269
+ for provider in sorted(providers_to_check):
270
+ if provider == "BEDROCK":
271
+ try:
272
+ import boto3 # noqa: F401
273
+ except ImportError:
274
+ missing.append(
275
+ f" {provider}: boto3 is not installed. Install the 'bedrock' or 'all' extra."
276
+ )
277
+ continue
278
+
279
+ key_info = _PROVIDER_KEY_MAP.get(provider)
280
+ if key_info is None:
281
+ missing.append(f" {provider}: unknown provider")
282
+ continue
283
+
284
+ attr_name, env_hint = key_info
285
+ if not getattr(llm_settings, attr_name, None):
286
+ missing.append(f" {provider}: set {env_hint} in your environment or .env file")
287
+
288
+ if missing:
289
+ details = "\n".join(missing)
290
+ raise click.UsageError(
291
+ f"LLM analysis requires credentials for the configured provider(s), "
292
+ f"but the following are missing:\n{details}\n\n"
293
+ f"Current configuration: LLM_PROVIDER={llm_settings.provider}, "
294
+ f"LIGHT_LLM_PROVIDER={llm_settings.light_provider}"
295
+ )