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