skit-cli 0.0.1__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.
- skit/__init__.py +3 -0
- skit/analyzer.py +410 -0
- skit/argspec.py +528 -0
- skit/argstate.py +166 -0
- skit/atomic.py +101 -0
- skit/cli.py +1621 -0
- skit/config.py +255 -0
- skit/editor.py +77 -0
- skit/flows.py +596 -0
- skit/i18n.py +324 -0
- skit/inlineform.py +61 -0
- skit/launcher.py +326 -0
- skit/locales/zh_CN/LC_MESSAGES/skit.mo +0 -0
- skit/locales/zh_TW/LC_MESSAGES/skit.mo +0 -0
- skit/metawriter.py +267 -0
- skit/models.py +154 -0
- skit/paths.py +44 -0
- skit/pep723.py +292 -0
- skit/promptform.py +67 -0
- skit/reconcile.py +321 -0
- skit/shim.py +386 -0
- skit/store.py +566 -0
- skit/theme.py +123 -0
- skit/tokens.py +112 -0
- skit/tui.py +774 -0
- skit/tui_add.py +361 -0
- skit/tui_footer.py +38 -0
- skit/tui_form.py +586 -0
- skit/tui_health.py +150 -0
- skit/tui_prefs.py +175 -0
- skit/tui_settings.py +374 -0
- skit/uvman.py +288 -0
- skit_cli-0.0.1.dist-info/METADATA +199 -0
- skit_cli-0.0.1.dist-info/RECORD +37 -0
- skit_cli-0.0.1.dist-info/WHEEL +4 -0
- skit_cli-0.0.1.dist-info/entry_points.txt +3 -0
- skit_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
skit/__init__.py
ADDED
skit/analyzer.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""Analyzer: candidate-parameter detection at add time (the entry point to Layer 2, the core
|
|
2
|
+
differentiator).
|
|
3
|
+
|
|
4
|
+
The candidate-decision logic here (literal detection, the injectable type domain, main-guard
|
|
5
|
+
scanning) is the reason decision A2 exists: the Phase 3 shim rewrites the AST at run time and must
|
|
6
|
+
share exactly this logic, or a parameter selected at add time might be missed or misclassified at
|
|
7
|
+
run time. This module therefore uses **only the stdlib** and is fully headless, so the shim can
|
|
8
|
+
import or vendor it directly.
|
|
9
|
+
|
|
10
|
+
Detection scope (A4 + C4):
|
|
11
|
+
- Module top-level literal constant assignments (NAME = <str|int|float|bool>, negatives included).
|
|
12
|
+
- The same kind of assignment at the top of an `if __name__ == "__main__":` block (the second most
|
|
13
|
+
common place to hard-code values).
|
|
14
|
+
- Every `input()` call in the file, keyed by **order of appearance** (B1); the prompt is taken from
|
|
15
|
+
the first literal argument.
|
|
16
|
+
- argparse / click / typer import detection -> suggest the L1 args + preset path, no injection.
|
|
17
|
+
- Variable names / prompts containing KEY/TOKEN/SECRET/PASSWORD -> pre-check secret (C3).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import ast
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import TypeGuard
|
|
26
|
+
|
|
27
|
+
# Injectable type domain: the shim's AST substitution only supports these
|
|
28
|
+
# (JSON-representable, literal-reconstructable).
|
|
29
|
+
INJECTABLE_TYPES = ("str", "int", "float", "bool")
|
|
30
|
+
|
|
31
|
+
# Secret pre-check heuristic (matched against the upper-cased variable name / prompt).
|
|
32
|
+
_SECRET_HINTS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "PASSWD")
|
|
33
|
+
|
|
34
|
+
# Detecting these frameworks -> the script already has a proper CLI; suggest the L1 preset path
|
|
35
|
+
# rather than injection.
|
|
36
|
+
_CLI_FRAMEWORKS = ("argparse", "click", "typer", "docopt", "fire")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Candidate:
|
|
41
|
+
"""A candidate parameter. const is keyed by variable name; input by call order (B1/A8)."""
|
|
42
|
+
|
|
43
|
+
kind: str # "const" | "input"
|
|
44
|
+
name: str # const: variable name; input: display name (input-1, input-2, …)
|
|
45
|
+
type: str = "str" # one of INJECTABLE_TYPES
|
|
46
|
+
default: str | int | float | bool | None = None # const: the original value in the source
|
|
47
|
+
prompt: str = "" # input: the literal prompt of input() (if any)
|
|
48
|
+
order: int = -1 # input: which input() call (0-based); -1 for const
|
|
49
|
+
lineno: int = 0
|
|
50
|
+
secret: bool = False # heuristic pre-check, editable during onboarding
|
|
51
|
+
# Demotion signal (UX spec §0): a candidate that *parses* as a constant but whose usage
|
|
52
|
+
# says "not a parameter" — currently only "accumulator" (literal init + AugAssign anywhere,
|
|
53
|
+
# or reassigned inside a loop body). Demoted candidates default to unchecked at onboarding,
|
|
54
|
+
# with the reason surfaced; clean candidates default to checked.
|
|
55
|
+
demoted: bool = False
|
|
56
|
+
demotion: str = "" # symbolic reason id; the UI owns the human wording
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Analysis:
|
|
61
|
+
candidates: list[Candidate] = field(default_factory=list)
|
|
62
|
+
frameworks: list[str] = field(default_factory=list) # detected CLI frameworks
|
|
63
|
+
syntax_error: bool = False
|
|
64
|
+
uses_argv: bool = False # sys.argv appears -> the run form gets a passthrough-args hint
|
|
65
|
+
# Filename-looking string literals passed directly as call arguments (never bound to a
|
|
66
|
+
# name): the "extract this into a named constant to manage it" hint. Capped, deduped,
|
|
67
|
+
# source order. Only literals a cheap deterministic rule can vouch for — nothing else
|
|
68
|
+
# (see the 'RGB' exclusion in the UX spec: no domain-knowledge guesses).
|
|
69
|
+
filename_literals: list[str] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def uses_cli_framework(self) -> bool:
|
|
73
|
+
return bool(self.frameworks)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_secret_name(text: str) -> bool:
|
|
77
|
+
up = text.upper()
|
|
78
|
+
return any(h in up for h in _SECRET_HINTS)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _literal_value(node: ast.expr) -> tuple[bool, str | int | float | bool | None]:
|
|
82
|
+
"""Whether the RHS is an injectable literal. Returns (ok, value).
|
|
83
|
+
|
|
84
|
+
Handles unary +/- forms such as ``-3`` or ``+2.5``.
|
|
85
|
+
"""
|
|
86
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, (str, int, float, bool)):
|
|
87
|
+
return True, node.value
|
|
88
|
+
if (
|
|
89
|
+
isinstance(node, ast.UnaryOp)
|
|
90
|
+
and isinstance(node.op, (ast.USub, ast.UAdd))
|
|
91
|
+
and isinstance(node.operand, ast.Constant)
|
|
92
|
+
and isinstance(node.operand.value, (int, float))
|
|
93
|
+
and not isinstance(node.operand.value, bool)
|
|
94
|
+
):
|
|
95
|
+
v = node.operand.value
|
|
96
|
+
return True, (-v if isinstance(node.op, ast.USub) else v)
|
|
97
|
+
return False, None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _type_name(value: object) -> str:
|
|
101
|
+
# bool is a subclass of int, so it must be checked first.
|
|
102
|
+
if isinstance(value, bool):
|
|
103
|
+
return "bool"
|
|
104
|
+
if isinstance(value, int):
|
|
105
|
+
return "int"
|
|
106
|
+
if isinstance(value, float):
|
|
107
|
+
return "float"
|
|
108
|
+
return "str"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _const_candidates(body: list[ast.stmt]) -> list[Candidate]:
|
|
112
|
+
"""Scan top-level statements for literal constant assignments (single Name target).
|
|
113
|
+
|
|
114
|
+
A name can be bound to more than one injectable literal in the same block (`X = 1` then later
|
|
115
|
+
`X = 2`): a block runs sequentially, so the *last* assignment is the value the name actually
|
|
116
|
+
holds once the block finishes running -- that's the "effective" definition a form default/type
|
|
117
|
+
must agree with. It's also the only sound choice once a single ParamSpec is shared across every
|
|
118
|
+
same-named occurrence: shim._const_targets replaces *every* occurrence of the name with the
|
|
119
|
+
injected value (by design, so a guard-body override also gets the new value), so two
|
|
120
|
+
same-named candidates would make the shim compute the replacement spans twice and corrupt the
|
|
121
|
+
injected source (see the finding this fixes). Keep the *first* occurrence's position in the
|
|
122
|
+
returned list (so candidates still read top-to-bottom like the source), but let a later
|
|
123
|
+
occurrence's data replace it.
|
|
124
|
+
"""
|
|
125
|
+
out: list[Candidate] = []
|
|
126
|
+
index_by_name: dict[str, int] = {}
|
|
127
|
+
for stmt in body:
|
|
128
|
+
target: ast.expr | None = None # pragma: no mutate
|
|
129
|
+
value: ast.expr | None = None # pragma: no mutate
|
|
130
|
+
if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1:
|
|
131
|
+
target, value = stmt.targets[0], stmt.value
|
|
132
|
+
elif isinstance(stmt, ast.AnnAssign) and stmt.value is not None:
|
|
133
|
+
target, value = stmt.target, stmt.value
|
|
134
|
+
if not isinstance(target, ast.Name) or value is None:
|
|
135
|
+
continue
|
|
136
|
+
name = target.id
|
|
137
|
+
if name.startswith("_"):
|
|
138
|
+
continue # conventionally private/internal values; not treated as parameters
|
|
139
|
+
ok, v = _literal_value(value)
|
|
140
|
+
if not ok:
|
|
141
|
+
continue
|
|
142
|
+
candidate = Candidate(
|
|
143
|
+
kind="const",
|
|
144
|
+
name=name,
|
|
145
|
+
type=_type_name(v),
|
|
146
|
+
default=v,
|
|
147
|
+
lineno=stmt.lineno,
|
|
148
|
+
secret=_is_secret_name(name),
|
|
149
|
+
)
|
|
150
|
+
if name in index_by_name:
|
|
151
|
+
out[index_by_name[name]] = candidate # last occurrence's data wins; keep first slot
|
|
152
|
+
else:
|
|
153
|
+
index_by_name[name] = len(out)
|
|
154
|
+
out.append(candidate)
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _is_main_guard(stmt: ast.stmt) -> TypeGuard[ast.If]:
|
|
159
|
+
"""`if __name__ == "__main__":` (including the operands-reversed form)."""
|
|
160
|
+
if not isinstance(stmt, ast.If):
|
|
161
|
+
return False
|
|
162
|
+
test = stmt.test
|
|
163
|
+
if not (
|
|
164
|
+
isinstance(test, ast.Compare) and len(test.ops) == 1 and isinstance(test.ops[0], ast.Eq)
|
|
165
|
+
):
|
|
166
|
+
return False
|
|
167
|
+
sides = [test.left, test.comparators[0]]
|
|
168
|
+
has_name = any(isinstance(s, ast.Name) and s.id == "__name__" for s in sides)
|
|
169
|
+
has_main = any(isinstance(s, ast.Constant) and s.value == "__main__" for s in sides)
|
|
170
|
+
return has_name and has_main
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _literal_prompt(call: ast.Call) -> str:
|
|
174
|
+
"""The literal first-argument string of an input() call, or "" if absent/non-literal.
|
|
175
|
+
|
|
176
|
+
This doubles as the stable match key shim/reconcile prefer over bare call order (3a): a
|
|
177
|
+
dynamic prompt (a variable, an f-string, no argument at all) has no literal text to key on and
|
|
178
|
+
must fall back to "" -- callers then treat that the same as "no stable key available" and fall
|
|
179
|
+
back to positional order.
|
|
180
|
+
"""
|
|
181
|
+
if call.args and isinstance(call.args[0], ast.Constant) and isinstance(call.args[0].value, str):
|
|
182
|
+
return call.args[0].value
|
|
183
|
+
return ""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _input_candidates(tree: ast.Module) -> list[Candidate]:
|
|
187
|
+
"""Every input() call in the file, numbered by order of appearance in the source (B1)."""
|
|
188
|
+
calls: list[ast.Call] = [
|
|
189
|
+
node
|
|
190
|
+
for node in ast.walk(tree)
|
|
191
|
+
if isinstance(node, ast.Call)
|
|
192
|
+
and isinstance(node.func, ast.Name)
|
|
193
|
+
and node.func.id == "input"
|
|
194
|
+
]
|
|
195
|
+
calls.sort(key=lambda c: (c.lineno, c.col_offset))
|
|
196
|
+
out: list[Candidate] = []
|
|
197
|
+
for i, call in enumerate(calls):
|
|
198
|
+
prompt = _literal_prompt(call)
|
|
199
|
+
candidate = Candidate(
|
|
200
|
+
kind="input",
|
|
201
|
+
name=f"input-{i + 1}",
|
|
202
|
+
prompt=prompt,
|
|
203
|
+
order=i,
|
|
204
|
+
lineno=call.lineno,
|
|
205
|
+
secret=_is_secret_name(prompt),
|
|
206
|
+
)
|
|
207
|
+
candidate.type = "str" # pragma: no mutate — matches Candidate's own field default
|
|
208
|
+
out.append(candidate)
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _match_inputs(
|
|
213
|
+
stored: list[tuple[int, str]], current: list[tuple[int, str]]
|
|
214
|
+
) -> dict[int, tuple[int, bool]]:
|
|
215
|
+
"""Bind each stored input (its recorded ``order``, ``prompt``) to a call site in the CURRENT
|
|
216
|
+
source (3a). Shared by reconcile (drift detection) and shim (actual injection) so both agree on
|
|
217
|
+
exactly the same call site for a given definition -- the reason this lives in analyzer rather
|
|
218
|
+
than in either caller (A2).
|
|
219
|
+
|
|
220
|
+
A value must follow its *question*, not its position: keying purely by ``order`` (B1's original
|
|
221
|
+
design) breaks the instant a source edit inserts or deletes an *earlier* input() call, silently
|
|
222
|
+
shifting every later position -- a secret-marked definition can then attach to a different
|
|
223
|
+
prompt with no warning at all. So the literal prompt text is tried first (it survives a shift);
|
|
224
|
+
bare position is only a fallback, and is trusted as "no news" only when neither side has a
|
|
225
|
+
prompt to compare in the first place (a dynamic/absent prompt, or a spec stored before 3a) --
|
|
226
|
+
that case is no worse than the pre-3a behaviour, so it must not manufacture a new warning.
|
|
227
|
+
|
|
228
|
+
Returns ``{stored_order: (current_order, ambiguous)}``. A stored order absent from the result
|
|
229
|
+
could not be matched at all (genuinely gone -- the caller reports it as missing). ``ambiguous``
|
|
230
|
+
is True when position had to be trusted *despite* having a prompt to check -- either the prompt
|
|
231
|
+
no longer appears anywhere (likely edited/renamed) or it now matches more than one call site (two
|
|
232
|
+
prompts collide) -- both are exactly the silent-rebind risk this function exists to surface, so
|
|
233
|
+
callers must turn it into a visible warning rather than silently treating it as "ok".
|
|
234
|
+
|
|
235
|
+
Two passes: exact prompt matches are resolved first and their current-order claimed, so a
|
|
236
|
+
*different* stored entry's positional fallback can never be handed a call site some other
|
|
237
|
+
definition already owns by an exact prompt match -- e.g. deleting input #1 entirely (its prompt
|
|
238
|
+
now matches nothing) must not let it fall back onto position 0, when input #2's own prompt has
|
|
239
|
+
already, and correctly, claimed position 0 for itself. Without this, the deleted entry would
|
|
240
|
+
silently "recover" a value onto a call site someone else already owns.
|
|
241
|
+
|
|
242
|
+
The exact pass itself must also be 1:1, not just enforced against the fallback pass: two or more
|
|
243
|
+
STORED entries can legitimately share the identical literal prompt (a retry pattern like two
|
|
244
|
+
`input("Go? ")` calls, both managed). If the current source now has exactly one call site with
|
|
245
|
+
that prompt (the user deleted one of the two calls), every one of those stored entries would
|
|
246
|
+
otherwise resolve its *own* "unique candidate" check independently and all exact-match onto the
|
|
247
|
+
same current order -- silently binding two different definitions to one call site. Downstream,
|
|
248
|
+
reconcile would call all of them "ok" (no warning at all) and shim would splice two replacements
|
|
249
|
+
over the same `input` callee span, corrupting the injected copy into unparsable source. So the
|
|
250
|
+
exact pass claims its current-order as it goes: the first stored entry (in the order given) that
|
|
251
|
+
uniquely resolves a prompt wins that current order outright, and any later stored entry whose own
|
|
252
|
+
"unique" candidate has *already* been claimed loses the exact match and falls through to the
|
|
253
|
+
positional-fallback pass below -- where it is correctly reported ``missing`` (its bare position no
|
|
254
|
+
longer exists either) or flagged ``ambiguous`` (a different call now sits at that position), but
|
|
255
|
+
never silently double-bound.
|
|
256
|
+
"""
|
|
257
|
+
current_by_order = dict(current)
|
|
258
|
+
by_prompt: dict[str, list[int]] = {}
|
|
259
|
+
for order, prompt in current:
|
|
260
|
+
if prompt:
|
|
261
|
+
by_prompt.setdefault(prompt, []).append(order)
|
|
262
|
+
|
|
263
|
+
exact: dict[int, int] = {}
|
|
264
|
+
claimed: set[int] = set()
|
|
265
|
+
_match_prompt_multisets(stored, by_prompt, exact, claimed)
|
|
266
|
+
for order, prompt in stored:
|
|
267
|
+
if order in exact:
|
|
268
|
+
continue
|
|
269
|
+
if prompt:
|
|
270
|
+
candidates = by_prompt.get(prompt, [])
|
|
271
|
+
if len(candidates) == 1 and candidates[0] not in claimed:
|
|
272
|
+
exact[order] = candidates[0]
|
|
273
|
+
claimed.add(candidates[0])
|
|
274
|
+
|
|
275
|
+
out: dict[int, tuple[int, bool]] = {}
|
|
276
|
+
for order, prompt in stored:
|
|
277
|
+
if order in exact:
|
|
278
|
+
out[order] = (exact[order], False)
|
|
279
|
+
continue
|
|
280
|
+
# No exact prompt match (no prompt to compare, the prompt matches nothing anymore, it
|
|
281
|
+
# collides across multiple call sites, or its one candidate was already claimed by another
|
|
282
|
+
# stored entry's exact match): fall back to position, but never onto a call site an exact
|
|
283
|
+
# match already claimed, and flag it as ambiguous unless there was never a prompt to check
|
|
284
|
+
# in the first place (not a new risk, see the module-level note above).
|
|
285
|
+
if order in current_by_order and order not in claimed:
|
|
286
|
+
out[order] = (order, bool(prompt))
|
|
287
|
+
return out
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# Filename-shaped: no whitespace, a real extension (alpha-led, 2-4 chars — "3.14" is a
|
|
291
|
+
# version, not a file), and not a URL. Deliberately narrow; a missed hint is cheaper than
|
|
292
|
+
# a wrong one.
|
|
293
|
+
_FILENAME_RE = re.compile(r"[^\s]{1,120}\.[A-Za-z][A-Za-z0-9]{1,3}")
|
|
294
|
+
_FILENAME_HINT_CAP = 3
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _mutated_names(tree: ast.Module) -> set[str]:
|
|
298
|
+
"""Names that look like working variables, not parameters: augmented-assigned anywhere,
|
|
299
|
+
or (re)assigned inside a for/while body."""
|
|
300
|
+
out: set[str] = set()
|
|
301
|
+
for node in ast.walk(tree):
|
|
302
|
+
if isinstance(node, ast.AugAssign) and isinstance(node.target, ast.Name):
|
|
303
|
+
out.add(node.target.id)
|
|
304
|
+
elif isinstance(node, (ast.For, ast.While)):
|
|
305
|
+
for sub in ast.walk(node):
|
|
306
|
+
if isinstance(sub, ast.Assign):
|
|
307
|
+
out.update(t.id for t in sub.targets if isinstance(t, ast.Name))
|
|
308
|
+
elif isinstance(sub, (ast.AnnAssign, ast.AugAssign)) and isinstance(
|
|
309
|
+
sub.target, ast.Name
|
|
310
|
+
):
|
|
311
|
+
out.add(sub.target.id)
|
|
312
|
+
return out
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _uses_argv(tree: ast.Module) -> bool:
|
|
316
|
+
"""Any appearance of sys.argv (subscript, slice, len(...) — all imply CLI args)."""
|
|
317
|
+
for node in ast.walk(tree):
|
|
318
|
+
if (
|
|
319
|
+
isinstance(node, ast.Attribute)
|
|
320
|
+
and node.attr == "argv"
|
|
321
|
+
and isinstance(node.value, ast.Name)
|
|
322
|
+
and node.value.id == "sys"
|
|
323
|
+
):
|
|
324
|
+
return True
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _filename_literals(tree: ast.Module) -> list[str]:
|
|
329
|
+
"""Filename-looking string literals passed directly as call arguments (source order,
|
|
330
|
+
deduped, capped). A literal bound to a name first is already a candidate, not a hint."""
|
|
331
|
+
out: list[str] = []
|
|
332
|
+
for node in ast.walk(tree):
|
|
333
|
+
if not isinstance(node, ast.Call):
|
|
334
|
+
continue
|
|
335
|
+
args: list[ast.expr] = list(node.args) + [kw.value for kw in node.keywords]
|
|
336
|
+
for arg in args:
|
|
337
|
+
if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str)):
|
|
338
|
+
continue
|
|
339
|
+
s = arg.value
|
|
340
|
+
if _FILENAME_RE.fullmatch(s) and "://" not in s and s not in out:
|
|
341
|
+
out.append(s)
|
|
342
|
+
return out[:_FILENAME_HINT_CAP]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _match_prompt_multisets(
|
|
346
|
+
stored: list[tuple[int, str]],
|
|
347
|
+
by_prompt: dict[str, list[int]],
|
|
348
|
+
exact: dict[int, int],
|
|
349
|
+
claimed: set[int],
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Multiset pass: when the stored and current sides have the SAME number of call
|
|
352
|
+
sites for a prompt, pair them in positional order. A retry pattern — two identical
|
|
353
|
+
`input("Go? ")` calls, both managed — is a stable shape, and without this pass the
|
|
354
|
+
per-entry uniqueness rule would flag it as a rebind on every run, forever (resync
|
|
355
|
+
can't fix what isn't drift)."""
|
|
356
|
+
stored_by_prompt: dict[str, list[int]] = {}
|
|
357
|
+
for order, prompt in stored:
|
|
358
|
+
if prompt:
|
|
359
|
+
stored_by_prompt.setdefault(prompt, []).append(order)
|
|
360
|
+
for prompt, stored_orders in stored_by_prompt.items():
|
|
361
|
+
current_orders = by_prompt.get(prompt, [])
|
|
362
|
+
if len(stored_orders) > 1 and len(current_orders) == len(stored_orders):
|
|
363
|
+
for stored_order, current_order in zip(
|
|
364
|
+
sorted(stored_orders), sorted(current_orders), strict=True
|
|
365
|
+
):
|
|
366
|
+
exact[stored_order] = current_order
|
|
367
|
+
claimed.add(current_order)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _detect_frameworks(tree: ast.Module) -> list[str]:
|
|
371
|
+
found: list[str] = []
|
|
372
|
+
for node in ast.walk(tree):
|
|
373
|
+
mods: list[str] = []
|
|
374
|
+
if isinstance(node, ast.Import):
|
|
375
|
+
mods = [a.name.split(".")[0] for a in node.names]
|
|
376
|
+
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
|
377
|
+
mods = [node.module.split(".")[0]]
|
|
378
|
+
for m in mods:
|
|
379
|
+
if m in _CLI_FRAMEWORKS and m not in found:
|
|
380
|
+
found.append(m)
|
|
381
|
+
return found
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def analyze(text: str) -> Analysis:
|
|
385
|
+
"""Detect candidate parameters in the script source. On a syntax error, return an empty result
|
|
386
|
+
(no exception; add can still take the script into the store)."""
|
|
387
|
+
try:
|
|
388
|
+
tree = ast.parse(text)
|
|
389
|
+
except SyntaxError:
|
|
390
|
+
return Analysis(syntax_error=True)
|
|
391
|
+
candidates = _const_candidates(tree.body)
|
|
392
|
+
seen = {c.name for c in candidates}
|
|
393
|
+
for stmt in tree.body:
|
|
394
|
+
if _is_main_guard(stmt):
|
|
395
|
+
for c in _const_candidates(stmt.body):
|
|
396
|
+
if c.name not in seen: # module top-level wins (a same-name main-guard assignment
|
|
397
|
+
candidates.append(c) # is an override, not the definition)
|
|
398
|
+
seen.add(c.name)
|
|
399
|
+
mutated = _mutated_names(tree)
|
|
400
|
+
for c in candidates:
|
|
401
|
+
if c.kind == "const" and c.name in mutated:
|
|
402
|
+
c.demoted = True
|
|
403
|
+
c.demotion = "accumulator"
|
|
404
|
+
candidates.extend(_input_candidates(tree))
|
|
405
|
+
return Analysis(
|
|
406
|
+
candidates=candidates,
|
|
407
|
+
frameworks=_detect_frameworks(tree),
|
|
408
|
+
uses_argv=_uses_argv(tree),
|
|
409
|
+
filename_literals=_filename_literals(tree),
|
|
410
|
+
)
|