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/shim.py ADDED
@@ -0,0 +1,386 @@
1
+ """Shim: parameter injection at run time (AST location + precise source substitution).
2
+
3
+ Shares one candidate-decision set with analyzer (the reason A2 exists):
4
+ - const: literal assignments at module top level / main-guard top level, keyed by variable name,
5
+ with the RHS span replaced in-source.
6
+ - input: every input() call in the file, keyed by order of appearance (B1), matched to a stored
7
+ value by prompt text first and position only as a fallback (3a, `analyzer._match_inputs` — shared
8
+ with reconcile so both agree on the same call site). Each matched call site is rewritten
9
+ in-source, exactly like a const's RHS, from `input(...)` to `_skit_i[K](...)`, where `_skit_i[K]`
10
+ is a one-shot wrapper defined by a single-line preamble: it echoes "prompt + value" (masked to
11
+ *** when secret) and hands back the queued value on its first invocation, then falls through to
12
+ the real, unpatched `input` on every invocation after that — so input() inside loops, or called
13
+ more times than there are form values, still behaves correctly. Binding the value to the call
14
+ *site* rather than a global runtime call counter is what makes this correct even when a script's
15
+ *runtime* execution order differs from its *static* source order (e.g. a function's input() is
16
+ defined above a top-level input() but only invoked after it runs).
17
+
18
+ The substitution strategy is "locate via AST, replace as text": only the source span of a value node
19
+ (or, for input, the `input` callee name at a specific call site) changes; every other byte (PEP 723
20
+ / [tool.skit] block, comments, layout) is left untouched. The preamble is a **single physical line**
21
+ inserted after the docstring / __future__ imports (preserving `__doc__` semantics and avoiding a
22
+ __future__ syntax error, B4), for a fixed line-number shift of 1. The injected result is written to
23
+ a temp file (the OS temp directory, not next to the script copy — a crash must never leave a
24
+ plaintext-secret file behind, 3b) and run from there; the copy itself is never modified (A5).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import ast
30
+ import math
31
+ import os
32
+ import re
33
+ import tempfile
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+
37
+ from .analyzer import _is_main_guard, _literal_prompt, _literal_value, _match_inputs
38
+ from .metawriter import ParamSpec
39
+
40
+
41
+ class ShimError(Exception):
42
+ pass
43
+
44
+
45
+ class ShimValueError(ShimError):
46
+ """A value couldn't be coerced to its parameter's declared type.
47
+
48
+ Distinct from the base ShimError: that one means an injection *target* couldn't be located (the
49
+ script drifted from its [tool.skit] definitions). Here the target was found just fine — only the
50
+ value the user typed doesn't fit the declared int/float/bool type. Callers must not conflate the
51
+ two: telling a user to "re-add" a script because they mistyped a number is both wrong and
52
+ unhelpful (nothing about the source has drifted, so re-adding fixes nothing). Carries structured
53
+ fields (value / type_name / param_name) so a caller can build its own value-specific message
54
+ without re-parsing str(exc) — the str() form stays exactly "{value!r} -> {type_name}", matching
55
+ the plain ShimError message a `_coerce` failure has always raised.
56
+ """
57
+
58
+ def __init__(self, value: str, type_name: str, param_name: str) -> None:
59
+ self.value = value
60
+ self.type_name = type_name
61
+ self.param_name = param_name
62
+ super().__init__(f"{value!r} -> {type_name}")
63
+
64
+
65
+ _UTF8 = "utf-8" # pragma: no mutate — "utf-8"/"UTF-8" codec alias
66
+
67
+ # The exact set of newline sequences CPython's tokenizer/AST count as a line break: \r\n, \r, \n
68
+ # (in that preference order, so a CRLF pair is one line break, not two). str.splitlines() breaks on
69
+ # a much larger set (\v \f \x1c \x1d \x1e \x85 U+2028 U+2029 too), which desyncs any code that
70
+ # indexes AST linenos into its output — see _physical_lines.
71
+ _NEWLINE_RE = re.compile(r"\r\n|\r|\n")
72
+
73
+
74
+ def _physical_lines(text: str) -> list[str]:
75
+ """Split text into the same physical lines (keeping line endings) that AST linenos count.
76
+
77
+ A drop-in replacement for `text.splitlines(keepends=True)` for this exact purpose: that method
78
+ also splits on \\v, \\f, \\x1c-\\x1e, NEL (\\x85), and U+2028/U+2029 — none of which end a
79
+ physical line as far as the tokenizer/AST are concerned. When one of those characters appears
80
+ anywhere in the source (even inside a string literal, e.g. `MSG = "hi\\u2028there"`), splitlines
81
+ silently produces *more* entries than the AST's line count, so `lines[lineno - 1]` for every
82
+ node at or after that point no longer names the node's real line — the byte-slice write lands on
83
+ the wrong physical line. Depending on where that lands, the result is either a SyntaxError in the
84
+ injected temp copy, or — worse — a silently-corrupted preamble insertion that never takes effect
85
+ (the queued input() value is dropped with no error at all).
86
+ """
87
+ if not text:
88
+ return []
89
+ lines: list[str] = []
90
+ pos = 0
91
+ for m in _NEWLINE_RE.finditer(text):
92
+ lines.append(text[pos : m.end()])
93
+ pos = m.end()
94
+ if pos < len(text):
95
+ lines.append(text[pos:])
96
+ return lines
97
+
98
+
99
+ @dataclass
100
+ class _Replacement:
101
+ lineno: int # 1-based
102
+ col: int
103
+ end_lineno: int
104
+ end_col: int
105
+ new_text: str
106
+
107
+
108
+ def _coerce_float(value: str) -> float:
109
+ f = float(value)
110
+ # repr(inf/nan) is not a valid Python literal (X = inf -> NameError), so reject explicitly.
111
+ if math.isnan(f) or math.isinf(f):
112
+ raise ValueError(value)
113
+ return f
114
+
115
+
116
+ def _coerce_bool(value: str) -> bool:
117
+ low = value.strip().lower()
118
+ if low in ("true", "1", "yes", "y", "on"):
119
+ return True
120
+ if low in ("false", "0", "no", "n", "off"):
121
+ return False
122
+ raise ValueError(value)
123
+
124
+
125
+ def _coerce(value: str, type_name: str, param_name: str) -> str | int | float | bool:
126
+ """Convert the string from the form back to the defined type. If it can't convert, raise
127
+ ShimValueError (explicit error; never silently inject a broken value) — a ShimError subclass so
128
+ existing `except ShimError` callers keep working, but distinguishable from a missing-target
129
+ ShimError by callers that want to give a value-specific message instead of a drift warning."""
130
+ converters = {"int": int, "float": _coerce_float, "bool": _coerce_bool}
131
+ conv = converters.get(type_name)
132
+ if conv is None:
133
+ return value
134
+ try:
135
+ return conv(value)
136
+ except ValueError as exc:
137
+ raise ShimValueError(value, type_name, param_name) from exc
138
+
139
+
140
+ def _const_targets(body: list[ast.stmt], name: str) -> list[ast.expr]:
141
+ """In a block of top-level statements, the RHS nodes of literal assignments named `name`."""
142
+ out: list[ast.expr] = []
143
+ for stmt in body:
144
+ target: ast.expr | None = None # pragma: no mutate
145
+ value: ast.expr | None = None # pragma: no mutate
146
+ if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1:
147
+ target, value = stmt.targets[0], stmt.value
148
+ elif isinstance(stmt, ast.AnnAssign) and stmt.value is not None:
149
+ target, value = stmt.target, stmt.value
150
+ if isinstance(target, ast.Name) and target.id == name and value is not None:
151
+ ok, _ = _literal_value(value)
152
+ if ok:
153
+ out.append(value)
154
+ return out
155
+
156
+
157
+ def _input_calls(tree: ast.Module) -> list[ast.Call]:
158
+ calls = [
159
+ node
160
+ for node in ast.walk(tree)
161
+ if isinstance(node, ast.Call)
162
+ and isinstance(node.func, ast.Name)
163
+ and node.func.id == "input"
164
+ ]
165
+ calls.sort(key=lambda c: (c.lineno, c.col_offset))
166
+ return calls
167
+
168
+
169
+ def _build_preamble(queue: dict[int, tuple[str, bool]]) -> str:
170
+ """A single physical line defining one **per-call-site** one-shot override per queued input (3a).
171
+
172
+ Each managed input() call is rewritten in-source (see `_node_replacement` on its `.func`) from
173
+ `input(...)` to `_skit_i[K](...)`, where K is that call's resolved position in the CURRENT
174
+ source. `_skit_i[K]` is a one-shot wrapper: on its first invocation it echoes "prompt + value"
175
+ (masked to *** when secret, matching real terminal echo) and pops its queue entry; every
176
+ invocation after that (loops, dynamic call counts) falls through to the real, unpatched `input`
177
+ — same fallback contract the old global-counter design had. The difference is *what* the value
178
+ is bound to: a specific call site instead of "the Nth call at runtime" — so a script whose
179
+ *runtime* call order differs from its *static* source order (e.g. a function's input() is
180
+ defined above a top-level input() but only invoked after it) can no longer swap values, because
181
+ there is no shared counter left to race.
182
+ """
183
+ keys = sorted(queue)
184
+ return (
185
+ "import sys as _skit_s; _skit_o = input; _skit_q = "
186
+ + repr(queue)
187
+ + "; _skit_i = {k: (lambda p='', /, k=k: ("
188
+ "(_skit_s.stdout.write(str(p) + ('***' if _skit_q[k][1] else _skit_q[k][0]) + chr(10)), "
189
+ "_skit_q.pop(k)[0])[1] if k in _skit_q else _skit_o(p))) for k in "
190
+ + repr(keys)
191
+ + "} # skit:shim\n"
192
+ )
193
+
194
+
195
+ def _preamble_line_index(tree: ast.Module) -> int:
196
+ """The 0-based line index where the preamble should be inserted.
197
+
198
+ Skips the docstring and __future__ imports (B4: preserve `__doc__` semantics; __future__ must be
199
+ the first non-docstring statement, so inserting before it would be a SyntaxError). Callers must
200
+ only call this when the module has at least one top-level statement after that preamble.
201
+ """
202
+ body = tree.body
203
+ i = 0 # pragma: no mutate — only used as a slice bound; body[None:] == body[0:]
204
+ if (
205
+ body
206
+ and isinstance(body[0], ast.Expr)
207
+ and isinstance(body[0].value, ast.Constant)
208
+ and isinstance(body[0].value.value, str)
209
+ ):
210
+ i = 1
211
+ for node in body[i:]:
212
+ if not (isinstance(node, ast.ImportFrom) and node.module == "__future__"):
213
+ break
214
+ else: # pragma: no cover — callers only invoke this when a stmt follows the preamble
215
+ raise AssertionError("unreachable: caller guarantees a stmt follows the preamble")
216
+ stmt = node
217
+ # Decorators sit above the def/class lineno, so the insertion point must take the topmost one.
218
+ decorators = getattr(stmt, "decorator_list", None) or []
219
+ lineno = min([stmt.lineno, *(d.lineno for d in decorators)])
220
+ return lineno - 1
221
+
222
+
223
+ def _insert_preamble(text: str, tree: ast.Module, preamble: str) -> str:
224
+ idx = _preamble_line_index(tree)
225
+ lines = _physical_lines(text)
226
+ return "".join([*lines[:idx], preamble, *lines[idx:]])
227
+
228
+
229
+ def _node_replacement(node: ast.expr, new_text: str) -> _Replacement:
230
+ if node.end_lineno is None or node.end_col_offset is None: # pragma: no cover
231
+ raise ShimError("missing node span")
232
+ return _Replacement(
233
+ node.lineno, node.col_offset, node.end_lineno, node.end_col_offset, new_text
234
+ )
235
+
236
+
237
+ def _apply(text: str, replacements: list[_Replacement]) -> str:
238
+ """Apply replacements bottom-up to avoid span shifts. Spans are guaranteed non-overlapping: a
239
+ const target's RHS must be a literal (same decision as analyzer) so it cannot contain an
240
+ input() call, and an input replacement only ever covers the `input` callee name itself (a fixed
241
+ 5-byte identifier), never an argument — so const spans, other calls' callee spans, and a given
242
+ call's own argument spans never collide.
243
+
244
+ Note: ast's col_offset / end_col_offset are **UTF-8 byte** offsets, not character offsets.
245
+ When a line contains multibyte characters (e.g. CJK), slicing the str directly misaligns; we
246
+ must slice at the byte level and decode (a real bug caught by corpus 17_unicode_cjk).
247
+ """
248
+ lines = _physical_lines(text)
249
+ for r in sorted(replacements, key=lambda r: (r.lineno, r.col), reverse=True):
250
+ new_bytes = r.new_text.encode(_UTF8)
251
+ if r.lineno == r.end_lineno:
252
+ line = lines[r.lineno - 1].encode(_UTF8)
253
+ lines[r.lineno - 1] = (line[: r.col] + new_bytes + line[r.end_col :]).decode(_UTF8)
254
+ else:
255
+ first = lines[r.lineno - 1].encode(_UTF8)
256
+ last = lines[r.end_lineno - 1].encode(_UTF8)
257
+ merged = (first[: r.col] + new_bytes + last[r.end_col :]).decode(_UTF8)
258
+ lines[r.lineno - 1 : r.end_lineno] = [merged]
259
+ return "".join(lines)
260
+
261
+
262
+ def write_injected(entry_dir: Path, content: str) -> Path:
263
+ """Write the injected result to a unique temp file and return its path.
264
+
265
+ The file is written to the OS temp directory, not entry_dir — the persistent script store
266
+ (3b): entry_dir sits right next to script.py and holds only script.py + meta.toml (see
267
+ store.add_python's own contract for that invariant), and nothing depends on the injected copy
268
+ living there specifically — the run's cwd is resolved independently by
269
+ launcher._resolve_workdir, and `uv run --script <path>` doesn't require the script to sit next
270
+ to anything else. Writing a plaintext-secret-bearing file (const substitutions / queue literals)
271
+ into entry_dir instead would mean a SIGKILL/OOM/power-loss before the caller's
272
+ `finally: unlink()` runs leaves it there forever, since nothing skit owns ever sweeps entry_dir;
273
+ the OS temp directory, by contrast, is periodically reaped by the platform itself.
274
+
275
+ entry_dir is kept as a fallback (defense in depth) for the rare case the OS temp directory isn't
276
+ writable, so a run never fails outright just because TMPDIR is misconfigured.
277
+
278
+ - Unique filename (.injected-XXXX.py): concurrent runs of the same script don't clobber.
279
+ - 0o600 permissions: the content may contain secret values (const substitutions / queue
280
+ literals), so don't let other local users read it (an extension of C3; the caller must still
281
+ delete the file in a finally).
282
+ """
283
+ try:
284
+ fd, tmp = tempfile.mkstemp(prefix=".injected-", suffix=".py")
285
+ except OSError:
286
+ fd, tmp = tempfile.mkstemp(dir=entry_dir, prefix=".injected-", suffix=".py")
287
+ try:
288
+ os.chmod(
289
+ tmp, 0o600
290
+ ) # mkstemp is already 0600; state the intent explicitly (no-op on Windows)
291
+ except BaseException:
292
+ os.close(fd) # chmod raised before fdopen took ownership of fd; close it ourselves
293
+ os.unlink(tmp)
294
+ raise
295
+ try:
296
+ with os.fdopen(fd, "w", encoding=_UTF8) as f:
297
+ f.write(content)
298
+ except BaseException:
299
+ # fdopen already owns fd here (and the `with` closes it, whether the write succeeded or
300
+ # raised inside the block, or fdopen itself raised before returning) — closing it again
301
+ # would raise "Bad file descriptor" on an already-closed fd.
302
+ os.unlink(tmp)
303
+ raise
304
+ return Path(tmp)
305
+
306
+
307
+ def inject(text: str, specs: list[ParamSpec], values: dict[str, str]) -> str:
308
+ """Return the full injected script text. A parameter whose target can't be found means the
309
+ definition has drifted, so raise ShimError (tell the user to re-add explicitly; never silently
310
+ run stale values). A parameter whose target IS found but whose value doesn't fit the declared
311
+ type raises the ShimValueError subclass instead (via `_coerce`) — that's a bad input, not
312
+ drift, so callers should not react to it with re-add/drift wording."""
313
+ try:
314
+ tree = ast.parse(text)
315
+ except SyntaxError as exc:
316
+ raise ShimError(str(exc)) from exc
317
+
318
+ replacements: list[_Replacement] = []
319
+ missing: list[str] = []
320
+ input_calls = _input_calls(tree)
321
+ current_inputs = [(i, _literal_prompt(c)) for i, c in enumerate(input_calls)]
322
+ stored_inputs = [
323
+ (spec.order, spec.prompt) for spec in specs if spec.kind == "input" and spec.name in values
324
+ ]
325
+ input_bindings = _match_inputs(stored_inputs, current_inputs)
326
+ queue: dict[int, tuple[str, bool]] = {}
327
+
328
+ for spec in specs:
329
+ if spec.name not in values:
330
+ continue # no value received, leave it alone (preserve the script's original behavior)
331
+ raw = values[spec.name]
332
+ if spec.kind == "input":
333
+ # Resolve the call site the same way reconcile does (3a, shared via
334
+ # analyzer._match_inputs): prefer the stored prompt text over bare position, so a
335
+ # source edit that inserts/removes an earlier input() can't silently rebind this value
336
+ # onto the wrong question. No match at all (position gone too) = definition drift;
337
+ # error explicitly rather than dropping the value into a black hole.
338
+ binding = input_bindings.get(spec.order)
339
+ if binding is None:
340
+ missing.append(spec.name)
341
+ continue
342
+ resolved_order, _ambiguous = binding
343
+ if resolved_order in queue:
344
+ # Defense-in-depth: _match_inputs is meant to be strictly 1:1 (a claim-aware exact
345
+ # pass, 3a-fix), but this loop keys off `spec.order`, not identity -- two ParamSpec
346
+ # entries that happen to carry the same `order` (a hand-edited or otherwise
347
+ # corrupted [tool.skit] block) look up the *same* binding and would otherwise both
348
+ # queue a replacement over the identical `input` callee span. _apply's non-overlap
349
+ # contract can't survive that: two replacements at the same span corrupt the
350
+ # injected copy into unparsable text (e.g. `_skit_i[0]_i[0](...)`). Never let a
351
+ # second claimant reach _apply; report it as drift instead, same as any other
352
+ # target that can't be found.
353
+ missing.append(spec.name)
354
+ continue
355
+ queue[resolved_order] = (raw, spec.secret)
356
+ replacements.append(
357
+ _node_replacement(input_calls[resolved_order].func, f"_skit_i[{resolved_order}]")
358
+ )
359
+ continue
360
+ # const: module top level + main-guard top level; replace every same-name occurrence (both
361
+ # the top-level definition and a guard-body override should take the new value).
362
+ nodes = _const_targets(tree.body, spec.name)
363
+ for stmt in tree.body:
364
+ if _is_main_guard(stmt):
365
+ nodes += _const_targets(stmt.body, spec.name)
366
+ if not nodes:
367
+ missing.append(spec.name)
368
+ continue
369
+ typed = _coerce(raw, spec.type, spec.name)
370
+ replacements.extend(_node_replacement(n, repr(typed)) for n in nodes)
371
+
372
+ if missing:
373
+ raise ShimError(", ".join(missing))
374
+ out = _apply(text, replacements)
375
+ if queue:
376
+ # Apply span replacements before inserting the line: every const/input replacement site is
377
+ # after the insertion point (a top-level assignment can't precede docstring/__future__, and
378
+ # any statement containing a queued input() call — or one of its ancestors — is itself a
379
+ # top-level statement that also follows it), so lines above the insertion point are
380
+ # unaffected and the index stays valid.
381
+ preamble = _build_preamble(queue)
382
+ # Invariant: queue is non-empty only if an input() call was queued, which means at least one
383
+ # top-level statement (the one containing that call, or an ancestor of it) exists after the
384
+ # docstring/__future__ preamble; _preamble_line_index always finds it.
385
+ out = _insert_preamble(out, tree, preamble)
386
+ return out