update-kit 0.1.0__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.
update_kit/__init__.py ADDED
@@ -0,0 +1,333 @@
1
+ """Self-update for git-tag-released Python CLIs (uv tool / pipx). Stdlib only.
2
+
3
+ import sys
4
+ import update_kit
5
+
6
+ # `<cmd> update` / `<cmd> --update`
7
+ sys.exit(update_kit.update(app="my-cli", repo="owner/my-cli", dist="my-cli"))
8
+
9
+ # background hint, TTY stderr only
10
+ if update_kit.should_hint():
11
+ latest = update_kit.check(repo="owner/my-cli", current=__version__)
12
+ if latest:
13
+ print(update_kit.hint("my-cli", latest, __version__), file=sys.stderr)
14
+
15
+ Releases are strict ``vX.Y.Z`` git tags, discovered with ``git ls-remote``.
16
+ Never prompts.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import re
24
+ import shlex
25
+ import subprocess
26
+ import sys
27
+ import time
28
+ from collections.abc import Callable, Sequence
29
+ from importlib import metadata
30
+ from pathlib import Path
31
+ from typing import TextIO
32
+ from urllib.parse import urlparse
33
+ from urllib.request import url2pathname
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ __all__ = [
38
+ "UpdateError", "check", "hint", "install_kind", "latest_tag", "normalize_lang",
39
+ "should_hint", "update",
40
+ ]
41
+
42
+ _IS_WINDOWS = os.name == "nt" # the running exe is locked there; tests patch this flag
43
+ _NO_PROMPT = {"GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "never"} # fail instead of asking for credentials
44
+
45
+ _STRICT = re.compile(r"v?([0-9]{1,9})\.([0-9]{1,9})\.([0-9]{1,9})")
46
+ _CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]")
47
+ _USERINFO = re.compile(r"(?<=://)[^/\s]*@")
48
+ # Only these remote forms reach git; no whitespace/control chars, no userinfo (credentials).
49
+ _URL_OK = re.compile(r"(?:https://(?![^/?#]*@)|ssh://(?![^/?#]*:[^/?#]*@)|file://)[^\s\x00-\x1f\x7f-\x9f]+|git@[A-Za-z0-9][A-Za-z0-9._-]*:[^\s\x00-\x1f\x7f-\x9f]+")
50
+ # cmd.exe would interpret these inside the detached chain; such updates are refused.
51
+ _CMD_META = re.compile(r'[&|<>^%"()!\r\n]')
52
+
53
+ _MESSAGES = {
54
+ "en": {
55
+ "dev": "{app}: dev install from {dir}; use git pull to update",
56
+ "check_failed": "{app}: couldn't check for updates ({error})",
57
+ "up_to_date": "{app} {current} is already up to date",
58
+ "manual": "{app} {latest} is available (you have {current}). Install it manually:\n {command}",
59
+ "installing": "Updating {app} {current} -> {latest} ...",
60
+ "install_failed": "{app}: update failed ({error})",
61
+ "updated": "{app} updated to {latest}",
62
+ "after_failed": "warning: `{step}` failed after the update ({error})",
63
+ "background": "{app} {latest}: update continues in the background (log: {log})",
64
+ "hint": "{cmd} {latest} is available (you have {current}) — run `{cmd} update`",
65
+ "source": "{app}: using update source {url} (UPDATE_KIT_REPO_URL)",
66
+ },
67
+ "zh-TW": {
68
+ "dev": "{app}:開發版安裝({dir}),請用 git pull 更新",
69
+ "check_failed": "{app}:無法檢查更新({error})",
70
+ "up_to_date": "{app} {current} 已是最新版本",
71
+ "manual": "{app} {latest} 可更新(目前為 {current}),請手動安裝:\n {command}",
72
+ "installing": "正在更新 {app} {current} -> {latest} ...",
73
+ "install_failed": "{app}:更新失敗({error})",
74
+ "updated": "{app} 已更新至 {latest}",
75
+ "after_failed": "警告:更新後執行 `{step}` 失敗({error})",
76
+ "background": "{app} {latest}:更新將在背景繼續(記錄檔:{log})",
77
+ "hint": "{cmd} {latest} 可更新(目前為 {current})— 執行 `{cmd} update`",
78
+ "source": "{app}:使用更新來源 {url}(UPDATE_KIT_REPO_URL)",
79
+ },
80
+ }
81
+
82
+
83
+ class UpdateError(Exception):
84
+ """The latest release could not be determined."""
85
+
86
+
87
+ def _parse(version: str | None) -> tuple[int, int, int] | None:
88
+ match = _STRICT.fullmatch(version.strip()) if version else None
89
+ return tuple(int(part) for part in match.groups()) if match else None
90
+
91
+
92
+ def _clean(text: str) -> str:
93
+ """Printable, credential-free *text* (terminal escapes and URL userinfo removed)."""
94
+ return _USERINFO.sub("", _CONTROL.sub("", text))
95
+
96
+
97
+ def _repo_url(repo: str, url: str | None) -> str:
98
+ target = url or os.environ.get("UPDATE_KIT_REPO_URL") or f"https://github.com/{repo}.git"
99
+ if not _URL_OK.fullmatch(target):
100
+ raise UpdateError(f"unsupported repo URL: {_clean(target)[:200]!r}")
101
+ return target
102
+
103
+
104
+ def _msg(lang: str, key: str, **fields) -> str:
105
+ return _MESSAGES[normalize_lang(lang)][key].format(**fields)
106
+
107
+
108
+ def _say(out: TextIO, text: str) -> None:
109
+ try:
110
+ print(text, file=out)
111
+ except UnicodeEncodeError: # e.g. a cp1252 stream on Windows receiving zh-TW
112
+ encoding = getattr(out, "encoding", None) or "ascii"
113
+ print(text.encode(encoding, "replace").decode(encoding), file=out)
114
+
115
+
116
+ def latest_tag(repo: str, *, timeout: float = 5, url: str | None = None) -> str:
117
+ """Newest strict ``vX.Y.Z`` tag of github.com/<repo>; raises UpdateError."""
118
+ # Validated before any git call: a value like "--upload-pack=..." must never reach argv.
119
+ target = _repo_url(repo, url)
120
+ # No credential/passphrase prompt for a private/missing repo: fail instead of blocking.
121
+ # Low-speed limits stop a stalled HTTP transfer that outlives the killed git process.
122
+ env = {
123
+ "GIT_SSH_COMMAND": "ssh -o BatchMode=yes",
124
+ **os.environ,
125
+ **_NO_PROMPT,
126
+ "GIT_HTTP_LOW_SPEED_LIMIT": "1",
127
+ "GIT_HTTP_LOW_SPEED_TIME": str(max(1, round(timeout))),
128
+ }
129
+ try:
130
+ result = subprocess.run(
131
+ ["git", "-c", "protocol.ext.allow=never", "ls-remote", "--tags", "--refs", "--", target],
132
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
133
+ stdin=subprocess.DEVNULL, timeout=timeout, env=env, check=False,
134
+ cwd=Path.home(), # a repo-local config/hook in the caller's cwd must not apply
135
+ )
136
+ except FileNotFoundError as exc:
137
+ raise UpdateError("git not found") from exc
138
+ except subprocess.TimeoutExpired as exc:
139
+ raise UpdateError(f"timed out after {timeout:g}s") from exc
140
+ except OSError as exc:
141
+ raise UpdateError(str(exc)) from exc
142
+ if result.returncode != 0:
143
+ detail = (result.stderr or "").strip().splitlines()
144
+ raise UpdateError(_clean(detail[-1]) if detail else f"git ls-remote exited {result.returncode}")
145
+ tags = [line.split("\trefs/tags/", 1)[1] for line in result.stdout.splitlines() if "\trefs/tags/" in line]
146
+ versions = [(tuple(map(int, m.groups())), tag) for tag in tags if (m := _STRICT.fullmatch(tag))]
147
+ if not versions:
148
+ raise UpdateError(f"no release tags in {_clean(target)}")
149
+ return max(versions)[1]
150
+
151
+
152
+ def install_kind(dist: str) -> tuple[str, str | None]:
153
+ """("uv" | "pipx" | "dev" | "unknown", detail) for the installed *dist*."""
154
+ try:
155
+ distribution = metadata.distribution(dist)
156
+ except metadata.PackageNotFoundError:
157
+ return "unknown", None
158
+ try:
159
+ direct = json.loads(distribution.read_text("direct_url.json") or "{}")
160
+ except (OSError, ValueError):
161
+ direct = {}
162
+ if isinstance(direct, dict) and "dir_info" in direct: # PEP 610: editable or local dir
163
+ try:
164
+ return "dev", url2pathname(urlparse(direct.get("url", "")).path)
165
+ except Exception: # noqa: BLE001 - malformed metadata must not break `update`
166
+ return "dev", str(direct.get("url"))
167
+ prefix = Path(sys.prefix)
168
+ parents = [part.lower() for part in prefix.parent.parts]
169
+ # uv: <uv data>/tools/<name> (UV_TOOL_DIR overrides); pipx: <pipx home>/venvs/<name>.
170
+ tool_dir = os.environ.get("UV_TOOL_DIR")
171
+ if (parents[-1:] == ["tools"] and "uv" in parents) or (tool_dir and prefix.parent == Path(tool_dir)):
172
+ return "uv", str(prefix)
173
+ pipx_home = os.environ.get("PIPX_HOME")
174
+ if (parents[-1:] == ["venvs"] and "pipx" in parents) or (
175
+ pipx_home and prefix.parent == Path(pipx_home, "venvs")
176
+ ):
177
+ return "pipx", str(prefix)
178
+ return "unknown", str(prefix)
179
+
180
+
181
+ def _windows_chain(commands: Sequence[Sequence[str]], log: str) -> str:
182
+ """One cmd.exe line: wait for the caller to exit (its exe is locked), then run *commands*."""
183
+ chain = " && ".join(subprocess.list2cmdline(list(argv)) for argv in commands)
184
+ wait = "ping -n 4 127.0.0.1 >nul" # `timeout` refuses to run without a console
185
+ return f'cmd.exe /d /s /c "{wait} & ({chain}) > {subprocess.list2cmdline([log])} 2>&1"'
186
+
187
+
188
+ def _spawn_detached(cmdline: str, log: str, env: dict | None = None) -> None: # pragma: no cover - Windows only
189
+ os.makedirs(os.path.dirname(log), exist_ok=True)
190
+ # CREATE_NO_WINDOW (not DETACHED_PROCESS): children inherit a hidden console, no flashing.
191
+ flags = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP
192
+ # cwd=home: cmd.exe searches the cwd first, so a planted uv.bat/crw.exe there must not run.
193
+ subprocess.Popen(cmdline, creationflags=flags, close_fds=True, stdin=subprocess.DEVNULL,
194
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=str(Path.home()), env=env)
195
+
196
+
197
+ def update(*, app: str, repo: str, dist: str, current: str | None = None, lang: str = "en",
198
+ after: Sequence[Sequence[str]] = (), out: TextIO = sys.stderr,
199
+ run: Callable = subprocess.run, url: str | None = None) -> int:
200
+ """Install the newest release. 0 updated/up to date, 1 failure, 2 dev/unknown install."""
201
+ kind, detail = install_kind(dist)
202
+ if kind == "dev":
203
+ _say(out, _msg(lang, "dev", app=app, dir=detail))
204
+ return 2
205
+ if current is None:
206
+ try:
207
+ current = metadata.version(dist)
208
+ except metadata.PackageNotFoundError:
209
+ pass
210
+ try:
211
+ target = _repo_url(repo, url)
212
+ if not url and os.environ.get("UPDATE_KIT_REPO_URL"):
213
+ _say(out, _msg(lang, "source", app=app, url=_clean(target)))
214
+ latest = latest_tag(repo, url=target)
215
+ except UpdateError as exc:
216
+ _say(out, _msg(lang, "check_failed", app=app, error=exc))
217
+ return 1
218
+ have = _parse(current)
219
+ if have and have >= _parse(latest):
220
+ _say(out, _msg(lang, "up_to_date", app=app, current=current))
221
+ return 0
222
+ # An unparseable/unknown current version is treated as older: the user asked to update.
223
+ # pip/uv can't parse the scp form (git@host:path); install over the equivalent ssh:// URL.
224
+ source = re.sub(r"^git@([^:]+):/?", r"ssh://git@\1/", target)
225
+ spec = f"git+{source}@{latest}"
226
+ shown = current or "?"
227
+ if kind == "uv":
228
+ install = ["uv", "tool", "install", "--force", "--no-sources", spec]
229
+ elif kind == "pipx":
230
+ install = ["pipx", "install", "--force", spec]
231
+ else:
232
+ argv = ["python", "-m", "pip", "install", "--upgrade", spec]
233
+ command = subprocess.list2cmdline(argv) if _IS_WINDOWS else shlex.join(argv)
234
+ _say(out, _msg(lang, "manual", app=app, latest=latest, current=shown, command=command))
235
+ return 2
236
+ if _IS_WINDOWS:
237
+ log = os.path.join(os.path.expanduser("~"), ".update-kit", f"{app}-update.log")
238
+ unsafe = [arg for arg in [log, *install, *(a for step in after for a in step)] if _CMD_META.search(arg)]
239
+ if unsafe:
240
+ _say(out, _msg(lang, "install_failed", app=app, error=f"unsafe for cmd.exe: {unsafe[0]}"))
241
+ return 1
242
+ try:
243
+ _spawn_detached(_windows_chain([install, *after], log), log, {**os.environ, **_NO_PROMPT})
244
+ except OSError as exc:
245
+ _say(out, _msg(lang, "install_failed", app=app, error=exc))
246
+ return 1
247
+ _say(out, _msg(lang, "background", app=app, latest=latest, log=log))
248
+ return 0
249
+ _say(out, _msg(lang, "installing", app=app, current=shown, latest=latest))
250
+ try:
251
+ code = run(install, cwd=Path.home(), env={**os.environ, **_NO_PROMPT}).returncode
252
+ except OSError as exc:
253
+ _say(out, _msg(lang, "install_failed", app=app, error=exc))
254
+ return 1
255
+ if code != 0:
256
+ _say(out, _msg(lang, "install_failed", app=app, error=f"exit {code}"))
257
+ return 1
258
+ _say(out, _msg(lang, "updated", app=app, latest=latest))
259
+ result = 0
260
+ for step in after:
261
+ step = list(step)
262
+ try:
263
+ step_code, error = run(step).returncode, None
264
+ except OSError as exc:
265
+ step_code, error = -1, exc
266
+ if step_code != 0:
267
+ _say(out, _msg(lang, "after_failed", step=" ".join(step), error=error or f"exit {step_code}"))
268
+ result = 1
269
+ return result
270
+
271
+
272
+ def check(*, repo: str, current: str, cache_path: Path | None = None, ttl: int = 86400,
273
+ timeout: float = 1.5, url: str | None = None) -> str | None:
274
+ """Newer release tag than *current*, or None. Cached per source URL for *ttl*; never raises."""
275
+ try:
276
+ path = Path(cache_path) if cache_path else Path.home() / ".update-kit" / "state.json"
277
+ try:
278
+ state = json.loads(path.read_text(encoding="utf-8"))
279
+ except (OSError, ValueError):
280
+ state = {}
281
+ if not isinstance(state, dict):
282
+ state = {}
283
+ key = _repo_url(repo, url) # keyed by source, so an override can't poison the normal entry
284
+ entry = state.get(key)
285
+ now = time.time()
286
+ checked = entry.get("checked") if isinstance(entry, dict) else None
287
+ cached = entry.get("latest") if isinstance(entry, dict) else None
288
+ valid = isinstance(checked, (int, float)) and (
289
+ cached is None or (isinstance(cached, str) and _STRICT.fullmatch(cached) is not None)
290
+ )
291
+ if valid and 0 <= now - checked < ttl:
292
+ latest = cached
293
+ else:
294
+ try:
295
+ latest = latest_tag(repo, timeout=timeout, url=url)
296
+ except Exception: # noqa: BLE001 - offline, rate-limited, anything: negative-cache it too
297
+ latest = None
298
+ # shortcut: read-merge-replace without a lock; a concurrent writer can drop one
299
+ # entry, which only costs one extra ls-remote later. Add a lock if that matters.
300
+ state[key] = {"checked": now, "latest": latest}
301
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
302
+ try:
303
+ path.parent.mkdir(parents=True, exist_ok=True)
304
+ tmp.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
305
+ os.replace(tmp, path)
306
+ except OSError:
307
+ try:
308
+ tmp.unlink()
309
+ except OSError:
310
+ pass
311
+ have, new = _parse(current), _parse(latest)
312
+ return latest if have and new and new > have else None
313
+ except Exception: # noqa: BLE001 - check() never raises
314
+ return None
315
+
316
+
317
+ def hint(cmd: str, latest: str, current: str, lang: str = "en") -> str:
318
+ """One-line "update available" notice naming the command the user actually ran."""
319
+ return _msg(lang, "hint", cmd=cmd, latest=latest, current=current)
320
+
321
+
322
+ def should_hint(stream: TextIO = sys.stderr) -> bool:
323
+ """True only when *stream* is a TTY (never under launchd/timers/pipes/--json)."""
324
+ try:
325
+ return bool(stream.isatty())
326
+ except Exception: # noqa: BLE001 - closed/None/odd streams mean "no"
327
+ return False
328
+
329
+
330
+ def normalize_lang(value: str | None) -> str:
331
+ """zh_TW / zh-tw / zh-Hant / zh_HK -> "zh-TW"; anything else -> "en"."""
332
+ tag = re.split(r"[.@]", value or "")[0].replace("_", "-").lower()
333
+ return "zh-TW" if tag in ("zh-tw", "zh-hk", "zh-mo") or tag.startswith("zh-hant") else "en"
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.5
2
+ Name: update-kit
3
+ Version: 0.1.0
4
+ Summary: Self-update for git-tag-released Python CLIs (uv tool / pipx). Stdlib only.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ # update-kit
11
+
12
+ Self-update for Python CLIs that are released as git tags (`vX.Y.Z` or `X.Y.Z`) and installed with
13
+ `uv tool` or `pipx`. Stdlib only, Python ≥ 3.10, macOS / Linux / Windows.
14
+
15
+ - `<cmd> update` installs the newest release tag — no prompts, ever.
16
+ - A cached background check prints a one-line hint on an interactive terminal only.
17
+ - Every message exists in English and Traditional Chinese (`zh-TW`).
18
+
19
+ ## Install
20
+
21
+ ```toml
22
+ # consumer pyproject.toml
23
+ dependencies = ["update-kit>=0.1,<0.2"]
24
+ ```
25
+
26
+ ## Wiring a consumer CLI
27
+
28
+ ```python
29
+ import sys
30
+
31
+ import update_kit
32
+
33
+ from my_cli import __version__
34
+
35
+ APP, REPO, DIST = "my-cli", "owner/my-cli", "my-cli"
36
+
37
+
38
+ def main(argv=None):
39
+ argv = sys.argv[1:] if argv is None else argv
40
+ lang = "zh-TW" # the app's own current language setting
41
+
42
+ # `my-cli update` and `my-cli --update`
43
+ if argv[:1] == ["update"] or "--update" in argv:
44
+ sys.exit(update_kit.update(
45
+ app=APP, repo=REPO, dist=DIST, lang=lang,
46
+ after=[["my-cli", "migrate"]], # optional: runs with the NEW binary
47
+ ))
48
+
49
+ # Background hint: stderr, TTY only (never under launchd/timers/--json/pipes).
50
+ if update_kit.should_hint():
51
+ latest = update_kit.check(repo=REPO, current=__version__)
52
+ if latest:
53
+ print(update_kit.hint("my-cli", latest, __version__, lang), file=sys.stderr)
54
+ ...
55
+ ```
56
+
57
+ ## API
58
+
59
+ | Function | Does |
60
+ |---|---|
61
+ | `latest_tag(repo, *, timeout=5, url=None) -> str` | Newest strict `v?X.Y.Z` tag (ASCII digits) via `git ls-remote --tags --refs`. Prereleases and other names are ignored. Raises `UpdateError` (offline, no tags, git missing). |
62
+ | `install_kind(dist) -> (kind, detail)` | `"dev"` (PEP 610 local/editable dir, detail = the dir), `"uv"` / `"pipx"` (from `sys.prefix`), else `"unknown"`. |
63
+ | `update(*, app, repo, dist, current=None, lang="en", after=(), out=sys.stderr, run=subprocess.run, url=None) -> int` | Exit code: **0** updated or already up to date · **1** couldn't check (offline/rate limit) or install/after-step failed · **2** dev install (use `git pull`) or unknown install (manual command printed). |
64
+ | `check(*, repo, current, cache_path=None, ttl=86400, timeout=1.5, url=None) -> str \| None` | Newer tag or `None`. Never raises. Results — failures too — are cached for `ttl` in `$HOME/.update-kit/state.json`, keyed by the resolved source URL. |
65
+ | `hint(cmd, latest, current, lang="en") -> str` | `my-cli v1.2.0 is available (you have 1.1.0) — run` `` `my-cli update` `` |
66
+ | `should_hint(stream=sys.stderr) -> bool` | `True` only when `stream` is a TTY. |
67
+ | `normalize_lang(value) -> str` | `zh_TW` / `zh-tw` / `zh-Hant` / `zh_HK` → `"zh-TW"`; anything else → `"en"`. |
68
+
69
+ Install commands used by `update`:
70
+
71
+ - uv: `uv tool install --force --no-sources git+<url>@<tag>`
72
+ - pipx: `pipx install --force git+<url>@<tag>`
73
+
74
+ `<url>` is `url=`, else `$UPDATE_KIT_REPO_URL`, else `https://github.com/<repo>.git`
75
+ (`file://` works, which is how the tests run without a network). Only `https://`, `ssh://`,
76
+ `file://` and `git@host:path` are accepted (`ssh://git@host/...` is fine, `ssh://user:pass@` is not; `git@host:path` is installed as `ssh://git@host/path`); a leading `-`, whitespace/control characters,
77
+ `ext::` or credentials in the URL (`user:pass@`) raise `UpdateError` (`update` returns 1,
78
+ `check` returns `None`). When `$UPDATE_KIT_REPO_URL` is in effect, `update` prints one line
79
+ naming it. `git ls-remote` runs as `git -c protocol.ext.allow=never ls-remote --tags --refs -- <url>`
80
+ from the home directory, and so does the install; git's stderr is stripped of control characters.
81
+
82
+ **Windows:** the running `.exe` is locked, so `update` starts the install (and the `after`
83
+ steps, chained with `&&`) in a detached `cmd.exe` that waits a few seconds for the app to
84
+ exit, returns 0 and says the update continues in the background. Output goes to
85
+ `%USERPROFILE%\.update-kit\<app>-update.log`. Unlike POSIX (where every after-step runs and
86
+ any failure makes the result 1), the Windows chain stops at the first failing step and the
87
+ outcome is only in the log. An install whose arguments or log path contain a `cmd.exe`
88
+ metacharacter (`& | < > ^ % " ( ) !`) is refused with exit 1 instead of being spawned.
89
+
90
+ `git ls-remote` never prompts: `GIT_TERMINAL_PROMPT=0`, and `GIT_SSH_COMMAND` defaults to
91
+ `ssh -o BatchMode=yes` when unset. The install step (uv / pipx) also runs with
92
+ `GIT_TERMINAL_PROMPT=0` and `GCM_INTERACTIVE=never`.
93
+
94
+ ## Development
95
+
96
+ ```bash
97
+ uv sync
98
+ uv run pytest
99
+ uv run ruff check .
100
+ uv build
101
+ ```
102
+
103
+ ## License
104
+
105
+ MIT
@@ -0,0 +1,5 @@
1
+ update_kit/__init__.py,sha256=6HBl7ksLFJwcNKqyT-vojCqkFuPH-51Ex8WbKrly_MA,15424
2
+ update_kit-0.1.0.dist-info/METADATA,sha256=rreFtW4PcepUnNj9sFz3WR7gOsHA1oWDYRPlflEzpdQ,4796
3
+ update_kit-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
4
+ update_kit-0.1.0.dist-info/licenses/LICENSE,sha256=hbegGfnKh0keOqgPtmGw5K4EYue_BwPemF07Uw2eFDs,1047
5
+ update_kit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wes Kao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OF THE SOFTWARE.