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/models.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Data models: meta.toml (per-script, self-describing) and registry index entries.
|
|
2
|
+
|
|
3
|
+
meta.toml is the source of truth; registry.toml is only an index (rebuildable via doctor --rebuild).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
from .i18n import gettext
|
|
14
|
+
|
|
15
|
+
SCHEMA_VERSION = 1
|
|
16
|
+
|
|
17
|
+
Kind = Literal["python", "exe", "command"]
|
|
18
|
+
Mode = Literal["copy", "reference"]
|
|
19
|
+
Workdir = str # "origin" | "store" | "invoke" | absolute path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ScriptMetaError(ValueError):
|
|
23
|
+
"""meta.toml parsed as valid TOML but is missing a key ScriptMeta requires.
|
|
24
|
+
|
|
25
|
+
Distinguished from a bare KeyError so store.py's corruption handling (which already treats
|
|
26
|
+
malformed/unreadable meta.toml as "corrupt, skip and let doctor --rebuild report it") can catch
|
|
27
|
+
this alongside tomllib.TOMLDecodeError instead of a crash escaping list/resolve/doctor.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ScriptMeta:
|
|
33
|
+
"""Maps to scripts/<slug>/meta.toml."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
kind: Kind
|
|
37
|
+
mode: Mode = "copy"
|
|
38
|
+
source: str = "" # provenance: original path at add time (also for exe/command)
|
|
39
|
+
source_hash: str = "" # "sha256:…"; empty for command entries
|
|
40
|
+
added_at: str = ""
|
|
41
|
+
workdir: Workdir = "origin"
|
|
42
|
+
description: str = ""
|
|
43
|
+
template: str = "" # command template when kind=command
|
|
44
|
+
dependencies: list[str] | None = None # recorded deps when reference mode can't inject PEP 723
|
|
45
|
+
requires_python: str = "" # same; in copy mode both are written into the copy's PEP 723 block
|
|
46
|
+
params: list[str] | None = (
|
|
47
|
+
None # named placeholders in a command template (prompted before run)
|
|
48
|
+
)
|
|
49
|
+
schema: int = SCHEMA_VERSION
|
|
50
|
+
|
|
51
|
+
def to_toml_dict(self) -> dict[str, Any]:
|
|
52
|
+
d: dict[str, Any] = {
|
|
53
|
+
"schema": self.schema,
|
|
54
|
+
"name": self.name,
|
|
55
|
+
"kind": self.kind,
|
|
56
|
+
"mode": self.mode,
|
|
57
|
+
"source": self.source,
|
|
58
|
+
"source_hash": self.source_hash,
|
|
59
|
+
"added_at": self.added_at,
|
|
60
|
+
"workdir": self.workdir,
|
|
61
|
+
"description": self.description,
|
|
62
|
+
}
|
|
63
|
+
if self.template:
|
|
64
|
+
d["template"] = self.template
|
|
65
|
+
if self.dependencies:
|
|
66
|
+
d["dependencies"] = self.dependencies
|
|
67
|
+
if self.requires_python:
|
|
68
|
+
d["requires_python"] = self.requires_python
|
|
69
|
+
if self.params:
|
|
70
|
+
d["params"] = self.params
|
|
71
|
+
return d
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_toml_dict(cls, d: dict[str, Any]) -> ScriptMeta:
|
|
75
|
+
"""Validate at the model boundary: a meta.toml can be well-formed TOML yet structurally
|
|
76
|
+
invalid (missing keys, or a scalar where a list is required). Every such case must raise
|
|
77
|
+
ScriptMetaError — never a raw KeyError/TypeError — so store.py's corruption handling
|
|
78
|
+
(_META_CORRUPTION) can catch it uniformly instead of it crashing list/resolve/doctor."""
|
|
79
|
+
missing = [key for key in ("name", "kind") if key not in d]
|
|
80
|
+
if missing:
|
|
81
|
+
raise ScriptMetaError(
|
|
82
|
+
gettext("meta.toml is missing required key(s): %(keys)s")
|
|
83
|
+
% {"keys": ", ".join(missing)}
|
|
84
|
+
)
|
|
85
|
+
invalid = [key for key in ("name", "kind") if not isinstance(d[key], str)]
|
|
86
|
+
invalid += [
|
|
87
|
+
key
|
|
88
|
+
for key in ("dependencies", "params")
|
|
89
|
+
if d.get(key) is not None and not isinstance(d[key], list)
|
|
90
|
+
]
|
|
91
|
+
if invalid:
|
|
92
|
+
raise ScriptMetaError(
|
|
93
|
+
gettext("meta.toml has invalid type for key(s): %(keys)s")
|
|
94
|
+
% {"keys": ", ".join(invalid)}
|
|
95
|
+
)
|
|
96
|
+
return cls(
|
|
97
|
+
name=d["name"],
|
|
98
|
+
kind=d["kind"],
|
|
99
|
+
mode=d.get("mode", "copy"),
|
|
100
|
+
source=d.get("source", ""),
|
|
101
|
+
source_hash=d.get("source_hash", ""),
|
|
102
|
+
added_at=d.get("added_at", ""),
|
|
103
|
+
workdir=d.get("workdir", "origin"),
|
|
104
|
+
description=d.get("description", ""),
|
|
105
|
+
template=d.get("template", ""),
|
|
106
|
+
dependencies=list(d["dependencies"]) if d.get("dependencies") else None,
|
|
107
|
+
requires_python=d.get("requires_python", ""),
|
|
108
|
+
params=list(d["params"]) if d.get("params") else None,
|
|
109
|
+
schema=d.get("schema", SCHEMA_VERSION),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class RegistryEntry:
|
|
115
|
+
"""A single index row in registry.toml."""
|
|
116
|
+
|
|
117
|
+
name: str
|
|
118
|
+
slug: str
|
|
119
|
+
kind: Kind
|
|
120
|
+
description: str = ""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class Entry:
|
|
125
|
+
"""Combined view: index + full meta + directory path. The primary object of the Core API."""
|
|
126
|
+
|
|
127
|
+
slug: str
|
|
128
|
+
meta: ScriptMeta
|
|
129
|
+
dir: Path
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def script_path(self) -> Path:
|
|
133
|
+
"""The in-store script in copy mode; the original path in reference mode."""
|
|
134
|
+
if self.meta.mode == "reference":
|
|
135
|
+
return Path(self.meta.source)
|
|
136
|
+
return self.dir / ("script.py" if self.meta.kind == "python" else "payload")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def now_iso() -> str:
|
|
140
|
+
return datetime.now(UTC).replace(microsecond=0).isoformat()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def slugify(name: str) -> str:
|
|
144
|
+
out: list[str] = []
|
|
145
|
+
prev_dash = False # pragma: no mutate — falsy-equivalent init; `and out` guard hides it while out is empty
|
|
146
|
+
for ch in name.strip().lower():
|
|
147
|
+
if ch.isalnum():
|
|
148
|
+
out.append(ch)
|
|
149
|
+
prev_dash = False # pragma: no mutate — falsy-equivalent; only read via truthiness
|
|
150
|
+
elif not prev_dash and out:
|
|
151
|
+
out.append("-")
|
|
152
|
+
prev_dash = True
|
|
153
|
+
slug = "".join(out).strip("-") # pragma: no mutate — text already lowercased
|
|
154
|
+
return slug or "script"
|
skit/paths.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Directory resolution. Everything goes through platformdirs; overridable via env vars (for tests).
|
|
2
|
+
|
|
3
|
+
SKIT_DATA_DIR / SKIT_STATE_DIR / SKIT_CONFIG_DIR override the matching root directory.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from platformdirs import user_config_dir, user_data_dir, user_state_dir
|
|
12
|
+
|
|
13
|
+
_APP = "skit"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def data_dir() -> Path:
|
|
17
|
+
override = os.environ.get("SKIT_DATA_DIR")
|
|
18
|
+
return Path(override) if override else Path(user_data_dir(_APP))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def state_dir() -> Path:
|
|
22
|
+
override = os.environ.get("SKIT_STATE_DIR")
|
|
23
|
+
return Path(override) if override else Path(user_state_dir(_APP))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def config_dir() -> Path:
|
|
27
|
+
override = os.environ.get("SKIT_CONFIG_DIR")
|
|
28
|
+
return Path(override) if override else Path(user_config_dir(_APP))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def scripts_dir() -> Path:
|
|
32
|
+
return data_dir() / "scripts"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def registry_path() -> Path:
|
|
36
|
+
return data_dir() / "registry.toml"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def private_bin_dir() -> Path:
|
|
40
|
+
return data_dir() / "bin"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def values_dir() -> Path:
|
|
44
|
+
return state_dir() / "values"
|
skit/pep723.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""PEP 723 inline script metadata: parse, generate, inject (comment-only edits, A5).
|
|
2
|
+
|
|
3
|
+
- parse_block(): read the `# /// script` block from a script.
|
|
4
|
+
- suggest_dependencies(): walk imports via AST, diff against stdlib, suggest third-party deps.
|
|
5
|
+
- inject_block(): insert the completed block into the script text (after shebang / encoding lines).
|
|
6
|
+
The injected content is pure comments and changes no semantics, so a copy-mode copy just runs.
|
|
7
|
+
|
|
8
|
+
This module is headless; it imports neither CLI nor TUI.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
import tomllib
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
_BLOCK_RE = re.compile(
|
|
20
|
+
# The closer's trailing whitespace is restricted to horizontal whitespace ([^\S\n], i.e. space /
|
|
21
|
+
# tab / \r for CRLF) so it cannot cross a line boundary and swallow blank lines that follow the
|
|
22
|
+
# block — `\s*$` would otherwise match greedily across newlines and absorb them into the block,
|
|
23
|
+
# deleting them on rewrite (metawriter/pep723 both rebuild the text from m.end()).
|
|
24
|
+
r"(?m)^# /// script\s*$\n(?P<body>(?:^#(?:| .*)$\n)*?)^# ///[^\S\n]*$\n?",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Import name -> PyPI distribution name, for the common cases where they differ. Without this an
|
|
28
|
+
# `import PIL` becomes a `PIL` dependency, which uv can't resolve (the package is `Pillow`). Curated
|
|
29
|
+
# and stdlib-only (a static dict, no network / importlib.metadata probing): we only rewrite names we
|
|
30
|
+
# are sure about and leave everything else untouched.
|
|
31
|
+
_IMPORT_TO_PACKAGE = {
|
|
32
|
+
"PIL": "Pillow",
|
|
33
|
+
"cv2": "opencv-python",
|
|
34
|
+
"yaml": "PyYAML",
|
|
35
|
+
"bs4": "beautifulsoup4",
|
|
36
|
+
"sklearn": "scikit-learn",
|
|
37
|
+
"skimage": "scikit-image",
|
|
38
|
+
"dotenv": "python-dotenv",
|
|
39
|
+
"dateutil": "python-dateutil",
|
|
40
|
+
"serial": "pyserial",
|
|
41
|
+
"jwt": "PyJWT",
|
|
42
|
+
"docx": "python-docx",
|
|
43
|
+
"pptx": "python-pptx",
|
|
44
|
+
"fitz": "PyMuPDF",
|
|
45
|
+
"OpenSSL": "pyOpenSSL",
|
|
46
|
+
"Crypto": "pycryptodome",
|
|
47
|
+
"Cryptodome": "pycryptodomex",
|
|
48
|
+
"git": "GitPython",
|
|
49
|
+
"attr": "attrs",
|
|
50
|
+
"slugify": "python-slugify",
|
|
51
|
+
"usb": "pyusb",
|
|
52
|
+
"win32com": "pywin32",
|
|
53
|
+
"win32api": "pywin32",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _strip_comment_prefix(line: str) -> str:
|
|
58
|
+
"""_BLOCK_RE guarantees "#" or "# ..." lines; both branches agree on a bare "#"."""
|
|
59
|
+
if line.startswith("# "): # pragma: no mutate — TOML tolerates the extra leading space
|
|
60
|
+
return line[2:]
|
|
61
|
+
return line[1:] # pragma: no mutate — only reached on a bare "#"; [1:] == [2:] == ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_block(text: str) -> dict[str, Any] | None:
|
|
65
|
+
"""Return the PEP 723 metadata dict (dependencies / requires-python); None if no block."""
|
|
66
|
+
m = _BLOCK_RE.search(text)
|
|
67
|
+
if not m:
|
|
68
|
+
return None
|
|
69
|
+
lines = [_strip_comment_prefix(line) for line in m.group("body").splitlines()]
|
|
70
|
+
try:
|
|
71
|
+
return tomllib.loads("\n".join(lines))
|
|
72
|
+
except tomllib.TOMLDecodeError:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def has_block(text: str) -> bool:
|
|
77
|
+
return _BLOCK_RE.search(text) is not None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def suggest_dependencies(text: str) -> list[str]:
|
|
81
|
+
"""Scan imports, return the top-level module names that look third-party (sorted, deduped).
|
|
82
|
+
|
|
83
|
+
Returns an empty list on syntax errors.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
tree = ast.parse(text)
|
|
87
|
+
except SyntaxError:
|
|
88
|
+
return []
|
|
89
|
+
found: set[str] = set()
|
|
90
|
+
for node in ast.walk(tree):
|
|
91
|
+
if isinstance(node, ast.Import):
|
|
92
|
+
for alias in node.names:
|
|
93
|
+
found.add(alias.name.split(".")[0])
|
|
94
|
+
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
|
95
|
+
found.add(node.module.split(".")[0])
|
|
96
|
+
stdlib = sys.stdlib_module_names
|
|
97
|
+
third_party = (m for m in found if m not in stdlib and not m.startswith("_"))
|
|
98
|
+
# Map known import names to their real PyPI distribution names, then dedupe again in case two
|
|
99
|
+
# imports collapse to the same package (e.g. `Crypto` and its submodules -> pycryptodome).
|
|
100
|
+
return sorted({_IMPORT_TO_PACKAGE.get(m, m) for m in third_party})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _next_nonspace(text: str, pos: int) -> str:
|
|
104
|
+
"""The first non-whitespace character at or after pos, or "" when none is left."""
|
|
105
|
+
for ch in text[pos:]:
|
|
106
|
+
if not ch.isspace():
|
|
107
|
+
return ch
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def split_requirements(text: str) -> list[str]:
|
|
112
|
+
"""Split a comma-separated requirement list without shredding PEP 508 internals.
|
|
113
|
+
|
|
114
|
+
A single requirement may itself contain commas — in a version-specifier list
|
|
115
|
+
(``requests>=2,<3``), an extras bracket (``pkg[security,socks]``), a parenthesized
|
|
116
|
+
specifier (``foo (>=1.0,<2.0)``), or a quoted marker value
|
|
117
|
+
(``x; sys_platform in "linux,darwin"``). A naive ``split(",")`` turns
|
|
118
|
+
``requests>=2,<3`` into the two bogus items ``requests>=2`` and ``<3``.
|
|
119
|
+
|
|
120
|
+
A comma separates two requirements only when it sits outside brackets/quotes AND
|
|
121
|
+
what follows starts a new requirement: PEP 508 names begin with a letter or digit,
|
|
122
|
+
while a continued specifier clause always begins with an operator (``<`` ``>``
|
|
123
|
+
``=`` ``!`` ``~``). A trailing comma (nothing follows) also terminates. Known
|
|
124
|
+
limitation: a direct-URL reference whose URL itself contains a comma is not
|
|
125
|
+
supported (rare enough that guessing would be worse).
|
|
126
|
+
"""
|
|
127
|
+
parts: list[str] = []
|
|
128
|
+
buf: list[str] = []
|
|
129
|
+
depth = 0
|
|
130
|
+
quote = "" # pragma: no mutate — falsy-equivalent sentinel (""/None): only read via truthiness
|
|
131
|
+
for i, ch in enumerate(text):
|
|
132
|
+
if quote:
|
|
133
|
+
buf.append(ch)
|
|
134
|
+
if ch == quote:
|
|
135
|
+
quote = "" # pragma: no mutate — falsy-equivalent sentinel, as above
|
|
136
|
+
continue
|
|
137
|
+
if ch in ('"', "'"):
|
|
138
|
+
quote = ch
|
|
139
|
+
elif ch in "([":
|
|
140
|
+
depth += 1
|
|
141
|
+
elif ch in ")]":
|
|
142
|
+
depth -= 1
|
|
143
|
+
elif ch == "," and depth == 0:
|
|
144
|
+
nxt = _next_nonspace(text, i + 1)
|
|
145
|
+
if not nxt or nxt.isalnum():
|
|
146
|
+
parts.append("".join(buf))
|
|
147
|
+
buf = []
|
|
148
|
+
continue
|
|
149
|
+
buf.append(ch)
|
|
150
|
+
parts.append("".join(buf))
|
|
151
|
+
return [p.strip() for p in parts if p.strip()]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _toml_str(value: str) -> str:
|
|
155
|
+
r"""A TOML basic string for the PEP 723 comment block. A raw double quote or backslash
|
|
156
|
+
would terminate/mangle the string — a PEP 508 marker like `; python_version >= "3.8"`
|
|
157
|
+
carries embedded quotes — and any newline-class character would break out of the single
|
|
158
|
+
comment line the block is built from, so the rewritten block fails to re-parse and the
|
|
159
|
+
dependency list is silently lost. (Kept local: metawriter imports pep723, so pep723 must
|
|
160
|
+
not import back for the shared _toml_str.)"""
|
|
161
|
+
out = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
162
|
+
out = out.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
|
163
|
+
|
|
164
|
+
def _escape(ch: str) -> str:
|
|
165
|
+
if ch < " " or ch in ("\x7f", "\x85", "\u2028", "\u2029"): # pragma: no mutate
|
|
166
|
+
return f"\\u{ord(ch):04X}"
|
|
167
|
+
return ch
|
|
168
|
+
|
|
169
|
+
return '"' + "".join(_escape(ch) for ch in out) + '"'
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def build_block(dependencies: list[str], requires_python: str = "") -> str:
|
|
173
|
+
"""Generate the PEP 723 block text (including the trailing newline)."""
|
|
174
|
+
lines = ["# /// script"]
|
|
175
|
+
if requires_python:
|
|
176
|
+
lines.append(f"# requires-python = {_toml_str(requires_python)}")
|
|
177
|
+
if dependencies:
|
|
178
|
+
lines.append("# dependencies = [")
|
|
179
|
+
lines.extend(f"# {_toml_str(dep)}," for dep in dependencies)
|
|
180
|
+
lines.append("# ]")
|
|
181
|
+
else:
|
|
182
|
+
lines.append("# dependencies = []")
|
|
183
|
+
lines.append("# ///")
|
|
184
|
+
return "\n".join(lines) + "\n"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _structural_bracket_delta(s: str) -> int:
|
|
188
|
+
"""Net count of structural `[` minus `]` in a line of TOML content (already stripped of its
|
|
189
|
+
leading PEP 723 `#`/`# ` comment marker).
|
|
190
|
+
|
|
191
|
+
A naive `s.count("[") - s.count("]")` over the raw text is wrong whenever a bracket lives
|
|
192
|
+
inside a TOML string value (e.g. a dependency string `"foo]bar"`) or inside an inline `#`
|
|
193
|
+
comment (e.g. `# pin later [`) — those brackets are data/prose, not array syntax, and must
|
|
194
|
+
not perturb the array-nesting depth used to find the real closing `]`.
|
|
195
|
+
|
|
196
|
+
This walks the line char-by-char, tracking whether we are inside a quoted string (TOML basic
|
|
197
|
+
`"..."` strings, where `\\` escapes the next char, or literal `'...'` strings, which have no
|
|
198
|
+
escapes) and stops counting entirely once an unquoted `#` starts an inline comment. Only
|
|
199
|
+
brackets seen outside a string and outside a comment are counted, which is exactly what makes
|
|
200
|
+
them structural TOML array syntax.
|
|
201
|
+
"""
|
|
202
|
+
delta = 0
|
|
203
|
+
quote: str | None = None
|
|
204
|
+
i = 0
|
|
205
|
+
n = len(s)
|
|
206
|
+
while i < n:
|
|
207
|
+
ch = s[i]
|
|
208
|
+
if quote:
|
|
209
|
+
if quote == '"' and ch == "\\":
|
|
210
|
+
i += 2 # pragma: no mutate — skip the escaped char; only reachable via `"` strings
|
|
211
|
+
continue
|
|
212
|
+
if ch == quote:
|
|
213
|
+
quote = None
|
|
214
|
+
i += 1
|
|
215
|
+
continue
|
|
216
|
+
if ch in ("'", '"'):
|
|
217
|
+
quote = ch
|
|
218
|
+
elif ch == "#":
|
|
219
|
+
break # rest of the line is an inline TOML comment; nothing after this is structural
|
|
220
|
+
elif ch == "[":
|
|
221
|
+
delta += 1
|
|
222
|
+
elif ch == "]":
|
|
223
|
+
delta -= 1
|
|
224
|
+
i += 1
|
|
225
|
+
return delta
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def set_dependencies(text: str, dependencies: list[str], requires_python: str = "") -> str:
|
|
229
|
+
"""Update the dependencies / requires-python lines in an existing block, keeping the rest
|
|
230
|
+
(such as [tool.skit]). With no block this behaves like inject_block. Still comment-only (A5)."""
|
|
231
|
+
m = _BLOCK_RE.search(text)
|
|
232
|
+
if not m:
|
|
233
|
+
return inject_block(text, dependencies, requires_python)
|
|
234
|
+
body_lines = m.group("body").splitlines()
|
|
235
|
+
kept: list[str] = []
|
|
236
|
+
in_deps_array = False # pragma: no mutate — only read via truthiness (`if in_deps_array`)
|
|
237
|
+
depth = 0
|
|
238
|
+
for line in body_lines:
|
|
239
|
+
stripped = _strip_comment_prefix(line)
|
|
240
|
+
if in_deps_array:
|
|
241
|
+
# Track bracket nesting depth rather than requiring the closer to be alone on its line:
|
|
242
|
+
# a hand-edited array may close on the same line as the last element (`"requests"]`) or
|
|
243
|
+
# carry a trailing comment (`] # pin`). Only structural brackets count, so
|
|
244
|
+
# `"pkg[extra]"` requirement strings, a stray `]` inside a string (`"foo]bar"`), and a
|
|
245
|
+
# comment containing a bracket (`# pin later [`) can't desync the depth.
|
|
246
|
+
depth += _structural_bracket_delta(stripped)
|
|
247
|
+
if depth <= 0:
|
|
248
|
+
in_deps_array = False # pragma: no mutate — only read via truthiness
|
|
249
|
+
continue
|
|
250
|
+
s = stripped.strip()
|
|
251
|
+
if s.startswith("requires-python"):
|
|
252
|
+
continue
|
|
253
|
+
if s.startswith("dependencies"):
|
|
254
|
+
# Multi-line array detection: `[` opened but not closed on the same line
|
|
255
|
+
# (including a `[ # comment` trailing-comment form). Structural-only counting here
|
|
256
|
+
# too, for the same reasons as the in-array depth tracking above.
|
|
257
|
+
net = _structural_bracket_delta(s)
|
|
258
|
+
if net > 0:
|
|
259
|
+
in_deps_array = True
|
|
260
|
+
depth = net
|
|
261
|
+
continue
|
|
262
|
+
kept.append(line)
|
|
263
|
+
new_head: list[str] = []
|
|
264
|
+
if requires_python:
|
|
265
|
+
new_head.append(f"# requires-python = {_toml_str(requires_python)}")
|
|
266
|
+
if dependencies:
|
|
267
|
+
new_head.append("# dependencies = [")
|
|
268
|
+
new_head.extend(f"# {_toml_str(dep)}," for dep in dependencies)
|
|
269
|
+
new_head.append("# ]")
|
|
270
|
+
else:
|
|
271
|
+
new_head.append("# dependencies = []")
|
|
272
|
+
new_body = "\n".join(new_head + kept) + "\n"
|
|
273
|
+
new_block = "# /// script\n" + new_body + "# ///\n"
|
|
274
|
+
return text[: m.start()] + new_block + text[m.end() :]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def inject_block(text: str, dependencies: list[str], requires_python: str = "") -> str:
|
|
278
|
+
"""Insert the block at the top of the script (after shebang / coding declarations).
|
|
279
|
+
If a block already exists, return the text unchanged."""
|
|
280
|
+
if has_block(text):
|
|
281
|
+
return text
|
|
282
|
+
lines = text.splitlines(keepends=True)
|
|
283
|
+
insert_at = 0
|
|
284
|
+
if lines and lines[0].startswith("#!"):
|
|
285
|
+
insert_at = 1
|
|
286
|
+
if len(lines) > insert_at and re.match(r"^#.*coding[:=]", lines[insert_at]):
|
|
287
|
+
insert_at += 1
|
|
288
|
+
block = build_block(dependencies, requires_python)
|
|
289
|
+
prefix = "".join(lines[:insert_at])
|
|
290
|
+
suffix = "".join(lines[insert_at:])
|
|
291
|
+
sep = "\n" if suffix and not suffix.startswith("\n") else ""
|
|
292
|
+
return prefix + block + sep + suffix
|
skit/promptform.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Plain form renderer: the unified form as line prompts (rich Prompt/Confirm).
|
|
2
|
+
|
|
3
|
+
One of the FormPlan renderers ("one flow, N renderings"): the TUI renders widgets,
|
|
4
|
+
the inline mini-form renders a Textual window, and this renders the humble line-by-line
|
|
5
|
+
questionnaire — the deliberate fallback for SSH, dumb terminals, and the `--plain`
|
|
6
|
+
flag / `form = "plain"` preference. All logic (prefill, validation, assembly) lives in
|
|
7
|
+
flows; this module only asks."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.markup import escape
|
|
13
|
+
from rich.prompt import Confirm, Prompt
|
|
14
|
+
|
|
15
|
+
from . import flows
|
|
16
|
+
from .i18n import gettext
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def collect(
|
|
20
|
+
plan: flows.FormPlan,
|
|
21
|
+
prefill: dict[str, str],
|
|
22
|
+
*,
|
|
23
|
+
console: Console,
|
|
24
|
+
) -> dict[str, str]:
|
|
25
|
+
"""Ask for every field, re-prompting on a validation error. Returns raw (token/glob
|
|
26
|
+
original) values keyed by field key."""
|
|
27
|
+
values: dict[str, str] = {}
|
|
28
|
+
for f in plan.fields:
|
|
29
|
+
if f.help:
|
|
30
|
+
console.print(f" [dim]{escape(f.help)}[/dim]")
|
|
31
|
+
if f.degraded:
|
|
32
|
+
console.print(f" [dim]{gettext("Leave empty to use the script's own default.")}[/dim]")
|
|
33
|
+
values[f.key] = _ask_until_valid(f, prefill.get(f.key, ""), console)
|
|
34
|
+
return values
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ask_until_valid(f: flows.FormField, default: str, console: Console) -> str:
|
|
38
|
+
while True:
|
|
39
|
+
value = _ask_once(f, default, console)
|
|
40
|
+
error = flows.validate_value(f, value)
|
|
41
|
+
if error is None:
|
|
42
|
+
return value
|
|
43
|
+
console.print(f" [red]{escape(error)}[/red]")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ask_once(f: flows.FormField, default: str, console: Console) -> str:
|
|
47
|
+
label = escape(f.label)
|
|
48
|
+
if f.kind == "bool":
|
|
49
|
+
checked = Confirm.ask(
|
|
50
|
+
f" {label}", default=default.strip().lower() in ("true", "1", "yes"), console=console
|
|
51
|
+
)
|
|
52
|
+
return "true" if checked else "false"
|
|
53
|
+
if f.secret:
|
|
54
|
+
if f.env_source:
|
|
55
|
+
console.print(
|
|
56
|
+
" [dim]"
|
|
57
|
+
+ gettext("Enter to read it from the environment variable %(env)s.")
|
|
58
|
+
% {"env": escape(f.env_source)}
|
|
59
|
+
+ "[/dim]"
|
|
60
|
+
)
|
|
61
|
+
return Prompt.ask(f" {label}", password=True, console=console)
|
|
62
|
+
if f.kind == "choice" and f.choices:
|
|
63
|
+
return Prompt.ask(
|
|
64
|
+
f" {label}", choices=f.choices, default=default or f.choices[0], console=console
|
|
65
|
+
)
|
|
66
|
+
answer = Prompt.ask(f" {label}", default=default or None, console=console)
|
|
67
|
+
return (answer or "").strip()
|