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