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