sv-cli 0.3.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.
- sv_cli/__init__.py +3 -0
- sv_cli/adapters.py +323 -0
- sv_cli/api_client.py +82 -0
- sv_cli/commands/__init__.py +0 -0
- sv_cli/commands/auth.py +4 -0
- sv_cli/commands/better_keywords.py +4 -0
- sv_cli/commands/call.py +4 -0
- sv_cli/commands/config.py +4 -0
- sv_cli/commands/content_quality.py +5 -0
- sv_cli/commands/content_transformer.py +4 -0
- sv_cli/commands/core_analysis.py +4 -0
- sv_cli/commands/definitions.py +4 -0
- sv_cli/commands/geo_audit.py +4 -0
- sv_cli/commands/insight_igniter.py +4 -0
- sv_cli/commands/marketplace_services.py +5 -0
- sv_cli/commands/options.py +4 -0
- sv_cli/commands/preliminary_audit.py +4 -0
- sv_cli/commands/ranklens.py +4 -0
- sv_cli/commands/seo_image.py +4 -0
- sv_cli/commands/seo_mapping.py +4 -0
- sv_cli/commands/seogpt.py +4 -0
- sv_cli/commands/seogpt2.py +4 -0
- sv_cli/commands/seogpt_compare.py +4 -0
- sv_cli/commands/top_competitors.py +5 -0
- sv_cli/commands/topical_authority.py +4 -0
- sv_cli/config.py +216 -0
- sv_cli/definitions.py +283 -0
- sv_cli/errors.py +55 -0
- sv_cli/executor.py +188 -0
- sv_cli/formatter.py +227 -0
- sv_cli/main.py +905 -0
- sv_cli/renderers/__init__.py +0 -0
- sv_cli/renderers/csv_renderer.py +9 -0
- sv_cli/renderers/json_renderer.py +10 -0
- sv_cli/renderers/markdown_renderer.py +9 -0
- sv_cli/renderers/table_renderer.py +9 -0
- sv_cli/renderers/text_renderer.py +9 -0
- sv_cli/resolver.py +523 -0
- sv_cli/schemas/__init__.py +0 -0
- sv_cli/schemas/api_response.py +12 -0
- sv_cli/schemas/config.py +13 -0
- sv_cli/schemas/tool_definition.py +17 -0
- sv_cli/tasks.py +168 -0
- sv_cli/utils.py +204 -0
- sv_cli-0.3.0.dist-info/METADATA +338 -0
- sv_cli-0.3.0.dist-info/RECORD +49 -0
- sv_cli-0.3.0.dist-info/WHEEL +4 -0
- sv_cli-0.3.0.dist-info/entry_points.txt +2 -0
- sv_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
sv_cli/resolver.py
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""Human-friendly enum and field resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .adapters import field_candidates
|
|
12
|
+
from .errors import AmbiguousMatchError, InvalidInputError
|
|
13
|
+
from .utils import compact_text, normalize_text, slugify
|
|
14
|
+
|
|
15
|
+
OPTION_CONTAINER_KEYS = {
|
|
16
|
+
"options",
|
|
17
|
+
"values",
|
|
18
|
+
"enum",
|
|
19
|
+
"enums",
|
|
20
|
+
"choices",
|
|
21
|
+
"items",
|
|
22
|
+
"list",
|
|
23
|
+
"data",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
ID_KEYS = ("id", "ID", "Id", "value", "Value", "key", "Key", "code", "Code", "enum", "enum_value")
|
|
27
|
+
LABEL_KEYS = (
|
|
28
|
+
"label",
|
|
29
|
+
"Label",
|
|
30
|
+
"name",
|
|
31
|
+
"Name",
|
|
32
|
+
"title",
|
|
33
|
+
"Title",
|
|
34
|
+
"text",
|
|
35
|
+
"Text",
|
|
36
|
+
"display",
|
|
37
|
+
"Display",
|
|
38
|
+
"description",
|
|
39
|
+
"Description",
|
|
40
|
+
)
|
|
41
|
+
SLUG_KEYS = ("slug", "Slug", "handle", "Handle")
|
|
42
|
+
ALIAS_KEYS = ("aliases", "alias", "synonyms", "shortcuts", "examples")
|
|
43
|
+
PARAM_NAME_KEYS = ("name", "field", "param", "parameter", "key")
|
|
44
|
+
VALID_VALUES_PATTERN = re.compile(r"valid values?\s*:\s*([^\n.]+(?:\.[^\n]*)?)", re.IGNORECASE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Candidate:
|
|
49
|
+
field: str
|
|
50
|
+
id: Any
|
|
51
|
+
label: str
|
|
52
|
+
slug: str
|
|
53
|
+
aliases: tuple[str, ...] = ()
|
|
54
|
+
description: str | None = None
|
|
55
|
+
raw: Any = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def canonical_value(self) -> Any:
|
|
59
|
+
return self.id
|
|
60
|
+
|
|
61
|
+
def searchable_values(self) -> list[str]:
|
|
62
|
+
values = [str(self.id), self.slug, self.label, *self.aliases]
|
|
63
|
+
if self.description:
|
|
64
|
+
values.append(self.description)
|
|
65
|
+
return [v for v in values if v]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def extract_option_sets(definition: Any) -> dict[str, list[Candidate]]:
|
|
69
|
+
"""Discover option/enum lists from a schema-free definitions payload.
|
|
70
|
+
|
|
71
|
+
The SV definitions may evolve. This function deliberately looks for
|
|
72
|
+
common JSON patterns instead of relying on one fixed schema.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
found: dict[str, list[Candidate]] = {}
|
|
76
|
+
|
|
77
|
+
def add(field: str, candidates: Iterable[Candidate]) -> None:
|
|
78
|
+
clean_field = normalize_field(field)
|
|
79
|
+
if not clean_field:
|
|
80
|
+
return
|
|
81
|
+
unique: dict[tuple[str, str, str], Candidate] = {}
|
|
82
|
+
for candidate in candidates:
|
|
83
|
+
key = (str(candidate.id), candidate.slug, candidate.label)
|
|
84
|
+
unique[key] = Candidate(
|
|
85
|
+
field=clean_field,
|
|
86
|
+
id=candidate.id,
|
|
87
|
+
label=candidate.label,
|
|
88
|
+
slug=candidate.slug,
|
|
89
|
+
aliases=candidate.aliases,
|
|
90
|
+
description=candidate.description,
|
|
91
|
+
raw=candidate.raw,
|
|
92
|
+
)
|
|
93
|
+
if not unique:
|
|
94
|
+
return
|
|
95
|
+
existing = found.setdefault(clean_field, [])
|
|
96
|
+
seen = {(str(c.id), c.slug, c.label) for c in existing}
|
|
97
|
+
for key, candidate in unique.items():
|
|
98
|
+
if key not in seen:
|
|
99
|
+
existing.append(candidate)
|
|
100
|
+
|
|
101
|
+
def visit(node: Any, path: list[str]) -> None:
|
|
102
|
+
if isinstance(node, dict):
|
|
103
|
+
# A mapping like {"15": "Meta Description", "32": "Product Description"}.
|
|
104
|
+
mapping_candidates = candidates_from_mapping(path[-1] if path else "options", node)
|
|
105
|
+
if mapping_candidates and len(mapping_candidates) >= 2:
|
|
106
|
+
add(path[-1] if path else "options", mapping_candidates)
|
|
107
|
+
|
|
108
|
+
for key, value in node.items():
|
|
109
|
+
key_str = str(key)
|
|
110
|
+
if isinstance(value, list):
|
|
111
|
+
field = path[-1] if key_str.lower() in OPTION_CONTAINER_KEYS and path else key_str
|
|
112
|
+
candidates = candidates_from_list(field, value)
|
|
113
|
+
if candidates:
|
|
114
|
+
add(field, candidates)
|
|
115
|
+
elif isinstance(value, dict):
|
|
116
|
+
if key_str.lower() in OPTION_CONTAINER_KEYS and path:
|
|
117
|
+
candidates = candidates_from_mapping(path[-1], value)
|
|
118
|
+
if candidates:
|
|
119
|
+
add(path[-1], candidates)
|
|
120
|
+
else:
|
|
121
|
+
nested_candidates = candidates_from_mapping(key_str, value)
|
|
122
|
+
if nested_candidates and len(nested_candidates) >= 2:
|
|
123
|
+
add(key_str, nested_candidates)
|
|
124
|
+
visit(value, [*path, key_str])
|
|
125
|
+
else:
|
|
126
|
+
continue
|
|
127
|
+
elif isinstance(node, list):
|
|
128
|
+
if path:
|
|
129
|
+
candidates = candidates_from_list(path[-1], node)
|
|
130
|
+
if candidates:
|
|
131
|
+
add(path[-1], candidates)
|
|
132
|
+
for item in node:
|
|
133
|
+
visit(item, path)
|
|
134
|
+
|
|
135
|
+
def visit_described_options(node: Any) -> None:
|
|
136
|
+
if isinstance(node, dict):
|
|
137
|
+
field_name = first_present(node, PARAM_NAME_KEYS)
|
|
138
|
+
description = first_present(node, ("description", "Description", "help", "Help"))
|
|
139
|
+
if isinstance(field_name, str):
|
|
140
|
+
if isinstance(description, list):
|
|
141
|
+
described_candidates = candidates_from_labeled_list(field_name, description)
|
|
142
|
+
else:
|
|
143
|
+
described_candidates = candidates_from_valid_values(field_name, description)
|
|
144
|
+
if described_candidates:
|
|
145
|
+
add(field_name, described_candidates)
|
|
146
|
+
for value in node.values():
|
|
147
|
+
visit_described_options(value)
|
|
148
|
+
elif isinstance(node, list):
|
|
149
|
+
for item in node:
|
|
150
|
+
visit_described_options(item)
|
|
151
|
+
|
|
152
|
+
visit(definition, [])
|
|
153
|
+
visit_described_options(definition)
|
|
154
|
+
return found
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def extract_parameter_names(definition: Any) -> set[str]:
|
|
158
|
+
names: set[str] = set()
|
|
159
|
+
|
|
160
|
+
def visit(node: Any, parent_key: str | None = None) -> None:
|
|
161
|
+
if isinstance(node, dict):
|
|
162
|
+
for key, value in node.items():
|
|
163
|
+
key_str = str(key)
|
|
164
|
+
names.add(normalize_field(key_str))
|
|
165
|
+
if key_str in PARAM_NAME_KEYS and isinstance(value, str):
|
|
166
|
+
names.add(normalize_field(value))
|
|
167
|
+
visit(value, key_str)
|
|
168
|
+
elif isinstance(node, list):
|
|
169
|
+
for item in node:
|
|
170
|
+
visit(item, parent_key)
|
|
171
|
+
|
|
172
|
+
target = definition.get("api_input", definition) if isinstance(definition, dict) else definition
|
|
173
|
+
visit(target)
|
|
174
|
+
return {name for name in names if name}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def candidates_from_list(field: str, values: list[Any]) -> list[Candidate]:
|
|
178
|
+
candidates: list[Candidate] = []
|
|
179
|
+
for item in values:
|
|
180
|
+
candidate = candidate_from_item(field, item)
|
|
181
|
+
if candidate:
|
|
182
|
+
candidates.append(candidate)
|
|
183
|
+
return candidates
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def candidates_from_mapping(field: str, mapping: dict[Any, Any]) -> list[Candidate]:
|
|
187
|
+
if not mapping:
|
|
188
|
+
return []
|
|
189
|
+
candidates: list[Candidate] = []
|
|
190
|
+
for key, value in mapping.items():
|
|
191
|
+
# Avoid turning arbitrary schema objects into options.
|
|
192
|
+
if str(key) in {"type", "required", "optional", "parameters", "properties", "description"}:
|
|
193
|
+
return []
|
|
194
|
+
if isinstance(value, (str, int, float, bool)):
|
|
195
|
+
label = str(value)
|
|
196
|
+
candidates.append(
|
|
197
|
+
Candidate(field=field, id=coerce_id(key), label=label, slug=slugify(label), raw={key: value})
|
|
198
|
+
)
|
|
199
|
+
elif isinstance(value, dict):
|
|
200
|
+
candidate = candidate_from_item(field, {"id": key, **value})
|
|
201
|
+
if candidate:
|
|
202
|
+
candidates.append(candidate)
|
|
203
|
+
else:
|
|
204
|
+
return []
|
|
205
|
+
return candidates
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def candidate_from_item(field: str, item: Any) -> Candidate | None:
|
|
209
|
+
if isinstance(item, (str, int, float, bool)):
|
|
210
|
+
label = str(item)
|
|
211
|
+
return Candidate(field=field, id=coerce_id(item), label=label, slug=slugify(label), raw=item)
|
|
212
|
+
if not isinstance(item, dict):
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
# API parameter definition objects such as {"field": "kw", "type": "string",
|
|
216
|
+
# "description": "..."} are not enum candidates. Valid values embedded in
|
|
217
|
+
# descriptions are extracted separately by extract_option_sets().
|
|
218
|
+
lowered_keys = {str(key).lower() for key in item}
|
|
219
|
+
enumish_keys = {"id", "value", "slug", "aliases", "options", "values", "enum", "choices"}
|
|
220
|
+
if "field" in lowered_keys and "type" in lowered_keys and not (lowered_keys & enumish_keys):
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
identifier = first_present(item, ID_KEYS)
|
|
224
|
+
label = first_present(item, LABEL_KEYS)
|
|
225
|
+
slug = first_present(item, SLUG_KEYS)
|
|
226
|
+
description = first_present(item, ("description", "Description", "help", "Help"))
|
|
227
|
+
aliases_raw = first_present(item, ALIAS_KEYS)
|
|
228
|
+
aliases = normalize_aliases(aliases_raw)
|
|
229
|
+
|
|
230
|
+
if identifier is None and slug is not None:
|
|
231
|
+
identifier = slug
|
|
232
|
+
if label is None and slug is not None:
|
|
233
|
+
label = str(slug).replace("-", " ").replace("_", " ").title()
|
|
234
|
+
if label is None and identifier is not None:
|
|
235
|
+
label = str(identifier)
|
|
236
|
+
if identifier is None and label is not None:
|
|
237
|
+
identifier = slugify(label)
|
|
238
|
+
if label is None or identifier is None:
|
|
239
|
+
return None
|
|
240
|
+
slug_value = str(slug) if slug is not None else slugify(label)
|
|
241
|
+
|
|
242
|
+
# Include string identifiers as aliases when they differ from the label/slug.
|
|
243
|
+
extra_aliases = []
|
|
244
|
+
if isinstance(identifier, str) and identifier not in {label, slug_value}:
|
|
245
|
+
extra_aliases.append(identifier)
|
|
246
|
+
all_aliases = tuple(dict.fromkeys([*aliases, *extra_aliases]))
|
|
247
|
+
|
|
248
|
+
return Candidate(
|
|
249
|
+
field=field,
|
|
250
|
+
id=coerce_id(identifier),
|
|
251
|
+
label=str(label),
|
|
252
|
+
slug=slugify(slug_value),
|
|
253
|
+
aliases=all_aliases,
|
|
254
|
+
description=str(description) if description is not None else None,
|
|
255
|
+
raw=item,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def candidates_from_valid_values(field: str, description: Any) -> list[Candidate]:
|
|
260
|
+
text = description_to_text(description)
|
|
261
|
+
if not text:
|
|
262
|
+
return []
|
|
263
|
+
match = VALID_VALUES_PATTERN.search(text)
|
|
264
|
+
if not match:
|
|
265
|
+
return []
|
|
266
|
+
values_text = match.group(1)
|
|
267
|
+
# Stop at the first sentence when the definitions include prose after the list.
|
|
268
|
+
values_text = values_text.split(".", 1)[0]
|
|
269
|
+
values = [part.strip().strip(".:") for part in values_text.split(",")]
|
|
270
|
+
candidates: list[Candidate] = []
|
|
271
|
+
for value in values:
|
|
272
|
+
if not value:
|
|
273
|
+
continue
|
|
274
|
+
candidates.append(Candidate(field=field, id=value, label=value, slug=slugify(value), raw=value))
|
|
275
|
+
return candidates
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def candidates_from_labeled_list(field: str, items: list[Any]) -> list[Candidate]:
|
|
279
|
+
"""Parse ``"id: label"`` strings from an api_input description list."""
|
|
280
|
+
candidates: list[Candidate] = []
|
|
281
|
+
for item in items:
|
|
282
|
+
if not isinstance(item, str):
|
|
283
|
+
continue
|
|
284
|
+
m = re.match(r"^(\S+)\s*:\s*(.+)$", item.strip())
|
|
285
|
+
if not m:
|
|
286
|
+
continue
|
|
287
|
+
id_str, label = m.group(1).strip(), m.group(2).strip()
|
|
288
|
+
candidates.append(Candidate(field=field, id=coerce_id(id_str), label=label, slug=slugify(label), raw=item))
|
|
289
|
+
return candidates if len(candidates) >= 2 else []
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def description_to_text(description: Any) -> str:
|
|
293
|
+
if description is None:
|
|
294
|
+
return ""
|
|
295
|
+
if isinstance(description, str):
|
|
296
|
+
return description
|
|
297
|
+
if isinstance(description, (list, tuple, set)):
|
|
298
|
+
return "\n".join(str(item) for item in description)
|
|
299
|
+
return str(description)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def first_present(mapping: dict[str, Any], keys: Iterable[str]) -> Any:
|
|
303
|
+
for key in keys:
|
|
304
|
+
if key in mapping and mapping[key] not in (None, ""):
|
|
305
|
+
return mapping[key]
|
|
306
|
+
lowered = {str(k).lower(): v for k, v in mapping.items()}
|
|
307
|
+
for key in keys:
|
|
308
|
+
value = lowered.get(key.lower())
|
|
309
|
+
if value not in (None, ""):
|
|
310
|
+
return value
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def normalize_aliases(value: Any) -> tuple[str, ...]:
|
|
315
|
+
if value is None:
|
|
316
|
+
return ()
|
|
317
|
+
if isinstance(value, str):
|
|
318
|
+
# Keep phrases intact where possible, but support comma/pipe-separated aliases.
|
|
319
|
+
parts = [part.strip() for part in value.replace("|", ",").split(",")]
|
|
320
|
+
return tuple(part for part in parts if part)
|
|
321
|
+
if isinstance(value, (list, tuple, set)):
|
|
322
|
+
return tuple(str(item).strip() for item in value if str(item).strip())
|
|
323
|
+
return (str(value),)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def coerce_id(value: Any) -> Any:
|
|
327
|
+
if isinstance(value, str) and value.strip().isdigit():
|
|
328
|
+
return int(value.strip())
|
|
329
|
+
return value
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def normalize_field(field: str) -> str:
|
|
333
|
+
return str(field).strip().replace("-", "_").replace(" ", "_").lower()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def resolve_api_field(tool: str, cli_field: str, definition: Any) -> tuple[str, list[Candidate] | None]:
|
|
337
|
+
"""Choose the best API field name for a friendly CLI field."""
|
|
338
|
+
|
|
339
|
+
option_sets = extract_option_sets(definition)
|
|
340
|
+
parameter_names = extract_parameter_names(definition)
|
|
341
|
+
candidates = field_candidates(tool, cli_field)
|
|
342
|
+
normalized_candidates = [normalize_field(candidate) for candidate in candidates]
|
|
343
|
+
|
|
344
|
+
for candidate in normalized_candidates:
|
|
345
|
+
if candidate in option_sets:
|
|
346
|
+
return candidate, option_sets[candidate]
|
|
347
|
+
for candidate in normalized_candidates:
|
|
348
|
+
if candidate in parameter_names:
|
|
349
|
+
return candidate, option_sets.get(candidate)
|
|
350
|
+
# Fall back by normalized compact comparison.
|
|
351
|
+
compact_candidates = {compact_text(candidate): candidate for candidate in normalized_candidates}
|
|
352
|
+
for existing_field in option_sets:
|
|
353
|
+
if compact_text(existing_field) in compact_candidates:
|
|
354
|
+
return existing_field, option_sets[existing_field]
|
|
355
|
+
for existing_field in parameter_names:
|
|
356
|
+
if compact_text(existing_field) in compact_candidates:
|
|
357
|
+
return existing_field, option_sets.get(existing_field)
|
|
358
|
+
return normalized_candidates[0] if normalized_candidates else normalize_field(cli_field), None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def resolve_enum_value(
|
|
362
|
+
field: str,
|
|
363
|
+
value: Any,
|
|
364
|
+
candidates: list[Candidate],
|
|
365
|
+
*,
|
|
366
|
+
strict: bool = False,
|
|
367
|
+
fuzzy: bool = True,
|
|
368
|
+
non_interactive: bool = False,
|
|
369
|
+
) -> Any:
|
|
370
|
+
"""Resolve a user value into the candidate's canonical API value."""
|
|
371
|
+
|
|
372
|
+
if value is None:
|
|
373
|
+
return None
|
|
374
|
+
if isinstance(value, (list, tuple)):
|
|
375
|
+
return [
|
|
376
|
+
resolve_enum_value(field, item, candidates, strict=strict, fuzzy=fuzzy, non_interactive=non_interactive)
|
|
377
|
+
for item in value
|
|
378
|
+
]
|
|
379
|
+
text = str(value).strip()
|
|
380
|
+
if not text:
|
|
381
|
+
raise InvalidInputError(f'Could not resolve --{field}: empty value.')
|
|
382
|
+
|
|
383
|
+
# 1. Numeric ID exact match.
|
|
384
|
+
if text.isdigit():
|
|
385
|
+
numeric = int(text)
|
|
386
|
+
numeric_matches = [candidate for candidate in candidates if candidate.id == numeric]
|
|
387
|
+
if len(numeric_matches) == 1:
|
|
388
|
+
return numeric_matches[0].canonical_value
|
|
389
|
+
if len(numeric_matches) > 1:
|
|
390
|
+
raise ambiguous_error(field, text, numeric_matches)
|
|
391
|
+
|
|
392
|
+
# 2. Exact slug match.
|
|
393
|
+
slug_matches = [candidate for candidate in candidates if str(candidate.slug) == text]
|
|
394
|
+
if len(slug_matches) == 1:
|
|
395
|
+
return slug_matches[0].canonical_value
|
|
396
|
+
if len(slug_matches) > 1:
|
|
397
|
+
raise ambiguous_error(field, text, slug_matches)
|
|
398
|
+
|
|
399
|
+
# 3. Exact canonical API value match. This covers string-valued enums.
|
|
400
|
+
canonical_matches = [candidate for candidate in candidates if str(candidate.id) == text]
|
|
401
|
+
if len(canonical_matches) == 1:
|
|
402
|
+
return canonical_matches[0].canonical_value
|
|
403
|
+
if len(canonical_matches) > 1:
|
|
404
|
+
raise ambiguous_error(field, text, canonical_matches)
|
|
405
|
+
|
|
406
|
+
if strict:
|
|
407
|
+
allowed = ", ".join(format_candidate(candidate) for candidate in candidates[:10])
|
|
408
|
+
raise InvalidInputError(
|
|
409
|
+
f'Could not resolve --{field} "{text}" in strict mode. Use a numeric ID, exact slug, '
|
|
410
|
+
f"or exact canonical API value. Examples: {allowed}"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# 4. Exact alias/label matches are intentionally non-strict.
|
|
414
|
+
exact_groups = [
|
|
415
|
+
lambda c: text in {str(alias) for alias in c.aliases},
|
|
416
|
+
lambda c: str(c.label) == text,
|
|
417
|
+
]
|
|
418
|
+
for matcher in exact_groups:
|
|
419
|
+
matches = [candidate for candidate in candidates if matcher(candidate)]
|
|
420
|
+
if len(matches) == 1:
|
|
421
|
+
return matches[0].canonical_value
|
|
422
|
+
if len(matches) > 1:
|
|
423
|
+
raise ambiguous_error(field, text, matches)
|
|
424
|
+
|
|
425
|
+
norm = normalize_text(text)
|
|
426
|
+
compact = compact_text(text)
|
|
427
|
+
|
|
428
|
+
# 5. Normalized exact match.
|
|
429
|
+
normalized_matches = [
|
|
430
|
+
candidate
|
|
431
|
+
for candidate in candidates
|
|
432
|
+
if any(normalize_text(value) == norm or compact_text(value) == compact for value in candidate.searchable_values())
|
|
433
|
+
]
|
|
434
|
+
if len(normalized_matches) == 1:
|
|
435
|
+
return normalized_matches[0].canonical_value
|
|
436
|
+
if len(normalized_matches) > 1:
|
|
437
|
+
raise ambiguous_error(field, text, normalized_matches)
|
|
438
|
+
|
|
439
|
+
# 6. Prefix match.
|
|
440
|
+
prefix_matches = [
|
|
441
|
+
candidate
|
|
442
|
+
for candidate in candidates
|
|
443
|
+
if any(normalize_text(value).startswith(norm) or compact_text(value).startswith(compact) for value in candidate.searchable_values())
|
|
444
|
+
]
|
|
445
|
+
if len(prefix_matches) == 1:
|
|
446
|
+
return prefix_matches[0].canonical_value
|
|
447
|
+
if len(prefix_matches) > 1:
|
|
448
|
+
raise ambiguous_error(field, text, prefix_matches)
|
|
449
|
+
|
|
450
|
+
# 7. Contains match.
|
|
451
|
+
contains_matches = [
|
|
452
|
+
candidate
|
|
453
|
+
for candidate in candidates
|
|
454
|
+
if any(norm in normalize_text(value) or compact in compact_text(value) for value in candidate.searchable_values())
|
|
455
|
+
]
|
|
456
|
+
if len(contains_matches) == 1:
|
|
457
|
+
return contains_matches[0].canonical_value
|
|
458
|
+
if len(contains_matches) > 1:
|
|
459
|
+
raise ambiguous_error(field, text, contains_matches)
|
|
460
|
+
|
|
461
|
+
# 8. Fuzzy match.
|
|
462
|
+
if fuzzy:
|
|
463
|
+
scored = sorted(
|
|
464
|
+
((best_score(text, candidate), candidate) for candidate in candidates),
|
|
465
|
+
key=lambda pair: pair[0],
|
|
466
|
+
reverse=True,
|
|
467
|
+
)
|
|
468
|
+
if scored:
|
|
469
|
+
best, best_candidate = scored[0]
|
|
470
|
+
second = scored[1][0] if len(scored) > 1 else 0
|
|
471
|
+
if best >= 95 or (best >= 85 and best - second >= 10):
|
|
472
|
+
return best_candidate.canonical_value
|
|
473
|
+
if best >= 70:
|
|
474
|
+
raise ambiguous_error(field, text, [candidate for score, candidate in scored[:5] if score >= 70])
|
|
475
|
+
|
|
476
|
+
suggestions = ", ".join(format_candidate(candidate) for candidate in candidates[:10])
|
|
477
|
+
raise InvalidInputError(
|
|
478
|
+
f'Could not resolve --{field} "{text}". Try `sv options TOOL {field} --search {text}`. '
|
|
479
|
+
f"Examples: {suggestions}"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def best_score(text: str, candidate: Candidate) -> float:
|
|
484
|
+
norm = normalize_text(text)
|
|
485
|
+
compact = compact_text(text)
|
|
486
|
+
scores = []
|
|
487
|
+
for value in candidate.searchable_values():
|
|
488
|
+
scores.append(100 * difflib.SequenceMatcher(None, norm, normalize_text(value)).ratio())
|
|
489
|
+
scores.append(100 * difflib.SequenceMatcher(None, compact, compact_text(value)).ratio())
|
|
490
|
+
return max(scores or [0.0])
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def ambiguous_error(field: str, value: str, matches: list[Candidate]) -> AmbiguousMatchError:
|
|
494
|
+
lines = [f'Error: Could not resolve --{field} "{value}".', "It matched multiple options:"]
|
|
495
|
+
for index, candidate in enumerate(matches[:10], start=1):
|
|
496
|
+
lines.append(f"{index}. {format_candidate(candidate)}")
|
|
497
|
+
lines.append("Please rerun with one of:")
|
|
498
|
+
for candidate in matches[:5]:
|
|
499
|
+
lines.append(f"--{field} {candidate.slug}")
|
|
500
|
+
return AmbiguousMatchError("\n".join(lines))
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def format_candidate(candidate: Candidate) -> str:
|
|
504
|
+
return f"{candidate.label} {candidate.slug} id: {candidate.id}"
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def search_candidates(candidates: list[Candidate], query: str | None) -> list[Candidate]:
|
|
508
|
+
if not query:
|
|
509
|
+
return candidates
|
|
510
|
+
norm = normalize_text(query)
|
|
511
|
+
compact = compact_text(query)
|
|
512
|
+
filtered = []
|
|
513
|
+
for candidate in candidates:
|
|
514
|
+
if any(norm in normalize_text(value) or compact in compact_text(value) for value in candidate.searchable_values()):
|
|
515
|
+
filtered.append(candidate)
|
|
516
|
+
if filtered:
|
|
517
|
+
return filtered
|
|
518
|
+
scored = sorted(
|
|
519
|
+
((best_score(query, candidate), candidate) for candidate in candidates),
|
|
520
|
+
key=lambda pair: pair[0],
|
|
521
|
+
reverse=True,
|
|
522
|
+
)
|
|
523
|
+
return [candidate for score, candidate in scored if score >= 60][:20]
|
|
File without changes
|
sv_cli/schemas/config.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProfileConfig(BaseModel):
|
|
7
|
+
api_key: str | None = None
|
|
8
|
+
base_url: str
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CLIConfig(BaseModel):
|
|
12
|
+
default_profile: str = "default"
|
|
13
|
+
profiles: dict[str, ProfileConfig]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Pydantic models used by tests and downstream consumers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ToolDefinition(BaseModel):
|
|
11
|
+
tool: str
|
|
12
|
+
endpoint: str | None = None
|
|
13
|
+
definitions_url: str | None = Field(default=None, alias="definitionsUrl")
|
|
14
|
+
definition: Any = None
|
|
15
|
+
last_fetched: str | None = None
|
|
16
|
+
|
|
17
|
+
model_config = {"populate_by_name": True, "extra": "allow"}
|