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 ADDED
@@ -0,0 +1,3 @@
1
+ """skit — a launcher and parameter manager for your scripts."""
2
+
3
+ __version__ = "0.0.1"
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
+ )