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/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
|