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/reconcile.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Reconcile: before a run, reconcile the `[tool.skit]` definitions with the script's current
|
|
2
|
+
content (Phase 3).
|
|
3
|
+
|
|
4
|
+
Scripts get hand-edited (the copy in copy mode, the original in reference mode), while the
|
|
5
|
+
definitions are a snapshot from add time, so the two drift apart. The reconcile keys are the
|
|
6
|
+
same set analyzer/shim uses (A2):
|
|
7
|
+
|
|
8
|
+
- const: matched against analyzer's const candidates by **variable name**.
|
|
9
|
+
- input: matched against analyzer's input candidates by **prompt text** first, falling back to
|
|
10
|
+
**call order** (B1) only when the prompt can't disambiguate -- a dynamic/absent prompt, or the
|
|
11
|
+
prompt no longer uniquely identifies a call site (3a, `analyzer._match_inputs`). A positional
|
|
12
|
+
fallback that had a prompt to check and didn't get an exact match is a `rebind`: still injectable,
|
|
13
|
+
but flagged, since silently trusting position again is exactly the bug 3a fixes.
|
|
14
|
+
|
|
15
|
+
It produces five categories:
|
|
16
|
+
|
|
17
|
+
- ok: the definition still matches, proceed to the form as usual.
|
|
18
|
+
- missing: the definition has no target (variable deleted/renamed, fewer inputs). Injecting these
|
|
19
|
+
anyway would throw the value into a black hole, so the caller should drop them from the form and
|
|
20
|
+
warn (old/new comparison).
|
|
21
|
+
- changed: the const matched, but its type in the source changed (e.g. 3 -> "3"). Still injectable,
|
|
22
|
+
but the value domain may be off, so just warn.
|
|
23
|
+
- rebind: an input matched only by falling back to bare position after its prompt failed to
|
|
24
|
+
uniquely resolve (3a). Still injectable (usable), but warned -- a source edit may have silently
|
|
25
|
+
reshuffled which question this value now answers.
|
|
26
|
+
- new: candidates present in the script but not in the definitions. Note: during onboarding the user
|
|
27
|
+
may deliberately select only some, so new is not drift (has_drift excludes it) and is not raised
|
|
28
|
+
to nag the user at run time; it's reference info for views like `skit params`.
|
|
29
|
+
|
|
30
|
+
This module only makes **decisions**; it does no I/O and produces no user copy — presentation
|
|
31
|
+
is left to the CLI/TUI.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from dataclasses import dataclass, field, replace
|
|
37
|
+
|
|
38
|
+
from .analyzer import Candidate, _match_inputs, analyze
|
|
39
|
+
from .metawriter import ParamSpec
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Report:
|
|
44
|
+
ok: list[ParamSpec] = field(default_factory=list)
|
|
45
|
+
missing: list[ParamSpec] = field(default_factory=list)
|
|
46
|
+
changed: list[tuple[ParamSpec, Candidate]] = field(default_factory=list)
|
|
47
|
+
rebind: list[tuple[ParamSpec, Candidate]] = field(default_factory=list)
|
|
48
|
+
new: list[Candidate] = field(default_factory=list)
|
|
49
|
+
syntax_error: bool = False
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def has_drift(self) -> bool:
|
|
53
|
+
"""Drift on the definition side (missing/changed/rebind). new is info, not drift (see
|
|
54
|
+
module docstring)."""
|
|
55
|
+
return bool(self.missing or self.changed or self.rebind)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def usable(self) -> list[ParamSpec]:
|
|
59
|
+
"""Definitions still safe to inject (ok + changed + rebind; changed is only a type
|
|
60
|
+
warning, rebind is only a positional-fallback warning -- both still inject, just flagged,
|
|
61
|
+
per 3a: no silent drop, no silent wrong-value swap either)."""
|
|
62
|
+
return self.ok + [spec for spec, _ in self.changed] + [spec for spec, _ in self.rebind]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def drift_lines(report: Report, name: str) -> list[str]:
|
|
66
|
+
"""The display lines for a drift report (shared by CLI/TUI). The only copy exit point in this
|
|
67
|
+
module: plain-text old/new comparison; rich markup/color is wrapped by the caller."""
|
|
68
|
+
from .i18n import gettext
|
|
69
|
+
|
|
70
|
+
lines = [
|
|
71
|
+
gettext("The parameter definitions for %(name)s have drifted from the script:")
|
|
72
|
+
% {"name": name}
|
|
73
|
+
]
|
|
74
|
+
lines.extend(
|
|
75
|
+
" "
|
|
76
|
+
+ gettext("%(name)s: injection target no longer exists (dropped from this run's form)")
|
|
77
|
+
% {"name": spec.name}
|
|
78
|
+
for spec in report.missing
|
|
79
|
+
)
|
|
80
|
+
lines.extend(
|
|
81
|
+
" "
|
|
82
|
+
+ gettext(
|
|
83
|
+
"%(name)s: type changed from %(old)s to %(new)s in the source (still injected — double-check the value)"
|
|
84
|
+
)
|
|
85
|
+
% {"name": spec.name, "old": spec.type, "new": cand.type}
|
|
86
|
+
for spec, cand in report.changed
|
|
87
|
+
)
|
|
88
|
+
lines.extend(
|
|
89
|
+
" "
|
|
90
|
+
+ gettext(
|
|
91
|
+
"%(name)s: its prompt no longer matches a unique input() call; falling back to "
|
|
92
|
+
"position (still injected — double-check this lands on the right question, "
|
|
93
|
+
"especially if it's a secret)"
|
|
94
|
+
)
|
|
95
|
+
% {"name": spec.name}
|
|
96
|
+
for spec, cand in report.rebind
|
|
97
|
+
)
|
|
98
|
+
lines.append(
|
|
99
|
+
gettext("To refresh the definitions, re-run `skit add` or edit the [tool.skit] block.")
|
|
100
|
+
)
|
|
101
|
+
return lines
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def render_warning(warning: str) -> str:
|
|
105
|
+
"""Translate an EditResult warning ("code:name") into a user-facing line (shared by CLI/TUI).
|
|
106
|
+
|
|
107
|
+
The codes are the closed set emitted by edit_specs; keeping the message lookup here (rather than
|
|
108
|
+
a dynamic gettext(f"edit-warn-{code}")) lets Babel extract every string statically."""
|
|
109
|
+
from .i18n import gettext
|
|
110
|
+
|
|
111
|
+
code, _, name = warning.partition(":")
|
|
112
|
+
return {
|
|
113
|
+
"not-managed": gettext("%(name)s isn't a managed parameter; skipped."),
|
|
114
|
+
"resync-dropped": gettext("Dropped %(name)s: it no longer exists in the script."),
|
|
115
|
+
"already-managed": gettext("%(name)s is already managed; skipped."),
|
|
116
|
+
"not-a-candidate": gettext(
|
|
117
|
+
"%(name)s isn't a detectable parameter in the current script; skipped."
|
|
118
|
+
),
|
|
119
|
+
"resync-skipped": gettext(
|
|
120
|
+
"Could not parse the script (syntax error); resync skipped. "
|
|
121
|
+
"Parameter definitions are unchanged."
|
|
122
|
+
),
|
|
123
|
+
"resync-rebound": gettext(
|
|
124
|
+
"%(name)s: re-anchored to its current position after its prompt stopped matching "
|
|
125
|
+
"uniquely; double-check the prompt/secret assignment is still correct."
|
|
126
|
+
),
|
|
127
|
+
}[code] % {"name": name}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class EditResult:
|
|
132
|
+
specs: list[ParamSpec]
|
|
133
|
+
warnings: list[str] = field(
|
|
134
|
+
default_factory=list
|
|
135
|
+
) # unmatched names etc.; i18n key + value by CLI
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def edit_specs(
|
|
139
|
+
text: str,
|
|
140
|
+
specs: list[ParamSpec],
|
|
141
|
+
*,
|
|
142
|
+
resync: bool = False,
|
|
143
|
+
add: list[str] | tuple[str, ...] = (),
|
|
144
|
+
remove: list[str] | tuple[str, ...] = (),
|
|
145
|
+
secret: list[str] | tuple[str, ...] = (),
|
|
146
|
+
no_secret: list[str] | tuple[str, ...] = (),
|
|
147
|
+
prompts: dict[str, str] | None = None,
|
|
148
|
+
) -> EditResult:
|
|
149
|
+
"""Pure function: apply a set of edit operations to the existing `[tool.skit]` definitions and
|
|
150
|
+
return the new definition list.
|
|
151
|
+
|
|
152
|
+
Keys are always the **name** (const=variable name, input=input-N display name, matching
|
|
153
|
+
`skit params`; an input's name is bound to its order, so it's unique for inputs too). The apply
|
|
154
|
+
order is intentionally fixed: resync (prune/retype) -> remove -> add -> secret/no_secret/prompt
|
|
155
|
+
(tweaks). No I/O; unmatched names are collected into warnings for the caller to render.
|
|
156
|
+
"""
|
|
157
|
+
prompts = prompts or {}
|
|
158
|
+
warnings: list[str] = []
|
|
159
|
+
# Shallow-copy each spec: this function claims to be pure and must never mutate the caller's
|
|
160
|
+
# objects (resync changes type, tweaks change secret/prompt).
|
|
161
|
+
by_name: dict[str, ParamSpec] = {s.name: replace(s) for s in specs}
|
|
162
|
+
# Derive order from by_name's own (deduped) keys rather than re-deriving it from `specs`
|
|
163
|
+
# directly: a corrupted/legacy definition set can contain duplicate names (analyzer used to be
|
|
164
|
+
# able to emit two same-named const candidates; onboarding then wrote both), and order must
|
|
165
|
+
# never contain a name absent from by_name, or `[by_name[n] for n in order]` below raises
|
|
166
|
+
# KeyError once one of the two occurrences is removed (dict preserves first-occurrence
|
|
167
|
+
# insertion order, so this only changes anything when specs already has duplicate names).
|
|
168
|
+
order: list[str] = list(by_name) # keep original order; new ones appended at the end
|
|
169
|
+
|
|
170
|
+
# 1) resync: prune missing and update changed types per the current script (keeping custom
|
|
171
|
+
# secret/prompt/default).
|
|
172
|
+
if resync:
|
|
173
|
+
_apply_resync(text, specs, by_name, order, warnings)
|
|
174
|
+
|
|
175
|
+
# 2) remove: explicit drop.
|
|
176
|
+
for name in remove:
|
|
177
|
+
if name in by_name:
|
|
178
|
+
del by_name[name]
|
|
179
|
+
order.remove(name)
|
|
180
|
+
else:
|
|
181
|
+
warnings.append(f"not-managed:{name}")
|
|
182
|
+
|
|
183
|
+
# 3) add: bring a currently detected candidate under management (skip if already managed).
|
|
184
|
+
if add:
|
|
185
|
+
_apply_add(text, add, by_name, order, warnings)
|
|
186
|
+
|
|
187
|
+
# 4) tweak secret / prompt (only for managed ones).
|
|
188
|
+
_apply_tweaks(by_name, warnings, secret=secret, no_secret=no_secret, prompts=prompts)
|
|
189
|
+
|
|
190
|
+
return EditResult(specs=[by_name[n] for n in order], warnings=warnings)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _apply_resync(
|
|
194
|
+
text: str,
|
|
195
|
+
specs: list[ParamSpec],
|
|
196
|
+
by_name: dict[str, ParamSpec],
|
|
197
|
+
order: list[str],
|
|
198
|
+
warnings: list[str],
|
|
199
|
+
) -> None:
|
|
200
|
+
report = reconcile(text, specs)
|
|
201
|
+
if report.syntax_error:
|
|
202
|
+
# reconcile() can't tell "genuinely gone" from "the script doesn't parse right now" on its
|
|
203
|
+
# own: with a syntax error, analyze() finds no candidates at all, so reconcile() marks
|
|
204
|
+
# every spec missing (nothing can possibly match) even though nothing has actually changed.
|
|
205
|
+
# Treating that as real drift here would prune the whole managed set, and write_params
|
|
206
|
+
# drops the entire [tool.skit] block once params is empty -- a transient parse error (e.g.
|
|
207
|
+
# mid-edit) would silently destroy the user's managed-parameter definitions. Leave
|
|
208
|
+
# by_name/order untouched and tell the user resync didn't run instead.
|
|
209
|
+
warnings.append("resync-skipped")
|
|
210
|
+
return
|
|
211
|
+
missing_names = {s.name for s in report.missing}
|
|
212
|
+
changed_types = {spec.name: cand.type for spec, cand in report.changed}
|
|
213
|
+
rebind_targets = {spec.name: cand for spec, cand in report.rebind}
|
|
214
|
+
for name in list(order):
|
|
215
|
+
if name in missing_names:
|
|
216
|
+
warnings.append(f"resync-dropped:{name}")
|
|
217
|
+
del by_name[name]
|
|
218
|
+
order.remove(name)
|
|
219
|
+
elif name in changed_types:
|
|
220
|
+
by_name[name].type = changed_types[name]
|
|
221
|
+
elif name in rebind_targets:
|
|
222
|
+
# The prompt no longer uniquely resolves; re-anchor to whichever call site position
|
|
223
|
+
# currently supplied it, so the *next* run's plain reconcile() (no --resync) sees an
|
|
224
|
+
# exact prompt match again instead of re-deriving the same fallback every time.
|
|
225
|
+
cand = rebind_targets[name]
|
|
226
|
+
warnings.append(f"resync-rebound:{name}")
|
|
227
|
+
by_name[name].order = cand.order
|
|
228
|
+
by_name[name].prompt = cand.prompt
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _apply_add(
|
|
232
|
+
text: str,
|
|
233
|
+
add: list[str] | tuple[str, ...],
|
|
234
|
+
by_name: dict[str, ParamSpec],
|
|
235
|
+
order: list[str],
|
|
236
|
+
warnings: list[str],
|
|
237
|
+
) -> None:
|
|
238
|
+
candidates = {c.name: c for c in analyze(text).candidates}
|
|
239
|
+
for name in add:
|
|
240
|
+
if name in by_name:
|
|
241
|
+
warnings.append(f"already-managed:{name}")
|
|
242
|
+
elif name in candidates:
|
|
243
|
+
by_name[name] = ParamSpec.from_candidate(candidates[name])
|
|
244
|
+
order.append(name)
|
|
245
|
+
else:
|
|
246
|
+
warnings.append(f"not-a-candidate:{name}")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _apply_tweaks(
|
|
250
|
+
by_name: dict[str, ParamSpec],
|
|
251
|
+
warnings: list[str],
|
|
252
|
+
*,
|
|
253
|
+
secret: list[str] | tuple[str, ...],
|
|
254
|
+
no_secret: list[str] | tuple[str, ...],
|
|
255
|
+
prompts: dict[str, str],
|
|
256
|
+
) -> None:
|
|
257
|
+
for name in secret:
|
|
258
|
+
if name in by_name:
|
|
259
|
+
by_name[name].secret = True
|
|
260
|
+
else:
|
|
261
|
+
warnings.append(f"not-managed:{name}")
|
|
262
|
+
for name in no_secret:
|
|
263
|
+
if name in by_name:
|
|
264
|
+
by_name[name].secret = False
|
|
265
|
+
by_name[name].env_source = "" # an env source only means anything on a secret
|
|
266
|
+
else:
|
|
267
|
+
warnings.append(f"not-managed:{name}")
|
|
268
|
+
for name, prompt in prompts.items():
|
|
269
|
+
if name in by_name:
|
|
270
|
+
by_name[name].prompt = prompt
|
|
271
|
+
else:
|
|
272
|
+
warnings.append(f"not-managed:{name}")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def reconcile(text: str, specs: list[ParamSpec]) -> Report:
|
|
276
|
+
"""Reconcile the definitions with the script's current content. On a syntax error, mark
|
|
277
|
+
everything missing (nothing matches)."""
|
|
278
|
+
analysis = analyze(text)
|
|
279
|
+
if analysis.syntax_error:
|
|
280
|
+
return Report(missing=list(specs), syntax_error=True)
|
|
281
|
+
|
|
282
|
+
consts = {c.name: c for c in analysis.candidates if c.kind == "const"}
|
|
283
|
+
inputs = {c.order: c for c in analysis.candidates if c.kind == "input"}
|
|
284
|
+
stored_inputs = [(s.order, s.prompt) for s in specs if s.kind == "input"]
|
|
285
|
+
current_inputs = [(c.order, c.prompt) for c in analysis.candidates if c.kind == "input"]
|
|
286
|
+
input_bindings = _match_inputs(stored_inputs, current_inputs)
|
|
287
|
+
|
|
288
|
+
report = Report()
|
|
289
|
+
covered_consts: set[str] = set()
|
|
290
|
+
covered_inputs: set[int] = set()
|
|
291
|
+
|
|
292
|
+
for spec in specs:
|
|
293
|
+
if spec.kind == "input":
|
|
294
|
+
binding = input_bindings.get(spec.order)
|
|
295
|
+
if binding is None:
|
|
296
|
+
report.missing.append(spec)
|
|
297
|
+
continue
|
|
298
|
+
resolved_order, ambiguous = binding
|
|
299
|
+
cand = inputs[resolved_order]
|
|
300
|
+
covered_inputs.add(resolved_order)
|
|
301
|
+
if ambiguous:
|
|
302
|
+
report.rebind.append((spec, cand))
|
|
303
|
+
else:
|
|
304
|
+
report.ok.append(spec)
|
|
305
|
+
continue
|
|
306
|
+
cand = consts.get(spec.name)
|
|
307
|
+
if cand is None:
|
|
308
|
+
report.missing.append(spec)
|
|
309
|
+
elif cand.type != spec.type:
|
|
310
|
+
covered_consts.add(spec.name)
|
|
311
|
+
report.changed.append((spec, cand))
|
|
312
|
+
else:
|
|
313
|
+
covered_consts.add(spec.name)
|
|
314
|
+
report.ok.append(spec)
|
|
315
|
+
|
|
316
|
+
for cand in analysis.candidates:
|
|
317
|
+
if (cand.kind == "const" and cand.name not in covered_consts) or (
|
|
318
|
+
cand.kind == "input" and cand.order not in covered_inputs
|
|
319
|
+
):
|
|
320
|
+
report.new.append(cand)
|
|
321
|
+
return report
|