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/config.py ADDED
@@ -0,0 +1,255 @@
1
+ """User configuration (config.toml) and mirror settings.
2
+
3
+ skit keeps its own settings in its config dir (`config.toml`, next to the i18n `language` key) and
4
+ **never** writes to global state (`~/.config/uv/`, the shell). Mirror settings are applied only by
5
+ overlaying environment variables onto the `uv` child processes skit spawns — and only when the user
6
+ hasn't already set the corresponding variable themselves (their explicit env always wins).
7
+
8
+ Three GFW-facing download vectors are covered:
9
+ - `pypi` -> `UV_DEFAULT_INDEX` (PEP 723 script deps resolved by `uv run`)
10
+ - `python_install` -> `UV_PYTHON_INSTALL_MIRROR` (CPython fetched by uv for a script's requires-python)
11
+ - `uv_binary` -> skit's own uv bootstrap download (see uvman)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import socket
17
+ import sys
18
+ import tomllib
19
+ from collections.abc import Mapping
20
+ from dataclasses import dataclass, replace
21
+ from typing import Any
22
+
23
+ from .atomic import atomic_write_toml, load_toml_recoverable
24
+ from .i18n import gettext
25
+ from .paths import config_dir
26
+
27
+ # NJU mirrors GitHub release assets; uv/skit just swap the github.com download prefix for these.
28
+ _GITHUB_RELEASE = "https://mirror.nju.edu.cn/github-release"
29
+ PYTHON_INSTALL_MIRROR = f"{_GITHUB_RELEASE}/astral-sh/python-build-standalone/"
30
+ UV_BINARY_MIRROR = f"{_GITHUB_RELEASE}/astral-sh/uv"
31
+
32
+ # PyPI index presets (the part users pick between; the GitHub mirrors above are shared).
33
+ PYPI_PRESETS: dict[str, str] = {
34
+ "tsinghua": "https://pypi.tuna.tsinghua.edu.cn/simple",
35
+ "aliyun": "https://mirrors.aliyun.com/pypi/simple",
36
+ "ustc": "https://pypi.mirrors.ustc.edu.cn/simple",
37
+ }
38
+
39
+ # uv env vars that REPLACE uv's default index — if the user set one of these (to a non-empty value)
40
+ # they've already chosen their PyPI vector, so skit defers. UV_INDEX / UV_EXTRA_INDEX_URL are
41
+ # deliberately excluded: they're *additive* (they don't replace the default index), so deferring on
42
+ # them would leave the GFW-blocked default index in place.
43
+ _INDEX_ENV = ("UV_DEFAULT_INDEX", "UV_INDEX_URL")
44
+ _PYTHON_MIRROR_ENV = "UV_PYTHON_INSTALL_MIRROR"
45
+
46
+
47
+ def _config_path():
48
+ return config_dir() / "config.toml"
49
+
50
+
51
+ def load_config() -> dict[str, Any]:
52
+ path = _config_path()
53
+ if not path.is_file():
54
+ return {}
55
+ try:
56
+ with open(path, "rb") as f:
57
+ return tomllib.load(f)
58
+ except (OSError, tomllib.TOMLDecodeError):
59
+ return {}
60
+
61
+
62
+ def save_config(doc: Mapping[str, Any]) -> None:
63
+ atomic_write_toml(_config_path(), dict(doc))
64
+
65
+
66
+ def _load_config_for_save() -> dict[str, Any]:
67
+ """Like load_config(), but for the read-modify-write savers (save_editor / save_mirror).
68
+
69
+ load_config() treats a present-but-corrupt config.toml the same as an absent one (returns {}),
70
+ which is fine for read-only callers. But a saver that started from that {} would then overwrite
71
+ the file with just the one key it just set — silently destroying every other saved setting with
72
+ no trace, contradicting the "preserving every other key" docstring promise. So here: if the file
73
+ exists but fails to parse, back it up first (config.toml.bak) and warn on stderr, so the save can
74
+ still proceed but nothing is lost without a trace.
75
+
76
+ The backup mechanics (is-file / try-parse / copy2-on-failure) live in atomic.load_toml_recoverable
77
+ so i18n.set_language's own read-modify-write can share them: i18n can't import this module (config
78
+ already imports gettext from i18n, so the reverse would be an import cycle), but both safely
79
+ import the neutral atomic module.
80
+ """
81
+ path = _config_path()
82
+ recovery = load_toml_recoverable(path)
83
+ if recovery.corrupt:
84
+ if recovery.backup_path is not None:
85
+ print(
86
+ gettext(
87
+ "%(path)s is corrupt and could not be parsed. It has been backed up to "
88
+ "%(backup)s before this change; recover any lost settings from that file."
89
+ )
90
+ % {"path": str(path), "backup": str(recovery.backup_path)},
91
+ file=sys.stderr,
92
+ )
93
+ else:
94
+ print(
95
+ gettext(
96
+ "%(path)s is corrupt and could not be parsed, and it could not be backed up "
97
+ "either; the settings it contained will be lost when this change is saved."
98
+ )
99
+ % {"path": str(path)},
100
+ file=sys.stderr,
101
+ )
102
+ return recovery.doc
103
+
104
+
105
+ def is_configured() -> bool:
106
+ """True once the user has been through setup (config.toml exists), so first-run setup runs once."""
107
+ return _config_path().is_file()
108
+
109
+
110
+ def mirror_configured() -> bool:
111
+ """True once a [mirror] section has been written — the marker that the first-run mirror offer
112
+ has already happened. Distinct from is_configured(): setting a language also writes config.toml,
113
+ but must NOT suppress the mirror offer (that's what would happen if the gate keyed on the file's
114
+ mere existence)."""
115
+ return "mirror" in load_config()
116
+
117
+
118
+ def load_editor() -> str:
119
+ """The user's configured editor command (config.toml `editor`), or "" when unset. editor.py
120
+ falls back to $VISUAL / $EDITOR / a platform default when this is empty."""
121
+ value = load_config().get("editor", "") # pragma: no mutate — default guarded by isinstance
122
+ return value if isinstance(value, str) else ""
123
+
124
+
125
+ def save_editor(command: str) -> None:
126
+ """Persist (or clear, when empty) the editor command, preserving every other key."""
127
+ doc = _load_config_for_save()
128
+ if command.strip():
129
+ doc["editor"] = command.strip()
130
+ else:
131
+ doc.pop("editor", None)
132
+ save_config(doc)
133
+
134
+
135
+ FORM_STYLES = ("tui", "plain")
136
+
137
+
138
+ def load_form() -> str:
139
+ """Interactive-form style: "tui" (inline mini-form, the default) or "plain" (line
140
+ prompts). Governs every interactive flow (run form, add panel), not just run."""
141
+ value = load_config().get("form", "") # pragma: no mutate — normalized below
142
+ return value if value in FORM_STYLES else "tui"
143
+
144
+
145
+ def save_form(style: str) -> None:
146
+ """Persist the form style, preserving every other key."""
147
+ doc = _load_config_for_save()
148
+ doc["form"] = style
149
+ save_config(doc)
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class MirrorConfig:
154
+ enabled: bool = False
155
+ pypi: str = ""
156
+ python_install: str = ""
157
+ uv_binary: str = ""
158
+
159
+
160
+ def load_mirror() -> MirrorConfig:
161
+ section = load_config().get(
162
+ "mirror", {}
163
+ ) # pragma: no mutate — isinstance check below normalizes any default
164
+ if not isinstance(section, dict):
165
+ return MirrorConfig()
166
+
167
+ def _url(key: str) -> str:
168
+ # Type-harden a hand-edited config: only a real string is a URL; anything else (int, bool,
169
+ # ...) is treated as blank rather than str()-coerced into a bogus value like "123".
170
+ value = section.get(
171
+ key, ""
172
+ ) # pragma: no mutate — isinstance check below normalizes any default
173
+ return value if isinstance(value, str) else ""
174
+
175
+ def _https_url(key: str) -> str:
176
+ # uv_binary names an executable skit downloads, chmod +x's, and runs, so it MUST be https —
177
+ # a plain-http mirror would let a MITM swap in a trojaned uv. The wizard rejects non-https
178
+ # interactively; a hand-edited non-https value is silently blanked here so uv_binary_base()
179
+ # falls back to the GitHub default (checksum verification in uvman is the further backstop).
180
+ url = _url(key)
181
+ return url if url.startswith("https://") else ""
182
+
183
+ return MirrorConfig(
184
+ # Require a genuine bool: a stray `enabled = "false"` would otherwise be truthy and silently
185
+ # invert the user's intent (bool("false") is True).
186
+ enabled=section.get("enabled") is True,
187
+ pypi=_url("pypi"),
188
+ python_install=_url("python_install"),
189
+ uv_binary=_https_url("uv_binary"),
190
+ )
191
+
192
+
193
+ def save_mirror(mirror: MirrorConfig) -> None:
194
+ """Persist the [mirror] section, preserving every other key (e.g. language)."""
195
+ doc = _load_config_for_save()
196
+ doc["mirror"] = {
197
+ "enabled": mirror.enabled,
198
+ "pypi": mirror.pypi,
199
+ "python_install": mirror.python_install,
200
+ "uv_binary": mirror.uv_binary,
201
+ }
202
+ save_config(doc)
203
+
204
+
205
+ def preset(name: str) -> MirrorConfig:
206
+ """Build an enabled MirrorConfig for a PyPI provider, sharing the NJU GitHub-release mirrors."""
207
+ return MirrorConfig(
208
+ enabled=True,
209
+ pypi=PYPI_PRESETS[name],
210
+ python_install=PYTHON_INSTALL_MIRROR,
211
+ uv_binary=UV_BINARY_MIRROR,
212
+ )
213
+
214
+
215
+ def disable() -> None:
216
+ """Turn mirrors off (e.g. travelling abroad) without discarding the saved URLs."""
217
+ save_mirror(replace(load_mirror(), enabled=False))
218
+
219
+
220
+ def mirror_env(base_env: Mapping[str, str]) -> dict[str, str]:
221
+ """The env overlay to inject into uv child processes.
222
+
223
+ Returns only the variables the user has NOT already set themselves (their env wins — the "defer"
224
+ rule). Empty when mirrors are disabled.
225
+ """
226
+ mirror = load_mirror()
227
+ if not mirror.enabled:
228
+ return {}
229
+ overlay: dict[str, str] = {}
230
+ # Defer on a *truthy* user value only: an empty `UV_INDEX_URL=""` means "unset", so it must not
231
+ # suppress the mirror (presence-based defer would wrongly leave the blocked default in place).
232
+ if mirror.pypi and not any(base_env.get(v) for v in _INDEX_ENV):
233
+ overlay["UV_DEFAULT_INDEX"] = mirror.pypi
234
+ if mirror.python_install and not base_env.get(_PYTHON_MIRROR_ENV):
235
+ overlay[_PYTHON_MIRROR_ENV] = mirror.python_install
236
+ return overlay
237
+
238
+
239
+ def uv_binary_base() -> str:
240
+ """The base URL for skit's own uv-binary bootstrap download, or "" to use the GitHub default."""
241
+ mirror = load_mirror()
242
+ return mirror.uv_binary if mirror.enabled else ""
243
+
244
+
245
+ def looks_blocked(timeout: float = 2.5) -> bool:
246
+ """Heuristic for "is this network likely behind the GFW?" — True if PyPI or GitHub can't be
247
+ reached within `timeout` seconds. Used only to *offer* mirror setup on first run; never decides
248
+ anything on its own."""
249
+ for host in ("pypi.org", "github.com"):
250
+ try:
251
+ with socket.create_connection((host, 443), timeout=timeout):
252
+ pass
253
+ except OSError:
254
+ return True
255
+ return False
skit/editor.py ADDED
@@ -0,0 +1,77 @@
1
+ """Launch the user's editor to write or edit a script's source.
2
+
3
+ Resolution precedence for which editor to run: config.toml `editor` > $VISUAL > $EDITOR > a platform
4
+ default (`notepad` on Windows, `vi` elsewhere). The configured value may carry arguments
5
+ (e.g. `code --wait`), split with shlex; the file path is appended as the final argument.
6
+
7
+ Headless: imports neither CLI nor TUI, so store/launcher paths can use it too.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import shlex
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from . import config
19
+ from .i18n import gettext
20
+
21
+
22
+ class EditorError(Exception):
23
+ """The editor could not be launched (e.g. the command was not found on PATH)."""
24
+
25
+
26
+ def _platform_default() -> str:
27
+ return "notepad" if sys.platform == "win32" else "vi"
28
+
29
+
30
+ def resolve_editor() -> list[str]:
31
+ """The editor command as an argv prefix (the file path is appended by open_in_editor).
32
+
33
+ Falls back to the platform default when nothing is configured and neither env var is set. A
34
+ candidate that is blank or whitespace-only (e.g. VISUAL=" ") is treated as unset so the next
35
+ candidate in the precedence chain — not the platform default — gets a chance.
36
+ """
37
+ visual = os.environ.get("VISUAL", "") # pragma: no mutate — default "" vs None are both falsy
38
+ editor_env = os.environ.get(
39
+ "EDITOR", ""
40
+ ) # pragma: no mutate — default "" vs None are both falsy
41
+ candidates = (config.load_editor(), visual, editor_env)
42
+ raw = next((c.strip() for c in candidates if c.strip()), _platform_default())
43
+ try:
44
+ parts = shlex.split(raw, posix=sys.platform != "win32")
45
+ except ValueError:
46
+ # An unbalanced-quote value is unusable as a parsed command; treat the whole thing as the
47
+ # program name rather than crashing.
48
+ parts = [raw]
49
+ if sys.platform == "win32":
50
+ # Non-posix shlex preserves backslashes (so C:\tools\edit.exe survives intact) but it also
51
+ # keeps a token's surrounding double-quotes literally. A quoted spaced path (the normal way
52
+ # to write one on Windows, e.g. "C:\Program Files\...\Code.exe") would otherwise reach
53
+ # CreateProcess with the quote characters baked into the filename, which it can never find.
54
+ # Strip one matching pair of surrounding quotes per token to fix that.
55
+ parts = [p[1:-1] if len(p) >= 2 and p[0] == p[-1] == '"' else p for p in parts]
56
+ return parts or [_platform_default()]
57
+
58
+
59
+ def open_in_editor(path: Path) -> int:
60
+ """Open `path` in the resolved editor and block until it exits; return the editor's exit code.
61
+
62
+ Raises EditorError only when the editor cannot be launched at all (a non-zero exit is returned,
63
+ not raised — some editors exit non-zero on an unmodified close).
64
+ """
65
+ argv = [*resolve_editor(), str(path)]
66
+ try:
67
+ # check=False is subprocess.run's default; keeping it explicit. noqa: S603 — argv from the
68
+ # user-configured editor.
69
+ completed = subprocess.run(argv, check=False) # noqa: S603 # pragma: no mutate
70
+ except OSError as exc:
71
+ raise EditorError(
72
+ gettext(
73
+ "Could not launch the editor (%(cmd)s): %(error)s. Set one with: skit config editor <cmd>"
74
+ )
75
+ % {"cmd": " ".join(argv[:-1]), "error": str(exc)}
76
+ ) from exc
77
+ return completed.returncode