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/i18n.py ADDED
@@ -0,0 +1,324 @@
1
+ """i18n core (GNU gettext).
2
+
3
+ Design notes:
4
+ - Catalogs are standard gettext with **source-string msgids**: the English text is the msgid, so an
5
+ untranslated entry falls back to the English source automatically. English therefore ships no .mo
6
+ (it is the identity). Translations live at locales/<gettext_locale>/LC_MESSAGES/skit.mo — POSIX
7
+ underscore dirs (zh_CN), while the public tags stay hyphenated (zh-CN).
8
+ - Edit the .po sources, then recompile with `python scripts/i18n.py compile` (Babel). Runtime uses the
9
+ stdlib `gettext` module only — zero third-party i18n dependencies.
10
+ - Locale negotiation chain: SKIT_LANG > config.toml [language] > LC_ALL > LC_MESSAGES > LANG > system
11
+ locale > en. Each candidate expands into a fallback chain (zh-TW -> zh-Hant alias -> zh -> en),
12
+ wired into gettext via its language list so a missing translation falls back entry by entry and
13
+ always ends at the English source. The macrolanguage step is script-aware (_zh_family): a
14
+ Traditional tag's bare-"zh" fallback resolves back to zh-TW, not zh-CN, so a msgid missing from
15
+ zh_TW.mo surfaces in English rather than in Simplified glyphs — and symmetrically for Simplified.
16
+ - Pseudo-locale (x-pseudo): an ⟦bracket + stretch⟧ transform over English, used to visually catch
17
+ hard-coded / untranslated strings and truncation. Placeholders (%(name)s) are preserved so
18
+ %-substitution at the call site still works.
19
+ - Headless: no CLI/TUI dependency; store/launcher can import it safely.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import gettext as _gt
25
+ import os
26
+ import re
27
+ import sys
28
+ import tomllib
29
+ from pathlib import Path
30
+
31
+ from .atomic import atomic_write_toml, load_toml_recoverable
32
+ from .paths import config_dir
33
+
34
+ _LOCALES_DIR = Path(__file__).parent / "locales"
35
+ _DOMAIN = "skit"
36
+ DEFAULT_LOCALE = "en"
37
+ PSEUDO_LOCALE = "x-pseudo"
38
+
39
+ # Alias -> the locale we actually ship.
40
+ _ALIASES = {
41
+ "zh": "zh-CN",
42
+ "zh-hans": "zh-CN",
43
+ "zh-sg": "zh-CN",
44
+ "zh-my": "zh-CN",
45
+ "zh-hant": "zh-TW",
46
+ "zh-hk": "zh-TW",
47
+ "zh-mo": "zh-TW",
48
+ }
49
+
50
+ # Script/region hints that identify which shipped Chinese locale a "zh-*" tag belongs to. Used by
51
+ # _zh_family() to make the bare-macrolanguage fallback step ("zh" on its own, reached by truncating
52
+ # e.g. zh-TW down to zh) resolve within the same script family instead of always defaulting to
53
+ # Simplified.
54
+ #
55
+ # Split into script vs. region tiers because they must be checked in that order: BCP-47 treats the
56
+ # script subtag as more specific than the region subtag, so a tag carrying both must let script
57
+ # decide when they disagree (zh-Hans-TW is Simplified despite the Traditional-associated "TW"
58
+ # region; zh-Hant-CN is Traditional despite the Simplified-associated "CN" region). Region hints
59
+ # are only consulted when the tag has no script subtag at all.
60
+ _HANT_SCRIPT = {"hant"}
61
+ _HANS_SCRIPT = {"hans"}
62
+ _HANT_REGION_HINTS = {"hk", "mo", "tw"}
63
+ _HANS_REGION_HINTS = {"cn", "sg", "my"}
64
+
65
+ _translations: _gt.NullTranslations | None = None
66
+ _active: str = DEFAULT_LOCALE
67
+ _pseudo: bool = False
68
+
69
+
70
+ def _gt_dir(tag: str) -> str:
71
+ """Public hyphen tag -> gettext locale dir (POSIX underscore): 'zh-CN' -> 'zh_CN'."""
72
+ return tag.replace("-", "_")
73
+
74
+
75
+ def available_locales() -> list[str]:
76
+ """Locales we ship: English (identity) plus every dir with a compiled catalog."""
77
+ found = {DEFAULT_LOCALE}
78
+ if _LOCALES_DIR.is_dir():
79
+ for p in _LOCALES_DIR.iterdir():
80
+ if (p / "LC_MESSAGES" / f"{_DOMAIN}.mo").is_file(): # pragma: no mutate — fs-case
81
+ found.add(p.name.replace("_", "-"))
82
+ return sorted(found)
83
+
84
+
85
+ def _normalize(tag: str) -> str:
86
+ """'zh_TW.UTF-8' -> 'zh-TW'; normalizes casing."""
87
+ tag = tag.split(".", maxsplit=1)[0].split("@", maxsplit=1)[0] # pragma: no mutate
88
+ tag = tag.replace("_", "-").strip()
89
+ if not tag:
90
+ return ""
91
+ parts = tag.split("-")
92
+ out = [parts[0].lower()]
93
+ for p in parts[1:]:
94
+ if len(p) == 2:
95
+ out.append(p.upper())
96
+ elif len(p) == 4:
97
+ out.append(p.capitalize())
98
+ else:
99
+ out.append(p.lower())
100
+ return "-".join(out)
101
+
102
+
103
+ def _zh_family(tag: str) -> str | None:
104
+ """Which shipped Chinese locale a "zh-*" tag's script/region hints point to: "zh-TW"
105
+ (Traditional) or "zh-CN" (Simplified). None if tag isn't zh-rooted, or is the bare "zh"
106
+ macrolanguage itself with no script/region qualifier at all (that case is left to the plain
107
+ _ALIASES lookup, which defaults it to zh-CN — the conventional "zh" == Simplified reading).
108
+
109
+ Script subtag is authoritative over region when a tag carries both and they disagree (BCP-47
110
+ treats script as the more specific signal): zh-Hant-CN is Traditional despite the "CN" region,
111
+ and zh-Hans-TW is Simplified despite the "TW" region. Region hints are only consulted when no
112
+ script subtag is present.
113
+
114
+ Used by _expand_chain so that truncating a qualified tag (zh-TW, zh-HK, zh-Hant, zh-Hans, ...)
115
+ down to the bare macrolanguage subtag "zh" resolves within the *same* script family, rather
116
+ than unconditionally injecting zh-CN into a Traditional tag's fallback chain — which would
117
+ surface Simplified glyphs for any msgid untranslated in zh_TW.mo, instead of falling through to
118
+ the English source as the module docstring's negotiation chain intends.
119
+ """
120
+ parts = [p.lower() for p in tag.split("-")]
121
+ if parts[0] != "zh" or len(parts) == 1:
122
+ return None
123
+ rest = parts[1:]
124
+ if any(p in _HANT_SCRIPT for p in rest):
125
+ return "zh-TW"
126
+ if any(p in _HANS_SCRIPT for p in rest):
127
+ return "zh-CN"
128
+ if any(p in _HANT_REGION_HINTS for p in rest):
129
+ return "zh-TW"
130
+ if any(p in _HANS_REGION_HINTS for p in rest):
131
+ return "zh-CN"
132
+ return None
133
+
134
+
135
+ def _expand_chain(tag: str) -> list[str]:
136
+ """Expand a single tag into a fallback chain: zh-Hant-TW -> [zh-Hant-TW, zh-Hant, zh] + alias
137
+ resolution.
138
+
139
+ Script-aware: when truncation reaches the bare macrolanguage subtag "zh" for a tag that carried
140
+ a script/region hint (zh-TW, zh-HK, zh-Hant, zh-Hans, zh-CN, ...), that step's alias resolves
141
+ within the same script family (see _zh_family) instead of unconditionally defaulting to zh-CN —
142
+ otherwise every Traditional tag's chain would gain a Simplified candidate ahead of en. A
143
+ genuinely bare "zh" request (no hint at all) still defaults to zh-CN via _ALIASES.
144
+ """
145
+ family = _zh_family(tag)
146
+ chain: list[str] = []
147
+ parts = tag.split("-")
148
+ while parts:
149
+ cand = "-".join(parts)
150
+ if family is not None and cand.lower() == "zh":
151
+ alias = family
152
+ else:
153
+ alias = _ALIASES.get(cand.lower(), cand)
154
+ for c in (cand, alias):
155
+ if c not in chain:
156
+ chain.append(c)
157
+ parts.pop()
158
+ return chain
159
+
160
+
161
+ def _config_language() -> str:
162
+ path = config_dir() / "config.toml" # pragma: no mutate — fs-case-dependent
163
+ if not path.is_file():
164
+ return ""
165
+ try:
166
+ with open(path, "rb") as f:
167
+ return str(tomllib.load(f).get("language", ""))
168
+ except (OSError, tomllib.TOMLDecodeError):
169
+ return ""
170
+
171
+
172
+ def _env(name: str) -> str:
173
+ return os.environ.get(name, "") # pragma: no mutate — default only matters via truthiness
174
+
175
+
176
+ def detect_locale() -> str:
177
+ """Return the raw tag the user requested (un-negotiated).
178
+
179
+ An empty string means no preference was found.
180
+ """
181
+ for source in (
182
+ _env("SKIT_LANG"),
183
+ _config_language(),
184
+ _env("LC_ALL"),
185
+ _env("LC_MESSAGES"),
186
+ _env("LANG"),
187
+ ):
188
+ tag = _normalize(source) if source and source.lower() != "c" else ""
189
+ if tag:
190
+ return tag
191
+ try:
192
+ import locale as _locale
193
+
194
+ loc = _locale.getlocale()[0]
195
+ if loc:
196
+ return _normalize(loc)
197
+ except (ValueError, TypeError):
198
+ pass
199
+ return ""
200
+
201
+
202
+ def is_supported(tag: str) -> bool:
203
+ """Whether the tag (or any candidate on its fallback chain) maps to a shipped locale.
204
+ The pseudo-locale is always considered supported. Used to validate entry points like the CLI
205
+ `lang` command."""
206
+ normalized = _normalize(tag)
207
+ if not normalized:
208
+ return False
209
+ if normalized.lower() == PSEUDO_LOCALE:
210
+ return True
211
+ shipped = set(available_locales())
212
+ return any(c in shipped for c in _expand_chain(normalized))
213
+
214
+
215
+ def negotiate(requested: str) -> tuple[str, list[str]]:
216
+ """Negotiate (primary locale, full fallback chain ending in en)."""
217
+ shipped = set(available_locales())
218
+ chain: list[str] = []
219
+ if requested:
220
+ for cand in _expand_chain(requested):
221
+ if cand in shipped and cand not in chain:
222
+ chain.append(cand)
223
+ if DEFAULT_LOCALE not in chain:
224
+ chain.append(DEFAULT_LOCALE)
225
+ return chain[0], chain
226
+
227
+
228
+ def init(lang: str | None = None) -> str:
229
+ """Initialize (or reinitialize) the message system; return the effective primary locale."""
230
+ global _translations, _active, _pseudo
231
+ requested = _normalize(lang) if lang else detect_locale()
232
+ _pseudo = requested.lower() == PSEUDO_LOCALE
233
+ if _pseudo:
234
+ primary, chain = DEFAULT_LOCALE, [DEFAULT_LOCALE]
235
+ _active = PSEUDO_LOCALE
236
+ else:
237
+ primary, chain = negotiate(requested)
238
+ _active = primary
239
+ # English is the msgid itself (identity), so only non-en locales need a catalog; gettext chains
240
+ # them as fallbacks in order and returns the English source for anything still untranslated.
241
+ languages = [_gt_dir(c) for c in chain if c != DEFAULT_LOCALE]
242
+ _translations = _gt.translation(_DOMAIN, _LOCALES_DIR, languages=languages, fallback=True)
243
+ return _active
244
+
245
+
246
+ def current_locale() -> str:
247
+ return _active
248
+
249
+
250
+ _PSEUDO_TABLE = str.maketrans("aeiouAEIOU", "àéîöûÀÉÎÖÛ")
251
+ _PLACEHOLDER = re.compile(r"%\([^)]*\)[sdr]|%[sdrifgeExXoc%]")
252
+
253
+
254
+ def _pseudoize(text: str) -> str:
255
+ """en -> pseudo: bracket markers + vowel transforms (~30% inflation), leaving %(...)s
256
+ placeholders untouched so call-site %-substitution still resolves."""
257
+ parts: list[str] = []
258
+ last = 0 # pragma: no mutate — text[None:x] == text[0:x]
259
+ for m in _PLACEHOLDER.finditer(text):
260
+ parts.append(text[last : m.start()].translate(_PSEUDO_TABLE))
261
+ parts.append(m.group())
262
+ last = m.end()
263
+ parts.append(text[last:].translate(_PSEUDO_TABLE))
264
+ return f"⟦{''.join(parts)}~~⟧"
265
+
266
+
267
+ def gettext(message: str) -> str:
268
+ """Translate a source-string message (falls back to the English source; never raises)."""
269
+ if _translations is None:
270
+ init()
271
+ if _translations is None: # pragma: no cover — init() guarantees it is set
272
+ raise RuntimeError("i18n init failed")
273
+ text = _translations.gettext(message)
274
+ return _pseudoize(text) if _pseudo else text
275
+
276
+
277
+ def ngettext(singular: str, plural: str, n: int) -> str:
278
+ """Translate a source-string message with plural selection (CLDR rules per the target locale)."""
279
+ if _translations is None:
280
+ init()
281
+ if _translations is None: # pragma: no cover — init() guarantees it is set
282
+ raise RuntimeError("i18n init failed")
283
+ text = _translations.ngettext(singular, plural, n)
284
+ return _pseudoize(text) if _pseudo else text
285
+
286
+
287
+ def set_language(tag: str) -> str:
288
+ """Write config.toml and take effect immediately. Returns the effective locale. An empty tag
289
+ clears the setting (back to auto-detection).
290
+
291
+ Uses atomic.load_toml_recoverable() for the read step, so a present-but-corrupt config.toml is
292
+ backed up to config.toml.bak (and the user warned on stderr) instead of being silently wiped —
293
+ matching config.save_editor()/save_mirror(). This module can't import config.py for the same
294
+ recovery helper those use (config.py imports gettext from here, so the reverse would cycle);
295
+ the neutral atomic module is what both safely share.
296
+ """
297
+ path = config_dir() / "config.toml" # pragma: no mutate — fs-case-dependent
298
+ recovery = load_toml_recoverable(path)
299
+ doc = recovery.doc
300
+ if recovery.corrupt:
301
+ if recovery.backup_path is not None:
302
+ print(
303
+ gettext(
304
+ "%(path)s is corrupt and could not be parsed. It has been backed up to "
305
+ "%(backup)s before this change; recover any lost settings from that file."
306
+ )
307
+ % {"path": str(path), "backup": str(recovery.backup_path)},
308
+ file=sys.stderr,
309
+ )
310
+ else:
311
+ print(
312
+ gettext(
313
+ "%(path)s is corrupt and could not be parsed, and it could not be backed up "
314
+ "either; the settings it contained will be lost when this change is saved."
315
+ )
316
+ % {"path": str(path)},
317
+ file=sys.stderr,
318
+ )
319
+ if tag:
320
+ doc["language"] = _normalize(tag)
321
+ else:
322
+ doc.pop("language", None)
323
+ atomic_write_toml(path, doc)
324
+ return init(tag or None)
skit/inlineform.py ADDED
@@ -0,0 +1,61 @@
1
+ """The CLI's inline mini-form: the run form opened in place, in the same terminal.
2
+
3
+ `skit run NAME` on a TTY (with `form = "tui"`, the default) opens the same RunFormScreen
4
+ the TUI uses — via Textual's inline mode, so there's no alternate screen and the
5
+ scrollback survives. Submit collapses the form and the script runs right below it;
6
+ `--plain` / `form = "plain"` / TERM=dumb fall back to line prompts instead.
7
+
8
+ The extra-arguments row is hidden here: on the CLI, passthrough args already arrived
9
+ via `skit run NAME -- <args>` (argv owns them; two sources would fight).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING, override
15
+
16
+ from textual.app import App
17
+
18
+ from . import flows, theme
19
+ from .theme import CLAUDE_THEME
20
+ from .tui_form import FormResult, RunFormScreen
21
+
22
+ if TYPE_CHECKING:
23
+ from .models import Entry
24
+
25
+
26
+ class _InlineFormApp(App[FormResult]):
27
+ ENABLE_COMMAND_PALETTE = False
28
+ CSS = theme.CHROME_CSS
29
+
30
+ def __init__(self, entry: Entry, plan: flows.FormPlan, prefill: dict[str, str]) -> None:
31
+ super().__init__()
32
+ self._entry: Entry = entry
33
+ self._plan: flows.FormPlan = plan
34
+ self._prefill: dict[str, str] = prefill
35
+
36
+ @override
37
+ def get_css_variables(self) -> dict[str, str]:
38
+ # The first stylesheet parse runs before on_mount activates the theme; the
39
+ # screen CSS needs $skit-box-* resolvable from the very first frame.
40
+ return {**super().get_css_variables(), **theme.BOX_VARIABLES}
41
+
42
+ def on_mount(self) -> None:
43
+ self.register_theme(CLAUDE_THEME)
44
+ self.theme = "skit-claude"
45
+
46
+ def _done(result: FormResult) -> None:
47
+ self.exit(result)
48
+
49
+ self.push_screen(
50
+ RunFormScreen(self._entry, self._plan, self._prefill, include_extra=False), _done
51
+ )
52
+
53
+
54
+ def collect(entry: Entry, plan: flows.FormPlan, prefill: dict[str, str]) -> dict[str, str] | None:
55
+ """Run the inline form; returns raw values, or None when the user cancelled."""
56
+ app = _InlineFormApp(entry, plan, prefill)
57
+ result = app.run(inline=True)
58
+ if result is None:
59
+ return None
60
+ values, _extra = result
61
+ return values