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.
- cfuicli-1.0.0/.github/dependabot.yml +10 -0
- cfuicli-1.0.0/.github/workflows/ci.yml +14 -0
- cfuicli-1.0.0/.github/workflows/release.yml +25 -0
- cfuicli-1.0.0/.gitignore +4 -0
- cfuicli-1.0.0/.gitmessage +7 -0
- cfuicli-1.0.0/.python-version +1 -0
- cfuicli-1.0.0/AGENTS.md +4 -0
- cfuicli-1.0.0/CHANGELOG.md +7 -0
- cfuicli-1.0.0/LICENSE +0 -0
- cfuicli-1.0.0/PKG-INFO +45 -0
- cfuicli-1.0.0/README.md +34 -0
- cfuicli-1.0.0/docs/workflow.md +27 -0
- cfuicli-1.0.0/pyproject.toml +43 -0
- cfuicli-1.0.0/src/cfui/__init__.py +18 -0
- cfuicli-1.0.0/src/cfui/app.py +321 -0
- cfuicli-1.0.0/src/cfui/common.py +123 -0
- cfuicli-1.0.0/src/cfui/ui.py +285 -0
- cfuicli-1.0.0/tests/test_cfui.py +126 -0
- cfuicli-1.0.0/uv.lock +1020 -0
|
@@ -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
|
cfuicli-1.0.0/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
cfuicli-1.0.0/AGENTS.md
ADDED
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`.
|
cfuicli-1.0.0/README.md
ADDED
|
@@ -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]
|