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.
- jentic/apitools/cli/helpers.py +295 -0
- jentic/apitools/cli/main.py +781 -0
- jentic_apitools_cli-0.0.0a4.dist-info/METADATA +233 -0
- jentic_apitools_cli-0.0.0a4.dist-info/RECORD +8 -0
- jentic_apitools_cli-0.0.0a4.dist-info/WHEEL +4 -0
- jentic_apitools_cli-0.0.0a4.dist-info/entry_points.txt +3 -0
- jentic_apitools_cli-0.0.0a4.dist-info/licenses/LICENSE +202 -0
- jentic_apitools_cli-0.0.0a4.dist-info/licenses/NOTICE +4 -0
|
@@ -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
|
+
)
|