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/tui_settings.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Script settings (p): the merged per-script management screen — basics, parameters,
|
|
2
|
+
presets, dependencies in one place.
|
|
3
|
+
|
|
4
|
+
Enter saves everything in one atomic [tool.skit] rewrite; Esc asks when there are
|
|
5
|
+
unsaved changes. Reference-mode entries show the parameters read-only (skit never
|
|
6
|
+
writes the original file, A7); command entries show the template and placeholders.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import override
|
|
12
|
+
|
|
13
|
+
from rich.markup import escape
|
|
14
|
+
from textual import on
|
|
15
|
+
from textual.app import ComposeResult
|
|
16
|
+
from textual.binding import Binding
|
|
17
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
18
|
+
from textual.screen import ModalScreen, Screen
|
|
19
|
+
from textual.widgets import Checkbox, Input, Label, Static
|
|
20
|
+
|
|
21
|
+
from . import argspec, argstate, metawriter, pep723, reconcile, store, tui_footer
|
|
22
|
+
from .i18n import gettext
|
|
23
|
+
from .models import Entry
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DiscardChangesModal(ModalScreen[bool]):
|
|
27
|
+
BINDINGS = [
|
|
28
|
+
Binding("y", "discard", gettext("Discard")),
|
|
29
|
+
Binding("escape,n", "keep", gettext("Keep editing")),
|
|
30
|
+
]
|
|
31
|
+
DEFAULT_CSS = """
|
|
32
|
+
DiscardChangesModal { align: center middle; }
|
|
33
|
+
DiscardChangesModal > Vertical { border: round $accent; padding: 1 2; width: auto;
|
|
34
|
+
height: auto; background: $background; }
|
|
35
|
+
DiscardChangesModal Static { margin: 1 0 0 0; width: auto; }
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
@override
|
|
39
|
+
def compose(self) -> ComposeResult:
|
|
40
|
+
with Vertical():
|
|
41
|
+
yield Label(gettext("Discard unsaved changes?"))
|
|
42
|
+
yield Static(
|
|
43
|
+
tui_footer.bar(
|
|
44
|
+
tui_footer.chip("screen.discard", "y", gettext("Discard")),
|
|
45
|
+
tui_footer.chip("screen.keep", "Esc", gettext("Keep editing")),
|
|
46
|
+
),
|
|
47
|
+
markup=True,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def action_discard(self) -> None:
|
|
51
|
+
self.dismiss(True)
|
|
52
|
+
|
|
53
|
+
def action_keep(self) -> None:
|
|
54
|
+
self.dismiss(False)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ParamRow(Vertical):
|
|
58
|
+
"""One managed parameter: keep/unmanage, prompt text, secret + env source."""
|
|
59
|
+
|
|
60
|
+
DEFAULT_CSS = """
|
|
61
|
+
ParamRow { height: auto; margin: 0 0 1 2; }
|
|
62
|
+
ParamRow .p-meta { color: $text-muted; width: auto; }
|
|
63
|
+
ParamRow Horizontal { height: auto; }
|
|
64
|
+
ParamRow Horizontal > Checkbox { width: auto; }
|
|
65
|
+
ParamRow Input { width: 1fr; }
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, spec: metawriter.ParamSpec) -> None:
|
|
69
|
+
super().__init__()
|
|
70
|
+
self.spec: metawriter.ParamSpec = spec
|
|
71
|
+
|
|
72
|
+
@override
|
|
73
|
+
def compose(self) -> ComposeResult:
|
|
74
|
+
s = self.spec
|
|
75
|
+
default = "" if s.default is None else repr(s.default)
|
|
76
|
+
yield Checkbox(f"{escape(s.name)} [dim]{s.type} {escape(default)}[/dim]", value=True)
|
|
77
|
+
with Horizontal():
|
|
78
|
+
yield Static(" " + gettext("Form label:"), classes="p-meta")
|
|
79
|
+
yield Input(value=s.prompt, placeholder=s.name, classes="p-prompt")
|
|
80
|
+
with Horizontal():
|
|
81
|
+
yield Checkbox(
|
|
82
|
+
gettext("secret (never saved to disk)"), value=s.secret, classes="p-secret"
|
|
83
|
+
)
|
|
84
|
+
yield Input(
|
|
85
|
+
value=s.env_source,
|
|
86
|
+
placeholder=gettext("env variable to read it from (optional)"),
|
|
87
|
+
classes="p-env",
|
|
88
|
+
)
|
|
89
|
+
yield Static("", classes="p-note")
|
|
90
|
+
|
|
91
|
+
def collect(self) -> metawriter.ParamSpec | None:
|
|
92
|
+
"""None when unmanaged (checkbox off)."""
|
|
93
|
+
if not self.query_one(Checkbox).value:
|
|
94
|
+
return None
|
|
95
|
+
s = self.spec
|
|
96
|
+
s.prompt = self.query_one(".p-prompt", Input).value.strip()
|
|
97
|
+
s.secret = self.query_one(".p-secret", Checkbox).value
|
|
98
|
+
s.env_source = self.query_one(".p-env", Input).value.strip() if s.secret else ""
|
|
99
|
+
return s
|
|
100
|
+
|
|
101
|
+
@on(Checkbox.Changed, ".p-secret")
|
|
102
|
+
def _secret_note(self, event: Checkbox.Changed) -> None:
|
|
103
|
+
note = self.query_one(".p-note", Static)
|
|
104
|
+
if event.value and not self.spec.secret:
|
|
105
|
+
note.update(
|
|
106
|
+
f"[yellow]{gettext('Anything skit previously remembered for this value will be deleted too.')}[/yellow]"
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
note.update("")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ScriptSettingsScreen(Screen[bool]):
|
|
113
|
+
"""Four sections in one screen; `s` in the Library deep-links to Presets."""
|
|
114
|
+
|
|
115
|
+
BINDINGS = [
|
|
116
|
+
Binding("escape", "close", gettext("Back")),
|
|
117
|
+
Binding("ctrl+a", "save", gettext("Save"), priority=True),
|
|
118
|
+
Binding("ctrl+r", "resync", gettext("Resync"), priority=True),
|
|
119
|
+
]
|
|
120
|
+
DEFAULT_CSS = """
|
|
121
|
+
ScriptSettingsScreen #st-body {
|
|
122
|
+
padding: 0 1;
|
|
123
|
+
border: round $skit-box-indigo;
|
|
124
|
+
border-title-color: ansi_bright_white;
|
|
125
|
+
border-title-style: bold;
|
|
126
|
+
}
|
|
127
|
+
ScriptSettingsScreen .section { color: $accent; margin: 1 0 0 0; }
|
|
128
|
+
ScriptSettingsScreen .hint { color: $text-muted; }
|
|
129
|
+
ScriptSettingsScreen #st-keys { dock: bottom; height: 1; color: $text-muted; padding: 0 1; }
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, entry: Entry, initial_section: str = "") -> None:
|
|
133
|
+
super().__init__()
|
|
134
|
+
self._entry: Entry = entry
|
|
135
|
+
self._initial: str = initial_section
|
|
136
|
+
self._dirty: bool = False
|
|
137
|
+
# Widgets can emit Changed while the screen is still composing (initial values
|
|
138
|
+
# settling); only user edits after mount count as dirt.
|
|
139
|
+
self._dirt_armed: bool = False
|
|
140
|
+
self._text: str = ""
|
|
141
|
+
if entry.meta.kind == "python" and entry.script_path.exists():
|
|
142
|
+
self._text = entry.script_path.read_text(encoding="utf-8", errors="replace")
|
|
143
|
+
self._specs: list[metawriter.ParamSpec] = metawriter.read_params(self._text)
|
|
144
|
+
# The resync outcome (incl. safety-rebind warnings) must survive the recompose that
|
|
145
|
+
# action_resync triggers — a widget updated in place would be thrown away and rebuilt
|
|
146
|
+
# empty. Kept on the instance so compose can re-emit it. Already escape()'d for markup.
|
|
147
|
+
self._resync_report: str = ""
|
|
148
|
+
|
|
149
|
+
@override
|
|
150
|
+
def compose(self) -> ComposeResult:
|
|
151
|
+
with VerticalScroll(id="st-body"):
|
|
152
|
+
yield Static(gettext("Basics"), classes="section")
|
|
153
|
+
yield Input(value=self._entry.meta.name, id="st-name")
|
|
154
|
+
yield Static(
|
|
155
|
+
gettext("Renaming keeps everything — remembered values, presets, the stored copy."),
|
|
156
|
+
classes="hint",
|
|
157
|
+
)
|
|
158
|
+
yield Input(
|
|
159
|
+
value=self._entry.meta.description,
|
|
160
|
+
placeholder=gettext("Description (shown in the Library)"),
|
|
161
|
+
id="st-desc",
|
|
162
|
+
)
|
|
163
|
+
yield from self._compose_storage()
|
|
164
|
+
yield from self._compose_params()
|
|
165
|
+
yield from self._compose_presets()
|
|
166
|
+
yield from self._compose_deps()
|
|
167
|
+
yield Static(
|
|
168
|
+
tui_footer.bar(
|
|
169
|
+
tui_footer.chip("screen.save", "Ctrl+A", gettext("Save")),
|
|
170
|
+
tui_footer.chip("screen.resync", "Ctrl+R", gettext("Resync")),
|
|
171
|
+
tui_footer.chip("screen.close", "Esc", gettext("Back")),
|
|
172
|
+
),
|
|
173
|
+
id="st-keys",
|
|
174
|
+
markup=True,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def _compose_storage(self) -> ComposeResult:
|
|
178
|
+
meta = self._entry.meta
|
|
179
|
+
if meta.kind != "python":
|
|
180
|
+
return
|
|
181
|
+
yield Static(gettext("Storage"), classes="section")
|
|
182
|
+
if meta.mode == "copy":
|
|
183
|
+
yield Static(
|
|
184
|
+
gettext("Keep a copy — your original file is never modified. Source: %(path)s")
|
|
185
|
+
% {"path": escape(meta.source)},
|
|
186
|
+
classes="hint",
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
yield Static(
|
|
190
|
+
gettext("Linked to the original: %(path)s") % {"path": escape(meta.source)},
|
|
191
|
+
classes="hint",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def _compose_params(self) -> ComposeResult:
|
|
195
|
+
yield Static(gettext("Parameters (the run form's fields)"), classes="section")
|
|
196
|
+
meta = self._entry.meta
|
|
197
|
+
if meta.kind == "command":
|
|
198
|
+
yield Static(escape(meta.template), classes="hint")
|
|
199
|
+
for p in meta.params or []:
|
|
200
|
+
yield Static(f"· {escape(p)}")
|
|
201
|
+
return
|
|
202
|
+
if meta.kind != "python":
|
|
203
|
+
yield Static(gettext("(programs have no managed parameters)"), classes="hint")
|
|
204
|
+
return
|
|
205
|
+
if meta.mode == "reference":
|
|
206
|
+
yield Static(
|
|
207
|
+
gettext(
|
|
208
|
+
"skit doesn't write to this file — maintain the [tool.skit] definitions "
|
|
209
|
+
"in the source directly."
|
|
210
|
+
),
|
|
211
|
+
classes="hint",
|
|
212
|
+
)
|
|
213
|
+
for s in self._specs:
|
|
214
|
+
yield Static(f"· {escape(s.name)} ({s.type})")
|
|
215
|
+
return
|
|
216
|
+
for s in self._specs:
|
|
217
|
+
yield ParamRow(s)
|
|
218
|
+
if self._cli_driven():
|
|
219
|
+
# This script's form already comes from its own argparse/click/typer surface.
|
|
220
|
+
# Managing a hardcoded constant would write a [tool.skit] block that shadows
|
|
221
|
+
# that whole form (plan_for_entry prefers managed params) — a source-flip
|
|
222
|
+
# trap. Explain instead of offering the manage-these-constants checkboxes.
|
|
223
|
+
yield Static(
|
|
224
|
+
gettext(
|
|
225
|
+
"This script's run form comes from its own command-line arguments. "
|
|
226
|
+
"Managing a hardcoded constant here would replace that form — leave it as is."
|
|
227
|
+
),
|
|
228
|
+
classes="hint",
|
|
229
|
+
)
|
|
230
|
+
else:
|
|
231
|
+
report = reconcile.reconcile(self._text, self._specs) if self._text else None
|
|
232
|
+
if report is not None and report.new:
|
|
233
|
+
yield Static(
|
|
234
|
+
gettext("Detected but not yet managed — tick to manage:"), classes="hint"
|
|
235
|
+
)
|
|
236
|
+
for i, c in enumerate(report.new):
|
|
237
|
+
yield Checkbox(
|
|
238
|
+
f"{escape(c.name)} [dim]{c.type} = {escape(repr(c.default))}[/dim]",
|
|
239
|
+
value=False,
|
|
240
|
+
id=f"st-new-{i}",
|
|
241
|
+
)
|
|
242
|
+
if self._specs and all(s.kind == "input" for s in self._specs):
|
|
243
|
+
yield Static(
|
|
244
|
+
gettext("Every input() is managed — this script can now run with --no-input."),
|
|
245
|
+
classes="hint",
|
|
246
|
+
)
|
|
247
|
+
yield Static(self._resync_report, id="st-resync-report", classes="hint", markup=True)
|
|
248
|
+
|
|
249
|
+
def _cli_driven(self) -> bool:
|
|
250
|
+
"""Whether the run form currently comes from the script's own CLI surface — i.e.
|
|
251
|
+
nothing is managed yet AND the script parses its own arguments. (Once anything is
|
|
252
|
+
managed, plan_for_entry already serves the injected form, so there's no trap.)"""
|
|
253
|
+
if self._specs or not self._text:
|
|
254
|
+
return False
|
|
255
|
+
return argspec.read_cli(self._text) is not None
|
|
256
|
+
|
|
257
|
+
def _compose_presets(self) -> ComposeResult:
|
|
258
|
+
yield Static(gettext("Presets"), classes="section", id="st-presets-section")
|
|
259
|
+
presets = argstate.load_state(self._entry.slug)["presets"]
|
|
260
|
+
if not presets:
|
|
261
|
+
yield Static(
|
|
262
|
+
gettext("None yet — press Ctrl+S inside the run form to save one."),
|
|
263
|
+
classes="hint",
|
|
264
|
+
)
|
|
265
|
+
return
|
|
266
|
+
yield Static(gettext("Untick a preset to delete it on save:"), classes="hint")
|
|
267
|
+
for i, (name, values) in enumerate(sorted(presets.items())):
|
|
268
|
+
summary = ", ".join(f"{k}={v}" for k, v in values.items())
|
|
269
|
+
yield Checkbox(
|
|
270
|
+
f"{escape(name)} [dim]{escape(summary)}[/dim]", value=True, id=f"st-preset-{i}"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def _compose_deps(self) -> ComposeResult:
|
|
274
|
+
meta = self._entry.meta
|
|
275
|
+
if meta.kind != "python":
|
|
276
|
+
return
|
|
277
|
+
yield Static(gettext("Dependencies"), classes="section")
|
|
278
|
+
yield Input(
|
|
279
|
+
value=", ".join(meta.dependencies or []),
|
|
280
|
+
placeholder=gettext("comma separated, e.g. requests>=2,<3, rich"),
|
|
281
|
+
id="st-deps",
|
|
282
|
+
)
|
|
283
|
+
yield Input(
|
|
284
|
+
value=meta.requires_python,
|
|
285
|
+
placeholder=gettext('Python constraint, e.g. ">=3.11" (empty = automatic)'),
|
|
286
|
+
id="st-python",
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def on_mount(self) -> None:
|
|
290
|
+
self.query_one("#st-body").border_title = gettext("Script settings · %(name)s") % {
|
|
291
|
+
"name": escape(self._entry.meta.name)
|
|
292
|
+
}
|
|
293
|
+
self.call_after_refresh(setattr, self, "_dirt_armed", True)
|
|
294
|
+
if self._initial == "presets":
|
|
295
|
+
# `s` in the Library deep-links here: land the eye on the Presets section.
|
|
296
|
+
section = self.query("#st-presets-section")
|
|
297
|
+
if section:
|
|
298
|
+
self.call_after_refresh(
|
|
299
|
+
self.query_one("#st-body", VerticalScroll).scroll_to_widget, section.first()
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
@on(Input.Changed)
|
|
303
|
+
@on(Checkbox.Changed)
|
|
304
|
+
def _mark_dirty(self) -> None:
|
|
305
|
+
if self._dirt_armed:
|
|
306
|
+
self._dirty = True
|
|
307
|
+
|
|
308
|
+
# ----------------------------------------------------------------- save
|
|
309
|
+
|
|
310
|
+
def action_resync(self) -> None:
|
|
311
|
+
if self._entry.meta.kind != "python" or self._entry.meta.mode != "copy":
|
|
312
|
+
return
|
|
313
|
+
result = reconcile.edit_specs(self._text, self._specs, resync=True)
|
|
314
|
+
self._specs = result.specs
|
|
315
|
+
# Stash the outcome before recompose rebuilds the screen (updating the live Static
|
|
316
|
+
# would be lost — recompose replaces it). compose re-emits self._resync_report.
|
|
317
|
+
if result.warnings:
|
|
318
|
+
self._resync_report = "\n".join(
|
|
319
|
+
escape(reconcile.render_warning(w)) for w in result.warnings
|
|
320
|
+
)
|
|
321
|
+
else:
|
|
322
|
+
self._resync_report = gettext("Everything still matches the script.")
|
|
323
|
+
self.refresh(recompose=True)
|
|
324
|
+
|
|
325
|
+
def action_save(self) -> None:
|
|
326
|
+
entry = self._entry
|
|
327
|
+
new_name = self.query_one("#st-name", Input).value.strip()
|
|
328
|
+
if new_name and new_name != entry.meta.name:
|
|
329
|
+
try:
|
|
330
|
+
store.rename(entry.slug, new_name)
|
|
331
|
+
except store.StoreError as exc:
|
|
332
|
+
self.notify(str(exc), severity="error")
|
|
333
|
+
return # stay on the screen; nothing else is saved half-way
|
|
334
|
+
description = self.query_one("#st-desc", Input).value.strip()
|
|
335
|
+
if description != entry.meta.description:
|
|
336
|
+
store.update_description(entry.slug, description)
|
|
337
|
+
if entry.meta.kind == "python" and entry.meta.mode == "copy" and self._text:
|
|
338
|
+
new_specs = [s for row in self.query(ParamRow) if (s := row.collect()) is not None]
|
|
339
|
+
report = reconcile.reconcile(self._text, self._specs) if self._text else None
|
|
340
|
+
if report is not None:
|
|
341
|
+
for i, c in enumerate(report.new):
|
|
342
|
+
box = self.query(f"#st-new-{i}")
|
|
343
|
+
if box and box.first(Checkbox).value:
|
|
344
|
+
new_specs.append(metawriter.ParamSpec.from_candidate(c))
|
|
345
|
+
copy_path = entry.dir / "script.py"
|
|
346
|
+
copy_path.write_text(metawriter.write_params(self._text, new_specs), encoding="utf-8")
|
|
347
|
+
purged = argstate.purge_secret(entry.slug, {s.name for s in new_specs if s.secret})
|
|
348
|
+
if purged:
|
|
349
|
+
self.notify(
|
|
350
|
+
gettext("Deleted previously remembered value(s): %(names)s")
|
|
351
|
+
% {"names": ", ".join(sorted(purged))}
|
|
352
|
+
)
|
|
353
|
+
if entry.meta.kind == "python":
|
|
354
|
+
deps = pep723.split_requirements(self.query_one("#st-deps", Input).value)
|
|
355
|
+
python = self.query_one("#st-python", Input).value.strip()
|
|
356
|
+
if deps != (entry.meta.dependencies or []) or python != entry.meta.requires_python:
|
|
357
|
+
store.update_dependencies(entry.slug, deps, requires_python=python)
|
|
358
|
+
presets = argstate.load_state(entry.slug)["presets"]
|
|
359
|
+
for i, name in enumerate(sorted(presets)):
|
|
360
|
+
box = self.query(f"#st-preset-{i}")
|
|
361
|
+
if box and not box.first(Checkbox).value:
|
|
362
|
+
argstate.delete_preset(entry.slug, name)
|
|
363
|
+
self.dismiss(True)
|
|
364
|
+
|
|
365
|
+
def action_close(self) -> None:
|
|
366
|
+
if not self._dirty:
|
|
367
|
+
self.dismiss(False)
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
def _decided(discard: bool | None) -> None:
|
|
371
|
+
if discard:
|
|
372
|
+
self.dismiss(False)
|
|
373
|
+
|
|
374
|
+
self.app.push_screen(DiscardChangesModal(), _decided)
|
skit/uvman.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""UvManager: when uv is missing, auto-download a managed copy into a private bin (A9, the pattern
|
|
2
|
+
rye validated).
|
|
3
|
+
|
|
4
|
+
- PIN a known-good version rather than chasing latest (reproducible, testable).
|
|
5
|
+
- Download/extract with pure stdlib (urllib + tarfile/zipfile); no extra dependencies.
|
|
6
|
+
- Download progress goes to stderr (stdout is reserved for the script's output).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import hashlib
|
|
13
|
+
import os
|
|
14
|
+
import platform
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
import tarfile
|
|
18
|
+
import tempfile
|
|
19
|
+
import urllib.request
|
|
20
|
+
import zipfile
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from . import config
|
|
24
|
+
from .i18n import gettext
|
|
25
|
+
from .paths import private_bin_dir
|
|
26
|
+
|
|
27
|
+
# Pinned rather than chasing latest: the download URL is built from the version, so users get
|
|
28
|
+
# exactly what CI tested (reproducible); chasing latest would turn the fallback path into untested
|
|
29
|
+
# code on every upstream release and add an API lookup as a failure point. This is only the
|
|
30
|
+
# fallback for "the system has no uv" — if find_uv() locates a system uv, the system one wins.
|
|
31
|
+
# Bump: change this line -> REFRESH _UV_SHA256 below from the official `.sha256` sidecars for the new
|
|
32
|
+
# version (the pinned hashes are version-specific — a stale table would reject every download) -> run
|
|
33
|
+
# tests/test_uvman.py (URL liveness + the SKIT_NET_TESTS sidecar cross-check) -> three-platform CI green.
|
|
34
|
+
UV_VERSION = "0.11.26" # bumped 2026-07-04 (latest at the time)
|
|
35
|
+
|
|
36
|
+
# Official SHA256 of each release archive, from Astral's per-asset `.sha256` sidecars for UV_VERSION.
|
|
37
|
+
# The downloaded archive is verified against this table BEFORE extraction, so a hostile or compromised
|
|
38
|
+
# mirror (or a corrupt transfer) can never get skit to extract and execute a trojaned uv — the fetch
|
|
39
|
+
# fails closed on any mismatch, and equally if a triple is missing here. These 8 triples are exactly
|
|
40
|
+
# what _triple() can emit: {x86_64, aarch64} x {apple-darwin, unknown-linux-gnu, unknown-linux-musl,
|
|
41
|
+
# pc-windows-msvc} — the musl pair covers Alpine and other musl-libc Linux userlands (see _is_musl).
|
|
42
|
+
# MUST be refreshed alongside UV_VERSION (see the bump note above).
|
|
43
|
+
_UV_SHA256: dict[str, str] = {
|
|
44
|
+
"aarch64-apple-darwin": "8f7fbf1708399b921857bce71e1d60f0d3ccf52a30caebc1c1a2f175dce13ab6",
|
|
45
|
+
"x86_64-apple-darwin": "922b460202707dd5f4ccacbadbe7f6a546cc46e82a99bf50ca99a7977a78eddd",
|
|
46
|
+
"aarch64-unknown-linux-gnu": "befa1a59c91e96eb601b0fd9a97c03dd666f17baba644b2b4db9c59a767e387e",
|
|
47
|
+
"x86_64-unknown-linux-gnu": "6426a73c3837e6e2483ee344cbc00f36394d179afcba6183cb77437e67db4af0",
|
|
48
|
+
"aarch64-unknown-linux-musl": "47418cfdb34b1ca42e503da72631ac8c475602e2411ac6c39aa84c2373fe6324",
|
|
49
|
+
"x86_64-unknown-linux-musl": "62bf1a53501adf4083224b69b33737450ac516935f5a5e483e9dfaf2665084de",
|
|
50
|
+
"aarch64-pc-windows-msvc": "98246149741f558e25e45ecf2b0b20f34de0634269f2bf0dcb4012d4b6ba289a",
|
|
51
|
+
"x86_64-pc-windows-msvc": "4e1278ede866be6c0bf32d2f466cc6de7a9fb399ecf20c9ce2d186e52424be47",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UvDownloadError(Exception):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class UvDeclinedError(UvDownloadError):
|
|
60
|
+
"""The user explicitly declined the download. The message includes self-install guidance."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _ask_consent(dest_dir: Path) -> bool:
|
|
64
|
+
"""Ask once before downloading on an interactive terminal; non-interactive (pipe/CI) keeps A9's
|
|
65
|
+
zero-action behavior but has already been told via stderr.
|
|
66
|
+
|
|
67
|
+
- Pulling an executable from the network shouldn't be entirely silent, but the default is Y: the
|
|
68
|
+
target user is someone who "grabbed a script and just wants to run it".
|
|
69
|
+
- The prompt goes to stderr (stdout is reserved for the script's output); EOF counts as consent
|
|
70
|
+
(common in semi-interactive environments).
|
|
71
|
+
"""
|
|
72
|
+
if not (sys.stdin.isatty() and sys.stderr.isatty()):
|
|
73
|
+
return True
|
|
74
|
+
print(
|
|
75
|
+
gettext(
|
|
76
|
+
"skit needs Astral's uv to run Python scripts, but it wasn't found on this system. Download uv %(version)s into skit's private directory (%(path)s)? This won't touch your PATH or global environment. [Y/n]"
|
|
77
|
+
)
|
|
78
|
+
% {"version": UV_VERSION, "path": str(dest_dir)},
|
|
79
|
+
file=sys.stderr,
|
|
80
|
+
flush=True,
|
|
81
|
+
end=" ",
|
|
82
|
+
)
|
|
83
|
+
try:
|
|
84
|
+
answer = input()
|
|
85
|
+
except EOFError:
|
|
86
|
+
return True
|
|
87
|
+
return answer.strip().lower() not in ("n", "no")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_MUSL_LD_DIR = Path("/lib")
|
|
91
|
+
_MUSL_LD_GLOB = "ld-musl-*.so.1"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _is_musl() -> bool:
|
|
95
|
+
"""Best-effort detection of a musl libc userland (Alpine Linux and similar).
|
|
96
|
+
|
|
97
|
+
musl's dynamic linker always installs at the fixed path /lib/ld-musl-<arch>.so.1 — baked
|
|
98
|
+
into the PT_INTERP field of every musl-linked ELF binary, unlike glibc's version- and
|
|
99
|
+
distro-dependent ld-linux naming — so this file's presence is a reliable, dependency-free
|
|
100
|
+
signal. Deliberately avoids shelling out to `ldd`/`getconf` or parsing ELF headers ourselves:
|
|
101
|
+
a plain path check can't fail in a way that blocks the download. Only meaningful (and only
|
|
102
|
+
called) under sys.platform == "linux"; harmless to call elsewhere since musl never installs
|
|
103
|
+
at this path outside Linux.
|
|
104
|
+
"""
|
|
105
|
+
return any(_MUSL_LD_DIR.glob(_MUSL_LD_GLOB))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _triple() -> str:
|
|
109
|
+
machine = platform.machine().lower()
|
|
110
|
+
arch = {
|
|
111
|
+
"x86_64": "x86_64",
|
|
112
|
+
"amd64": "x86_64",
|
|
113
|
+
"arm64": "aarch64",
|
|
114
|
+
"aarch64": "aarch64",
|
|
115
|
+
}.get(machine)
|
|
116
|
+
if arch is None:
|
|
117
|
+
raise UvDownloadError(
|
|
118
|
+
gettext("Unsupported platform: %(platform)s")
|
|
119
|
+
% {"platform": f"{sys.platform}/{machine}"}
|
|
120
|
+
)
|
|
121
|
+
if sys.platform == "darwin":
|
|
122
|
+
return f"{arch}-apple-darwin"
|
|
123
|
+
if sys.platform == "win32":
|
|
124
|
+
return f"{arch}-pc-windows-msvc"
|
|
125
|
+
libc = "musl" if _is_musl() else "gnu"
|
|
126
|
+
return f"{arch}-unknown-linux-{libc}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
_UV_RELEASES = "https://github.com/astral-sh/uv/releases/download"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def download_url(triple: str | None = None) -> str:
|
|
133
|
+
triple = triple or _triple()
|
|
134
|
+
ext = "zip" if "windows" in triple else "tar.gz"
|
|
135
|
+
base = config.uv_binary_base() or _UV_RELEASES
|
|
136
|
+
return f"{base}/{UV_VERSION}/uv-{triple}.{ext}"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _fsync_path(path: Path) -> None:
|
|
140
|
+
"""Fsync `path` (a file or a directory) so its data — or, for a directory, the rename/link
|
|
141
|
+
entries within it — is durable on stable storage, not just sitting in the page cache.
|
|
142
|
+
|
|
143
|
+
Flag choice is platform-split: on POSIX, fsync() flushes the inode's dirty pages regardless of
|
|
144
|
+
the fd's access mode, and directories can never be opened for writing, so O_RDONLY works for
|
|
145
|
+
both the staged file and (only on POSIX) the directory fsync. On Windows, os.fsync maps to the
|
|
146
|
+
CRT `_commit`, which requires a *writable* fd — a read-only handle fails with EBADF — and the
|
|
147
|
+
directory fsync isn't attempted there anyway (os.open can't open a directory), so this is only
|
|
148
|
+
ever called on the staged file, which we can safely open O_RDWR.
|
|
149
|
+
"""
|
|
150
|
+
# The Windows arm can't be exercised on the POSIX mutation runner: there O_RDONLY and O_RDWR are
|
|
151
|
+
# equivalent for fsync, and a flipped condition only opens the dir fd O_RDWR (EISDIR, swallowed
|
|
152
|
+
# by the caller's contextlib.suppress) — so no mutant is observable. Hence: no mutate.
|
|
153
|
+
flags = os.O_RDWR if sys.platform == "win32" else os.O_RDONLY # pragma: no mutate
|
|
154
|
+
fd = os.open(path, flags)
|
|
155
|
+
try:
|
|
156
|
+
os.fsync(fd)
|
|
157
|
+
finally:
|
|
158
|
+
os.close(fd)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _extract_uv(archive: Path, dest_dir: Path) -> Path:
|
|
162
|
+
"""Extract the uv executable from the archive into dest_dir and return the final path.
|
|
163
|
+
|
|
164
|
+
Installs atomically (mirrors atomic.py's tmp + os.replace pattern, C7): the executable is
|
|
165
|
+
copied into a throwaway file inside dest_dir, fsync'd, and only os.replace()'d onto the real
|
|
166
|
+
`dest` path once the copy, chmod, and fsync have all fully succeeded. If the process is killed
|
|
167
|
+
or the disk fills up partway through that copy, the only thing left behind is the throwaway
|
|
168
|
+
tmp file — `dest` itself either holds the complete, verified, executable binary or does not
|
|
169
|
+
exist at all. This matters because ensure_uv_downloaded and launcher.find_uv both gate purely
|
|
170
|
+
on dest.exists(): without this, a truncated file at `dest` would look "already installed"
|
|
171
|
+
forever and skit would never self-heal by re-downloading. The same-directory tmp file also
|
|
172
|
+
makes concurrent first runs safe (last os.replace wins, never a torn file).
|
|
173
|
+
|
|
174
|
+
The fsync of the staged file's data BEFORE os.replace is what makes the "complete or absent"
|
|
175
|
+
guarantee hold across power loss, not just SIGKILL/ENOSPC: os.replace() is atomic with respect
|
|
176
|
+
to concurrent readers (they see either the old or the new dest, never a torn file), but that
|
|
177
|
+
says nothing about whether the staged file's bytes have actually reached disk yet — without
|
|
178
|
+
the fsync, a crash right after the rename commits but before the page cache writes back could
|
|
179
|
+
leave `dest` renamed-in with zero-length/garbage contents. The directory fsync afterward is a
|
|
180
|
+
best-effort extra: it durably persists the rename's directory-entry update too, but isn't
|
|
181
|
+
required for the dest-content guarantee above (and isn't attempted on Windows, where
|
|
182
|
+
directories can't be opened via os.open).
|
|
183
|
+
"""
|
|
184
|
+
exe_name = "uv.exe" if sys.platform == "win32" else "uv"
|
|
185
|
+
dest = dest_dir / exe_name
|
|
186
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
187
|
+
tmp_path = Path(tmp)
|
|
188
|
+
if archive.suffix == ".zip":
|
|
189
|
+
with zipfile.ZipFile(archive) as zf:
|
|
190
|
+
zf.extractall(tmp_path) # noqa: S202 — official release zip, extracted to a temp dir
|
|
191
|
+
else:
|
|
192
|
+
with tarfile.open(
|
|
193
|
+
archive, "r:gz"
|
|
194
|
+
) as tf: # pragma: no mutate — "r" mode auto-detects gzip compression
|
|
195
|
+
tf.extractall(tmp_path, filter="data")
|
|
196
|
+
candidates = list(tmp_path.rglob(exe_name))
|
|
197
|
+
if not candidates:
|
|
198
|
+
raise UvDownloadError(
|
|
199
|
+
gettext("No uv binary found inside the archive: %(path)s") % {"path": str(archive)}
|
|
200
|
+
)
|
|
201
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
fd, staged_name = tempfile.mkstemp(dir=dest_dir, prefix=f".{exe_name}.", suffix=".tmp")
|
|
203
|
+
os.close(fd)
|
|
204
|
+
staged = Path(staged_name)
|
|
205
|
+
try:
|
|
206
|
+
shutil.copy2(candidates[0], staged)
|
|
207
|
+
staged.chmod(staged.stat().st_mode | 0o755)
|
|
208
|
+
_fsync_path(staged) # durable on disk BEFORE the rename, not just BEFORE this return
|
|
209
|
+
os.replace(staged, dest)
|
|
210
|
+
if sys.platform != "win32": # os.open can't open a directory on Windows
|
|
211
|
+
with contextlib.suppress(OSError):
|
|
212
|
+
_fsync_path(dest_dir) # best-effort: persist the rename's directory entry too
|
|
213
|
+
except BaseException:
|
|
214
|
+
with contextlib.suppress(OSError):
|
|
215
|
+
staged.unlink()
|
|
216
|
+
raise
|
|
217
|
+
return dest
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _verify_checksum(archive: Path, triple: str) -> None:
|
|
221
|
+
"""Verify the downloaded archive against the pinned official SHA256 for its platform triple.
|
|
222
|
+
|
|
223
|
+
Fails closed: an unknown triple (no pinned hash) or any digest mismatch raises UvDownloadError, so
|
|
224
|
+
skit never extracts or runs a uv binary it could not authenticate. This is the defense that hardens
|
|
225
|
+
BOTH the GitHub path and the China-mirror / custom-host path against a compromised or corrupt
|
|
226
|
+
download — the pinned hashes come from Astral's official release, not from wherever we fetched.
|
|
227
|
+
"""
|
|
228
|
+
expected = _UV_SHA256.get(triple)
|
|
229
|
+
if expected is None: # no pin for this triple -> refuse rather than trust an unverified binary
|
|
230
|
+
raise UvDownloadError(
|
|
231
|
+
gettext("No pinned checksum for platform %(triple)s; refusing to run an unverified uv.")
|
|
232
|
+
% {"triple": triple}
|
|
233
|
+
)
|
|
234
|
+
actual = hashlib.sha256(archive.read_bytes()).hexdigest()
|
|
235
|
+
if actual != expected:
|
|
236
|
+
raise UvDownloadError(
|
|
237
|
+
gettext(
|
|
238
|
+
"Downloaded uv failed its checksum (the mirror may be compromised or the file corrupt). Expected %(expected)s, got %(actual)s."
|
|
239
|
+
)
|
|
240
|
+
% {"expected": expected, "actual": actual}
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def ensure_uv_downloaded(*, quiet: bool = False) -> str:
|
|
245
|
+
"""Download the pinned uv into the private bin and return the path. If it already exists, return
|
|
246
|
+
it directly."""
|
|
247
|
+
exe_name = "uv.exe" if sys.platform == "win32" else "uv"
|
|
248
|
+
dest = private_bin_dir() / exe_name
|
|
249
|
+
if dest.exists():
|
|
250
|
+
return str(dest)
|
|
251
|
+
if not quiet and not _ask_consent(private_bin_dir()):
|
|
252
|
+
raise UvDeclinedError(
|
|
253
|
+
gettext(
|
|
254
|
+
"Download declined. Install uv yourself (https://docs.astral.sh/uv/getting-started/installation/) and skit will pick it up automatically."
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
triple = _triple()
|
|
258
|
+
url = download_url(triple)
|
|
259
|
+
if not quiet:
|
|
260
|
+
print(
|
|
261
|
+
gettext("First run — downloading uv %(version)s…") % {"version": UV_VERSION},
|
|
262
|
+
file=sys.stderr,
|
|
263
|
+
flush=True,
|
|
264
|
+
)
|
|
265
|
+
try:
|
|
266
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
267
|
+
archive = Path(tmp) / url.rsplit("/", 1)[-1] # pragma: no mutate — [1]==[-1]
|
|
268
|
+
# A timeout is mandatory: urlretrieve has no timeout parameter, and a hung network would
|
|
269
|
+
# stall the first run forever.
|
|
270
|
+
with urllib.request.urlopen(url, timeout=60) as resp, open(archive, "wb") as f: # noqa: S310 — url is always https: the GitHub default/presets are https, the wizard rejects a non-https custom mirror (cli._prompt_uv_binary), and config.load_mirror blanks a non-https hand-edited uv_binary (falling back to the GitHub default)
|
|
271
|
+
shutil.copyfileobj(resp, f)
|
|
272
|
+
# Verify integrity BEFORE extraction/execution: a compromised mirror or corrupt transfer
|
|
273
|
+
# must fail closed here, never reach _extract_uv + chmod +x. The UvDownloadError raised on
|
|
274
|
+
# mismatch propagates as-is (below), so the user sees the checksum error, not "Failed to
|
|
275
|
+
# download".
|
|
276
|
+
_verify_checksum(archive, triple)
|
|
277
|
+
path = _extract_uv(archive, private_bin_dir())
|
|
278
|
+
except UvDownloadError:
|
|
279
|
+
raise
|
|
280
|
+
except Exception as exc: # wrap network/extraction failures uniformly
|
|
281
|
+
raise UvDownloadError(
|
|
282
|
+
gettext("Failed to download uv: %(error)s") % {"error": str(exc)}
|
|
283
|
+
) from exc
|
|
284
|
+
if not quiet:
|
|
285
|
+
print(
|
|
286
|
+
gettext("uv installed at: %(path)s") % {"path": str(path)}, file=sys.stderr, flush=True
|
|
287
|
+
)
|
|
288
|
+
return str(path)
|