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