cfuicli 1.0.0__tar.gz

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.
@@ -0,0 +1,10 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: github-actions
4
+ directory: /
5
+ schedule: {interval: weekly}
6
+ commit-message: {prefix: ⬆}
7
+ - package-ecosystem: uv
8
+ directory: /
9
+ schedule: {interval: weekly}
10
+ commit-message: {prefix: ⬆}
@@ -0,0 +1,14 @@
1
+ name: CI
2
+ on:
3
+ push: {branches: [main, develop]}
4
+ pull_request: {branches: [main, develop]}
5
+ jobs:
6
+ test:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v6
10
+ - uses: astral-sh/setup-uv@v7
11
+ - run: uv sync
12
+ - run: uv run ruff check .
13
+ - run: uv run ruff format --check .
14
+ - run: uv run pytest
@@ -0,0 +1,25 @@
1
+ name: Release
2
+ on:
3
+ workflow_run: {workflows: [CI], branches: [main], types: [completed]}
4
+ workflow_dispatch:
5
+ permissions: {contents: write, issues: write, pull-requests: write, id-token: write}
6
+ jobs:
7
+ release:
8
+ if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
9
+ runs-on: ubuntu-latest
10
+ environment: pypi # configure the Trusted Publisher on pypi.org with this environment name
11
+ steps:
12
+ - uses: actions/checkout@v6
13
+ with: {fetch-depth: 0, ref: "${{ github.event.workflow_run.head_branch || github.ref }}"}
14
+ - uses: astral-sh/setup-uv@v7
15
+ - run: |
16
+ git config user.name "${{ github.actor }}"
17
+ git config user.email "${{ github.actor }}@users.noreply.github.com"
18
+ - id: sr
19
+ env: {GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}"}
20
+ run: |
21
+ uvx --from python-semantic-release semantic-release version --changelog --tag --push
22
+ # a new tag points at HEAD only when a release was made
23
+ git describe --exact-match --tags HEAD >/dev/null 2>&1 && echo "released=true" >> "$GITHUB_OUTPUT" || true
24
+ - if: steps.sr.outputs.released == 'true'
25
+ run: uv build && uv publish --trusted-publishing always
@@ -0,0 +1,4 @@
1
+ __pycache__/
2
+ .venv/
3
+ dist/
4
+ *.egg-info/
@@ -0,0 +1,7 @@
1
+ # <type>(<scope>): <subject>
2
+ #
3
+ # <body>
4
+ #
5
+ # types: feat fix docs style refactor perf test build ci chore revert
6
+ # subject: imperative, lowercase, no trailing dot
7
+ # footer: BREAKING CHANGE: ... / Closes #123
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,4 @@
1
+ # Agent notes
2
+ - Use `uv run ...`; never pip.
3
+ - Before finishing: `uv run ruff check . && uv run ruff format --check . && uv run pytest`.
4
+ - Conventional Commits only. Keep changes minimal; no new deps without need.
@@ -0,0 +1,7 @@
1
+ # CHANGELOG
2
+
3
+ <!-- version list -->
4
+
5
+ ## v1.0.0 (2026-10-11)
6
+
7
+ - Initial Release
cfuicli-1.0.0/LICENSE ADDED
File without changes
cfuicli-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.5
2
+ Name: cfuicli
3
+ Version: 1.0.0
4
+ Summary: cf-style terminal UI and a tiny argparse app framework (help, version, update, prompts for missing options).
5
+ Project-URL: Homepage, https://github.com/arian24b/cfui
6
+ Author-email: Arian Omrani <arian24b@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
12
+ # cfui
13
+
14
+ Install: `pip install cfuicli` (import name: `cfui`).
15
+
16
+ Stdlib-only terminal UI and tiny argparse framework in the style of Cloudflare's `cf` CLI.
17
+
18
+ ```python
19
+ from cfui import App, Arg, ui
20
+
21
+ app = App("tool", repo="https://github.com/me/tool", pypi="tool", description="Does things.")
22
+
23
+
24
+ @app.command("greet", "Say hi", args=[Arg("--name", required=True, prompt="Your name")])
25
+ def greet(a):
26
+ ui.success(f"hi {a.name}")
27
+
28
+
29
+ def main():
30
+ app.main()
31
+ ```
32
+
33
+ Gives you `help`, `version` (and `-v`), `update [--check]`, `-q/--quiet`, boxed errors, JSON output,
34
+ and prompts for missing required options on a TTY (a clear error when piped or in CI).
35
+
36
+ ## Shared pieces (`cfui.common`)
37
+
38
+ `YES`, `JSON`, `FORCE`, `DRY_RUN`, `JOBS`, `TIMEOUT`, `BACKGROUND` (ready-made `Arg`s), `confirm(msg, yes)`,
39
+ `parse_value`, `run_parallel(items, worker, jobs)`, and `add_config(app, items=, get=, set=, reset=, path=, validate=)`
40
+ which registers `config show|get|set|reset|path|validate`.
41
+
42
+ ## Releasing
43
+
44
+ Conventional Commits on `main` -> CI -> `Release` workflow (python-semantic-release) bumps the version, tags and
45
+ publishes to PyPI with a Trusted Publisher (environment `pypi`, workflow `release.yml`). See `docs/workflow.md`.
@@ -0,0 +1,34 @@
1
+ # cfui
2
+
3
+ Install: `pip install cfuicli` (import name: `cfui`).
4
+
5
+ Stdlib-only terminal UI and tiny argparse framework in the style of Cloudflare's `cf` CLI.
6
+
7
+ ```python
8
+ from cfui import App, Arg, ui
9
+
10
+ app = App("tool", repo="https://github.com/me/tool", pypi="tool", description="Does things.")
11
+
12
+
13
+ @app.command("greet", "Say hi", args=[Arg("--name", required=True, prompt="Your name")])
14
+ def greet(a):
15
+ ui.success(f"hi {a.name}")
16
+
17
+
18
+ def main():
19
+ app.main()
20
+ ```
21
+
22
+ Gives you `help`, `version` (and `-v`), `update [--check]`, `-q/--quiet`, boxed errors, JSON output,
23
+ and prompts for missing required options on a TTY (a clear error when piped or in CI).
24
+
25
+ ## Shared pieces (`cfui.common`)
26
+
27
+ `YES`, `JSON`, `FORCE`, `DRY_RUN`, `JOBS`, `TIMEOUT`, `BACKGROUND` (ready-made `Arg`s), `confirm(msg, yes)`,
28
+ `parse_value`, `run_parallel(items, worker, jobs)`, and `add_config(app, items=, get=, set=, reset=, path=, validate=)`
29
+ which registers `config show|get|set|reset|path|validate`.
30
+
31
+ ## Releasing
32
+
33
+ Conventional Commits on `main` -> CI -> `Release` workflow (python-semantic-release) bumps the version, tags and
34
+ publishes to PyPI with a Trusted Publisher (environment `pypi`, workflow `release.yml`). See `docs/workflow.md`.
@@ -0,0 +1,27 @@
1
+ # Workflow
2
+
3
+ Distilled from the conventions already used across my repos (tgnews, linkcovery, ollamatools, ...).
4
+
5
+ ## Tooling
6
+ - **uv** for everything: `uv sync`, `uv run pytest`, `uv add <pkg>`. Commit `uv.lock`.
7
+ - **ruff** for lint + format (config in `pyproject.toml`, line length 120).
8
+ - **hatchling** build backend, `src/` layout.
9
+
10
+ ## Branching & commits
11
+ 1. Branch from `develop`; PR into `develop`, then `develop` -> `main`.
12
+ 2. Use Conventional Commits (`git config commit.template .gitmessage`).
13
+ `feat` = minor, `fix`/`perf` = patch, `BREAKING CHANGE` = major.
14
+
15
+ ## Pipeline (`.github/workflows`)
16
+ `CI` (ruff + pytest) -> `Release` (python-semantic-release: bumps version, writes CHANGELOG, tags)
17
+ -> `Build` (docker image to ghcr.io, tagged version + latest).
18
+ Dependabot bumps actions and uv deps weekly.
19
+
20
+ ## Starting a project
21
+ 1. "Use this template" on GitHub.
22
+ 2. Rename `project-name` / `project_name` (pyproject, Dockerfile, tests, `src/`).
23
+ 3. Fill LICENSE, README; `uv sync`; `git config commit.template .gitmessage`.
24
+ 4. Create the PyPI project + Trusted Publisher (workflow `release.yml`, environment `pypi`) before the first release.
25
+
26
+ ## Adding Telegram/web apps
27
+ Secrets in `.env` (never committed; keep `.env.example` current). Timezone via `TZ` env in Dockerfile when scheduling.
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "cfuicli"
3
+ version = "1.0.0"
4
+ description = "cf-style terminal UI and a tiny argparse app framework (help, version, update, prompts for missing options)."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [{name = "Arian Omrani", email = "arian24b@gmail.com"}]
8
+ requires-python = ">=3.10"
9
+ dependencies = []
10
+
11
+ [project.urls]
12
+ Homepage = "https://github.com/arian24b/cfui"
13
+
14
+ [dependency-groups]
15
+ dev = ["pytest>=8", "ruff>=0.8", "python-semantic-release>=9"]
16
+
17
+ [build-system]
18
+ requires = ["hatchling"]
19
+ build-backend = "hatchling.build"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/cfui"]
23
+
24
+ [tool.ruff]
25
+ line-length = 120
26
+ target-version = "py310"
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+
31
+ [tool.ruff.lint]
32
+ select = ["E", "W", "F", "I", "N", "UP", "S", "B", "A", "C4", "SIM", "RUF"]
33
+ ignore = ["S101", "S110", "S603", "A002", "A001", "E731", "RUF012", "RUF001", "N818", "N806", "N801", "SIM105"]
34
+
35
+ [tool.semantic_release]
36
+ version_toml = ["pyproject.toml:project.version"]
37
+
38
+ [tool.semantic_release.branches.main]
39
+ match = "main"
40
+
41
+ [tool.semantic_release.changelog.default_templates]
42
+ changelog_file = "CHANGELOG.md"
43
+ output_format = "md"
@@ -0,0 +1,18 @@
1
+ from . import common, ui
2
+ from .app import App, Arg, Command
3
+ from .common import add_config, confirm, parse_value, run_parallel
4
+ from .ui import Cancel, Fail
5
+
6
+ __all__ = [
7
+ "App",
8
+ "Arg",
9
+ "Cancel",
10
+ "Command",
11
+ "Fail",
12
+ "add_config",
13
+ "common",
14
+ "confirm",
15
+ "parse_value",
16
+ "run_parallel",
17
+ "ui",
18
+ ]
@@ -0,0 +1,321 @@
1
+ """Tiny argparse framework: cf-style help, and ready-made help / version / update commands."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import importlib.metadata
6
+ import inspect
7
+ import json
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import textwrap
14
+ import urllib.request
15
+
16
+ from . import ui
17
+
18
+ _VALUE_ACTIONS = ("store", "append", "extend")
19
+ _BUILTIN = ("help", "version", "update")
20
+
21
+
22
+ class Arg:
23
+ """An argparse argument. `required=True` means: prompt for it on a TTY, fail with a clear error otherwise."""
24
+
25
+ def __init__(self, *flags, required=False, prompt=None, secret=False, group=None, **kw):
26
+ self.flags, self.required, self.prompt, self.secret, self.kw = flags, required, prompt, secret, kw
27
+ self.group = group or ("Arguments" if flags[0][0] != "-" else None) # help section title
28
+ self.positional = not flags[0].startswith("-")
29
+ if self.positional and required: # we enforce it ourselves so we can prompt
30
+ kw["nargs"] = "*" if kw.get("nargs") in ("+", "*") else "?"
31
+ elif required:
32
+ kw.pop("required", None)
33
+ self.dest = kw.get("dest") or (
34
+ flags[0] if self.positional else max(flags, key=len).lstrip("-").replace("-", "_")
35
+ )
36
+
37
+ def add_to(self, parser):
38
+ parser.add_argument(*self.flags, **self.kw)
39
+
40
+ def _pname(self):
41
+ n = self.kw.get("metavar") or self.flags[0]
42
+ if self.kw.get("nargs") in ("+", "*"):
43
+ n += "..."
44
+ return f"<{n}>" if self.required else f"[{n}]"
45
+
46
+ @property
47
+ def takes_value(self):
48
+ return not self.positional and self.kw.get("action", "store") in _VALUE_ACTIONS
49
+
50
+ def row(self):
51
+ left = ", ".join(self.flags)
52
+ if self.takes_value:
53
+ left += f" <{self.kw.get('metavar') or self.dest.upper()}>"
54
+ elif self.positional:
55
+ left = self._pname()
56
+ right = self.kw.get("help", "")
57
+ if self.required:
58
+ right += " [required]"
59
+ if self.kw.get("choices"):
60
+ right += f" [choices: {', '.join(map(str, self.kw['choices']))}]"
61
+ d = self.kw.get("default")
62
+ if d not in (None, False, "") and self.takes_value:
63
+ right += f" [default: {d}]"
64
+ return left, right.strip()
65
+
66
+
67
+ class _Parser(argparse.ArgumentParser):
68
+ def error(self, message):
69
+ raise ui.Fail(message, f"Run `{self.prog} --help` for usage.", code=2)
70
+
71
+
72
+ class Command:
73
+ def __init__(
74
+ self, name, help_="", fn=None, args=(), aliases=(), hidden=False, banner=False, raw=False, help_args=None
75
+ ):
76
+ self.name, self.help, self.fn, self.args = name, help_, fn, list(args)
77
+ self.raw, self.help_args = (
78
+ raw,
79
+ help_args,
80
+ ) # raw: gets the untouched argv tail as `.args`; help_args: subset shown in --help
81
+ self.aliases, self.hidden, self.banner = aliases, hidden, banner
82
+ self.commands = {} # non-empty for a group
83
+
84
+ def command(self, name, help="", args=(), aliases=(), hidden=False, banner=False, raw=False, help_args=None):
85
+ def deco(fn):
86
+ self.commands[name] = Command(name, help, fn, args, aliases, hidden, banner, raw, help_args)
87
+ return fn
88
+
89
+ return deco
90
+
91
+ def group(self, name, help="", aliases=()):
92
+ g = self.commands[name] = Command(name, help, aliases=aliases)
93
+ return g
94
+
95
+ def find(self, name):
96
+ for c in self.commands.values():
97
+ if name == c.name or name in c.aliases:
98
+ return c
99
+
100
+
101
+ class App(Command):
102
+ def __init__(
103
+ self, name, repo, description="", pypi=None, version=None, docs=None, global_args=(), default=None, upgrade=None
104
+ ):
105
+ super().__init__(name, description)
106
+ self.repo, self.pypi, self.docs = repo, pypi or name, docs or repo
107
+ self.version = version or _installed(self.pypi)
108
+ self.global_args, self.default = list(global_args), default # default: command used when none is named
109
+ self.upgrade = upgrade # optional () -> argv overriding how `update` upgrades (git checkouts, binaries...)
110
+ self.env_quiet = re.sub(r"\W", "_", name).upper() + "_QUIET"
111
+ self._builtins()
112
+
113
+ # --- built-in commands -------------------------------------------------------------------------------------
114
+ def _builtins(self):
115
+ @self.command("help", "Show help for a command", args=[Arg("topic", nargs="*", help="Command path")])
116
+ def _help(a):
117
+ self.print_help(a.topic)
118
+
119
+ @self.command("version", "Show version and repository")
120
+ def _version(a):
121
+ ui.out(self.version_text())
122
+
123
+ @self.command(
124
+ "update",
125
+ "Update to the latest version",
126
+ banner=True,
127
+ args=[Arg("--check", action="store_true", help="Only compare versions")],
128
+ )
129
+ def _update(a):
130
+ return self.update(a.check)
131
+
132
+ def version_text(self):
133
+ return f"{self.name} {self.version}\nrepo {self.repo}\npython {sys.version.split()[0]}"
134
+
135
+ def update(self, check_only=False):
136
+ latest = latest_version(self.pypi)
137
+ ui.out(f"installed: {self.version}\nlatest: {latest or 'unknown (offline or not published)'}")
138
+ if check_only:
139
+ return 0
140
+ if latest and latest == self.version:
141
+ ui.success("already up to date")
142
+ return 0
143
+ cmd = self.upgrade() if self.upgrade else upgrade_command(self.pypi)
144
+ ui.hint("running: " + " ".join(cmd))
145
+ return subprocess.call(cmd)
146
+
147
+ # --- help --------------------------------------------------------------------------------------------------
148
+ def print_help(self, path=()):
149
+ node, names = self, [self.name]
150
+ for p in path:
151
+ nxt = node.find(p)
152
+ if not nxt:
153
+ raise ui.Fail(
154
+ f"Unknown command: {' '.join([*names[1:], p])}", f"Run `{self.name} help` to list commands.", 2
155
+ )
156
+ node = nxt
157
+ names.append(nxt.name)
158
+ ui.banner(self.name, self.version)
159
+ H = lambda s: ui.paint("brand", ui.bold(s))
160
+ C = lambda s: ui.paint("info", s)
161
+ use = " ".join(names)
162
+ leaf = not node.commands
163
+ line = f"{use} <command> [options]" if not leaf else f"{use}{_usage(node.args)} [options]"
164
+ lines = [line, ""]
165
+ if node.help:
166
+ lines += [node.help, ""]
167
+ if node.commands:
168
+ lines.append(H("Commands"))
169
+ cmds = sorted(
170
+ (c for c in node.commands.values() if not c.hidden), key=lambda c: c.name in _BUILTIN and node is self
171
+ )
172
+ rows = [(f"{use} {c.name}", c.help) for c in cmds]
173
+ lines += _cols(rows, C)
174
+ lines.append("")
175
+ shown = (node.args if node.help_args is None else node.help_args) if leaf else self.global_args
176
+ if shown and (leaf or node is self):
177
+ sections: dict[str, list] = {}
178
+ for a in shown:
179
+ sections.setdefault(a.group or "Options", []).append(a.row())
180
+ for title, rows in sections.items():
181
+ lines += [H(title), *_cols(rows, C), ""]
182
+ lines.append(H("Global flags"))
183
+ lines += _cols(
184
+ [
185
+ ("-q, --quiet", "Suppress non-essential output [boolean] [default: false]"),
186
+ ("-h, --help", "Show help [boolean]"),
187
+ ]
188
+ + ([("-v, --version", "Show version [boolean]")] if node is self else []),
189
+ C,
190
+ )
191
+ lines += ["", ui.paint("muted", f"For more information, visit {self.docs}")]
192
+ ui.out("\n".join(lines))
193
+
194
+ # --- run ---------------------------------------------------------------------------------------------------
195
+ def run(self, argv=None):
196
+ argv = list(sys.argv[1:] if argv is None else argv)
197
+ try:
198
+ return self._run(argv) or 0
199
+ except ui.Fail as e:
200
+ ui.error_box(e, e.hint)
201
+ return e.code
202
+ except (ui.Cancel, KeyboardInterrupt):
203
+ return 130
204
+ except BrokenPipeError:
205
+ return 0
206
+ except SystemExit as e:
207
+ if isinstance(e.code, str): # legacy `sys.exit("message")` becomes a boxed error
208
+ ui.error_box(e.code)
209
+ return 1
210
+ return e.code or 0
211
+
212
+ def _run(self, argv):
213
+ if argv[:1] in (["-v"], ["--version"]):
214
+ ui.out(self.version_text())
215
+ return 0
216
+ raw = self.find(argv[0]) if argv else None
217
+ if raw is not None and raw.raw: # passthrough commands see the rest of argv untouched
218
+ ui.quiet = bool(os.environ.get(self.env_quiet))
219
+ r = raw.fn(argparse.Namespace(args=argv[1:]))
220
+ return asyncio.run(r) if inspect.iscoroutine(r) else r
221
+ g = _Parser(add_help=False, allow_abbrev=False, prog=self.name)
222
+ g.add_argument("-q", "--quiet", action="store_true")
223
+ g.add_argument("-h", "--help", action="store_true")
224
+ for a in self.global_args:
225
+ a.add_to(g)
226
+ gns, rest = g.parse_known_args(argv)
227
+ ui.quiet = gns.quiet or bool(os.environ.get(self.env_quiet))
228
+ node, names = self, [self.name]
229
+ if self.default and not gns.help and (rest or not sys.stdin.isatty()) and (not rest or rest[0].startswith("-")):
230
+ rest = [self.default, *rest]
231
+ while rest and node.commands and not rest[0].startswith("-"):
232
+ nxt = node.find(rest[0])
233
+ if not nxt:
234
+ raise ui.Fail(f"Unknown command: {rest[0]}", f"Run `{' '.join(names)} --help` to list commands.", 2)
235
+ node, rest = nxt, rest[1:]
236
+ names.append(node.name)
237
+ if node.commands: # root or group without a verb
238
+ if rest:
239
+ raise ui.Fail(f"Unknown option: {rest[0]}", f"Run `{' '.join(names)} --help` for usage.", 2)
240
+ self.print_help(names[1:])
241
+ return 0
242
+ if gns.help:
243
+ self.print_help(names[1:])
244
+ return 0
245
+ p = _Parser(add_help=False, allow_abbrev=False, prog=" ".join(names))
246
+ for a in node.args:
247
+ a.add_to(p)
248
+ ns = p.parse_args(rest)
249
+ for k, v in vars(gns).items():
250
+ setattr(ns, k, v)
251
+ for a in node.args:
252
+ if a.required and getattr(ns, a.dest, None) in (None, [], ""):
253
+ if node.banner:
254
+ ui.banner(self.name, self.version)
255
+ flag = a.flags[0] if a.positional else max(a.flags, key=len)
256
+ v = ui.need(
257
+ None,
258
+ flag,
259
+ a.prompt or a.dest.replace("_", " ").capitalize(),
260
+ positional=a.positional,
261
+ secret=a.secret,
262
+ choices=a.kw.get("choices"),
263
+ default=a.kw.get("default"),
264
+ )
265
+ setattr(ns, a.dest, _convert(a, v, flag))
266
+ if node.banner:
267
+ ui.banner(self.name, self.version)
268
+ r = node.fn(ns)
269
+ return asyncio.run(r) if inspect.iscoroutine(r) else r
270
+
271
+ def main(self, argv=None):
272
+ sys.exit(self.run(argv))
273
+
274
+
275
+ def _convert(a, v, flag):
276
+ t = a.kw.get("type")
277
+ try:
278
+ if a.kw.get("nargs") in ("+", "*"): # prompted text becomes a list
279
+ return [t(x) if t else x for x in v.replace(",", " ").split()]
280
+ return t(v) if t and isinstance(v, str) else v
281
+ except (ValueError, TypeError, argparse.ArgumentTypeError) as e:
282
+ raise ui.Fail(f"Invalid value for {flag}: {v!r} ({e})", code=2) from None
283
+
284
+
285
+ def _usage(args):
286
+ return "".join(" " + a._pname() for a in args if a.positional)
287
+
288
+
289
+ def _cols(rows, style):
290
+ width = min(max((len(left) for left, _ in rows), default=0), 36)
291
+ cols = shutil.get_terminal_size((100, 24)).columns
292
+ out = []
293
+ for left, right in rows:
294
+ wrapped = textwrap.wrap(right, max(cols - width - 4, 30)) or [""]
295
+ out.append(f" {style(left)}{' ' * (width - len(left) + 2)}{wrapped[0]}")
296
+ out += [f" {' ' * (width + 2)}{w}" for w in wrapped[1:]]
297
+ return out
298
+
299
+
300
+ def _installed(pkg):
301
+ try:
302
+ return importlib.metadata.version(pkg)
303
+ except importlib.metadata.PackageNotFoundError:
304
+ return "unknown"
305
+
306
+
307
+ def latest_version(pkg):
308
+ try:
309
+ with urllib.request.urlopen(f"https://pypi.org/pypi/{pkg}/json", timeout=5) as r:
310
+ return json.load(r)["info"]["version"]
311
+ except Exception:
312
+ return None
313
+
314
+
315
+ def upgrade_command(pkg):
316
+ exe = sys.executable.replace("\\", "/")
317
+ if "/uv/tools/" in exe and shutil.which("uv"):
318
+ return ["uv", "tool", "upgrade", pkg]
319
+ if "/pipx/" in exe and shutil.which("pipx"):
320
+ return ["pipx", "upgrade", pkg]
321
+ return [sys.executable, "-m", "pip", "install", "-U", pkg]