usecli 0.1.78__tar.gz → 0.1.80__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.
- {usecli-0.1.78 → usecli-0.1.80}/PKG-INFO +45 -2
- {usecli-0.1.78 → usecli-0.1.80}/README.md +44 -1
- {usecli-0.1.78 → usecli-0.1.80}/pyproject.toml +1 -1
- {usecli-0.1.78 → usecli-0.1.80}/pyproject.toml.orig +1 -1
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/__init__.py +4 -2
- usecli-0.1.80/src/usecli/cli/commands/defaults/base/upgrade_command.py +316 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/templates/usecli.config.toml.j2 +10 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/entry.py +5 -2
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/shared/config/manager.py +13 -2
- usecli-0.1.80/src/usecli/shared/upgrades/__init__.py +22 -0
- usecli-0.1.80/src/usecli/shared/upgrades/checker.py +276 -0
- usecli-0.1.80/src/usecli/shared/upgrades/discovery.py +370 -0
- usecli-0.1.80/src/usecli/shared/upgrades/installer.py +262 -0
- usecli-0.1.80/src/usecli/shared/upgrades/pyproject.py +619 -0
- usecli-0.1.80/src/usecli/shared/upgrades/service.py +47 -0
- usecli-0.1.80/src/usecli/shared/upgrades/settings.py +129 -0
- {usecli-0.1.78 → usecli-0.1.80}/LICENSE +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/bundler/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/bundler/__main__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/README.md +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/custom/README.md +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/custom/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/base/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/base/about_command.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/base/help_command.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/base/inspire_command.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/base/internal/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/base/internal/fzf_command.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/core/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/core/utils.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/make/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/make/make_bundle_command.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/make/make_command.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/defaults/make/make_theme_command.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/commands/init_command.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/config/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/config/colors.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/base_command.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/error/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/error/handler.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/error/utils.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/exceptions/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/exceptions/base.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/exceptions/config.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/exceptions/usage.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/exceptions/validation.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/runtime.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/ui/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/ui/list.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/ui/title.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/ui/title.txt +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/validators/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/validators/network.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/validators/numeric.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/validators/path.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/core/validators/string.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/services/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/services/command_service.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/templates/command.py.j2 +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/templates/theme.toml.j2 +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/ayu_dark.toml +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/catppuccin_frappe.toml +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/catppuccin_latte.toml +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/catppuccin_macchiato.toml +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/catppuccin_mocha.toml +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/default.toml +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/dracula.toml +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/gruvbox_dark.toml +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/nord.toml +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/themes/tokyo_night.toml +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/utils/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/utils/interactive/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/cli/utils/interactive/terminal_menu.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/menu.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/params.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/progress.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/shared/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/shared/config/__init__.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/shared/config/globals.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/ui.py +0 -0
- {usecli-0.1.78 → usecli-0.1.80}/src/usecli/usecli.config.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: usecli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.80
|
|
4
4
|
Summary: A powerful Python CLI framework for building beautiful, developer-friendly command-line tools.
|
|
5
5
|
Author: Edward Boswell
|
|
6
6
|
Author-email: Edward Boswell <thememium@gmail.com>
|
|
@@ -245,11 +245,41 @@ about Show app info
|
|
|
245
245
|
help Show help
|
|
246
246
|
init Initialize usecli (usecli only)
|
|
247
247
|
inspire Random quote
|
|
248
|
+
upgrade Upgrade the application to the latest version
|
|
248
249
|
make:command Create new command (usecli only)
|
|
249
250
|
make:theme Create new theme (usecli only)
|
|
250
251
|
make:bundle Build a standalone executable with PyInstaller (usecli only)
|
|
251
252
|
```
|
|
252
253
|
|
|
254
|
+
### Upgrades
|
|
255
|
+
|
|
256
|
+
Every CLI built on usecli ships `upgrade`, which detects how the running
|
|
257
|
+
package was installed and delegates to the same package manager — `uv tool`,
|
|
258
|
+
`pipx`, or `pip` — instead of creating a second installation:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
mycli upgrade --check # show installed/latest versions and the source
|
|
262
|
+
mycli upgrade # confirm and install the latest release
|
|
263
|
+
mycli upgrade --force # skip the prompt and the up-to-date check
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The source comes from packaging metadata: PyPI for index installs, and the
|
|
267
|
+
Git remote for PEP 610 `direct_url.json` installs. Override it in
|
|
268
|
+
`usecli.config.toml` when metadata is not enough (private mirrors, missing
|
|
269
|
+
`direct_url.json`, a package name that differs from the command name):
|
|
270
|
+
|
|
271
|
+
```toml
|
|
272
|
+
[usecli.upgrade]
|
|
273
|
+
enabled = true
|
|
274
|
+
source = "git" # "auto" (default) | "pypi" | "git"
|
|
275
|
+
package = "mycli" # optional
|
|
276
|
+
url = "https://github.com/owner/mycli.git" # required for "git" unless detected
|
|
277
|
+
ref = "main" # branch, tag, or commit
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Frozen bundles, editable installs, and commit-pinned installs are reported
|
|
281
|
+
as non-upgradeable with an explanation and manual next step.
|
|
282
|
+
|
|
253
283
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
|
254
284
|
|
|
255
285
|
<!-- ENTRY POINTS & PACKAGING -->
|
|
@@ -289,9 +319,14 @@ file is always named `usecli.config.toml`):
|
|
|
289
319
|
# main.py
|
|
290
320
|
from usecli import run
|
|
291
321
|
|
|
292
|
-
|
|
322
|
+
|
|
323
|
+
def main():
|
|
293
324
|
run() # auto-detect project config
|
|
294
325
|
# run("path/to/usecli.config.toml") # or point at a specific config
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
if __name__ == "__main__":
|
|
329
|
+
main()
|
|
295
330
|
```
|
|
296
331
|
|
|
297
332
|
Run it in development (no PyInstaller required):
|
|
@@ -300,6 +335,14 @@ Run it in development (no PyInstaller required):
|
|
|
300
335
|
uv run python main.py --help
|
|
301
336
|
```
|
|
302
337
|
|
|
338
|
+
To expose the same entry point as a console script, point `[project.scripts]`
|
|
339
|
+
at the `main` function in your `myapp` package (the usual `src/myapp` layout):
|
|
340
|
+
|
|
341
|
+
```toml
|
|
342
|
+
[project.scripts]
|
|
343
|
+
myapp = "myapp:main"
|
|
344
|
+
```
|
|
345
|
+
|
|
303
346
|
Because `run()` injects the located config explicitly, a custom `main.py` works
|
|
304
347
|
regardless of whether your CLI also has a `[project.scripts]` entry.
|
|
305
348
|
|
|
@@ -215,11 +215,41 @@ about Show app info
|
|
|
215
215
|
help Show help
|
|
216
216
|
init Initialize usecli (usecli only)
|
|
217
217
|
inspire Random quote
|
|
218
|
+
upgrade Upgrade the application to the latest version
|
|
218
219
|
make:command Create new command (usecli only)
|
|
219
220
|
make:theme Create new theme (usecli only)
|
|
220
221
|
make:bundle Build a standalone executable with PyInstaller (usecli only)
|
|
221
222
|
```
|
|
222
223
|
|
|
224
|
+
### Upgrades
|
|
225
|
+
|
|
226
|
+
Every CLI built on usecli ships `upgrade`, which detects how the running
|
|
227
|
+
package was installed and delegates to the same package manager — `uv tool`,
|
|
228
|
+
`pipx`, or `pip` — instead of creating a second installation:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
mycli upgrade --check # show installed/latest versions and the source
|
|
232
|
+
mycli upgrade # confirm and install the latest release
|
|
233
|
+
mycli upgrade --force # skip the prompt and the up-to-date check
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The source comes from packaging metadata: PyPI for index installs, and the
|
|
237
|
+
Git remote for PEP 610 `direct_url.json` installs. Override it in
|
|
238
|
+
`usecli.config.toml` when metadata is not enough (private mirrors, missing
|
|
239
|
+
`direct_url.json`, a package name that differs from the command name):
|
|
240
|
+
|
|
241
|
+
```toml
|
|
242
|
+
[usecli.upgrade]
|
|
243
|
+
enabled = true
|
|
244
|
+
source = "git" # "auto" (default) | "pypi" | "git"
|
|
245
|
+
package = "mycli" # optional
|
|
246
|
+
url = "https://github.com/owner/mycli.git" # required for "git" unless detected
|
|
247
|
+
ref = "main" # branch, tag, or commit
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Frozen bundles, editable installs, and commit-pinned installs are reported
|
|
251
|
+
as non-upgradeable with an explanation and manual next step.
|
|
252
|
+
|
|
223
253
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
|
224
254
|
|
|
225
255
|
<!-- ENTRY POINTS & PACKAGING -->
|
|
@@ -259,9 +289,14 @@ file is always named `usecli.config.toml`):
|
|
|
259
289
|
# main.py
|
|
260
290
|
from usecli import run
|
|
261
291
|
|
|
262
|
-
|
|
292
|
+
|
|
293
|
+
def main():
|
|
263
294
|
run() # auto-detect project config
|
|
264
295
|
# run("path/to/usecli.config.toml") # or point at a specific config
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
if __name__ == "__main__":
|
|
299
|
+
main()
|
|
265
300
|
```
|
|
266
301
|
|
|
267
302
|
Run it in development (no PyInstaller required):
|
|
@@ -270,6 +305,14 @@ Run it in development (no PyInstaller required):
|
|
|
270
305
|
uv run python main.py --help
|
|
271
306
|
```
|
|
272
307
|
|
|
308
|
+
To expose the same entry point as a console script, point `[project.scripts]`
|
|
309
|
+
at the `main` function in your `myapp` package (the usual `src/myapp` layout):
|
|
310
|
+
|
|
311
|
+
```toml
|
|
312
|
+
[project.scripts]
|
|
313
|
+
myapp = "myapp:main"
|
|
314
|
+
```
|
|
315
|
+
|
|
273
316
|
Because `run()` injects the located config explicitly, a custom `main.py` works
|
|
274
317
|
regardless of whether your CLI also has a `[project.scripts]` entry.
|
|
275
318
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "usecli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.80"
|
|
4
4
|
description = "A powerful Python CLI framework for building beautiful, developer-friendly command-line tools."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "Edward Boswell", email = "thememium@gmail.com" }]
|
|
@@ -274,7 +274,7 @@ def _ensure_cli_initialized() -> None:
|
|
|
274
274
|
fail(error.__class__.__name__, str(error), 1)
|
|
275
275
|
|
|
276
276
|
def invoke(self, ctx):
|
|
277
|
-
from click.exceptions import Exit
|
|
277
|
+
from click.exceptions import ClickException, Exit
|
|
278
278
|
|
|
279
279
|
from usecli.cli.core.runtime import is_json_mode
|
|
280
280
|
|
|
@@ -306,7 +306,9 @@ def _ensure_cli_initialized() -> None:
|
|
|
306
306
|
styled_error = UsecliUsageError(e.message, ctx=e.ctx)
|
|
307
307
|
styled_error.show()
|
|
308
308
|
sys.exit(styled_error.exit_code)
|
|
309
|
-
|
|
309
|
+
# UsecliError extends real-click ClickException, not the vendored
|
|
310
|
+
# typer._click one — catch both, mirroring the JSON path.
|
|
311
|
+
except (_TyperClickException, ClickException) as e:
|
|
310
312
|
if hasattr(e, "show"):
|
|
311
313
|
e.show()
|
|
312
314
|
sys.exit(e.exit_code if hasattr(e, "exit_code") else 1)
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""UpgradeCommand - upgrade the running application to the latest version.
|
|
2
|
+
|
|
3
|
+
A built-in default command available in every generated CLI. Installation
|
|
4
|
+
discovery, checking, and mutation are delegated to
|
|
5
|
+
``usecli.shared.upgrades.UpgradeService``; this command owns only the
|
|
6
|
+
interaction: flag handling, confirmation, and output formatting.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Annotated, Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from usecli.cli.config.colors import COLOR
|
|
16
|
+
from usecli.cli.core.base_command import BaseCommand
|
|
17
|
+
from usecli.cli.core.exceptions.base import UsecliError
|
|
18
|
+
from usecli.shared.config.manager import get_config
|
|
19
|
+
from usecli.shared.upgrades import UpgradeService
|
|
20
|
+
from usecli.shared.upgrades.checker import UpgradeStatus
|
|
21
|
+
from usecli.shared.upgrades.discovery import InstallInfo
|
|
22
|
+
from usecli.ui import Confirm
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _LazyConsole:
|
|
26
|
+
_console: Any | None = None
|
|
27
|
+
|
|
28
|
+
def _get_console(self) -> Any:
|
|
29
|
+
if self._console is None:
|
|
30
|
+
from rich.console import Console
|
|
31
|
+
|
|
32
|
+
self._console = Console()
|
|
33
|
+
return self._console
|
|
34
|
+
|
|
35
|
+
def __getattr__(self, name: str) -> Any:
|
|
36
|
+
return getattr(self._get_console(), name)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
console = _LazyConsole()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _short_commit(commit: str | None) -> str:
|
|
43
|
+
"""Render a commit id for display; version labels pass through intact."""
|
|
44
|
+
if not commit:
|
|
45
|
+
return "-"
|
|
46
|
+
import re
|
|
47
|
+
|
|
48
|
+
if re.fullmatch(r"[0-9a-fA-F]{8,40}", commit):
|
|
49
|
+
return commit[:7]
|
|
50
|
+
return commit
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class UpgradeCommand(BaseCommand):
|
|
54
|
+
"""Upgrade the running application from its original install source."""
|
|
55
|
+
|
|
56
|
+
def visible(self) -> bool:
|
|
57
|
+
config = get_config()
|
|
58
|
+
return bool(config.get("upgrade.enabled", True))
|
|
59
|
+
|
|
60
|
+
def signature(self) -> str:
|
|
61
|
+
return "upgrade"
|
|
62
|
+
|
|
63
|
+
def description(self) -> str:
|
|
64
|
+
return "Upgrade the application to the latest version"
|
|
65
|
+
|
|
66
|
+
def handle(
|
|
67
|
+
self,
|
|
68
|
+
check: Annotated[
|
|
69
|
+
bool,
|
|
70
|
+
typer.Option(
|
|
71
|
+
"--check",
|
|
72
|
+
help="Only check for updates; do not install anything.",
|
|
73
|
+
),
|
|
74
|
+
] = False,
|
|
75
|
+
force: Annotated[
|
|
76
|
+
bool,
|
|
77
|
+
typer.Option(
|
|
78
|
+
"--force",
|
|
79
|
+
help=(
|
|
80
|
+
"Upgrade even when already up to date and skip the "
|
|
81
|
+
"confirmation prompt."
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
] = False,
|
|
85
|
+
) -> dict[str, object]:
|
|
86
|
+
"""Handle the command execution."""
|
|
87
|
+
from usecli.cli.core.runtime import is_json_mode
|
|
88
|
+
|
|
89
|
+
config = get_config()
|
|
90
|
+
install = UpgradeService.discover(config)
|
|
91
|
+
status = UpgradeService.check(install, config)
|
|
92
|
+
|
|
93
|
+
data: dict[str, object] = {
|
|
94
|
+
"mode": "check" if check else "apply",
|
|
95
|
+
"package": install.package,
|
|
96
|
+
"current": install.version,
|
|
97
|
+
"latest": status.latest,
|
|
98
|
+
"source": install.source,
|
|
99
|
+
"source_detail": status.detail,
|
|
100
|
+
"installer": install.installer,
|
|
101
|
+
"url": install.url,
|
|
102
|
+
"revision": install.revision,
|
|
103
|
+
"commit": install.commit,
|
|
104
|
+
"pinned": install.pinned,
|
|
105
|
+
"pinned_reason": install.pinned_reason,
|
|
106
|
+
"frozen": install.frozen,
|
|
107
|
+
"update_available": status.update_available,
|
|
108
|
+
"upgraded": None,
|
|
109
|
+
"error": status.error,
|
|
110
|
+
"latest_tag": status.latest_tag,
|
|
111
|
+
"latest_tag_commit": status.latest_tag_commit,
|
|
112
|
+
"note": status.note,
|
|
113
|
+
"pyproject_path": None,
|
|
114
|
+
"pyproject_previous_version": None,
|
|
115
|
+
"pyproject_new_version": None,
|
|
116
|
+
"pyproject_updated": None,
|
|
117
|
+
"pyproject_reason": None,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if check:
|
|
121
|
+
if status.error:
|
|
122
|
+
raise UsecliError(
|
|
123
|
+
status.error,
|
|
124
|
+
suggestion="Check your network connection and try again.",
|
|
125
|
+
)
|
|
126
|
+
if is_json_mode():
|
|
127
|
+
return data
|
|
128
|
+
self._print_check(status)
|
|
129
|
+
return data
|
|
130
|
+
|
|
131
|
+
# Mutations are impossible for these installations; fail loudly.
|
|
132
|
+
if (
|
|
133
|
+
install.frozen
|
|
134
|
+
or install.pinned
|
|
135
|
+
or install.source
|
|
136
|
+
in (
|
|
137
|
+
"editable",
|
|
138
|
+
"unknown",
|
|
139
|
+
)
|
|
140
|
+
):
|
|
141
|
+
raise UsecliError(
|
|
142
|
+
self._blocked_message(install),
|
|
143
|
+
suggestion=self._blocked_suggestion(install),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if status.error is None and not status.update_available and not force:
|
|
147
|
+
data["upgraded"] = False
|
|
148
|
+
data["message"] = "Already up to date."
|
|
149
|
+
if is_json_mode():
|
|
150
|
+
return data
|
|
151
|
+
console.print(
|
|
152
|
+
f"[{COLOR.INFO}]Already up to date ({install.version}).[/{COLOR.INFO}]"
|
|
153
|
+
)
|
|
154
|
+
return data
|
|
155
|
+
|
|
156
|
+
if not is_json_mode() and not force:
|
|
157
|
+
confirmed = Confirm.ask(
|
|
158
|
+
f"[{COLOR.WARNING}]Upgrade {install.package} from "
|
|
159
|
+
f"{install.version} to "
|
|
160
|
+
f"{_short_commit(status.latest) if status.latest else 'latest'}?"
|
|
161
|
+
f"[/{COLOR.WARNING}]",
|
|
162
|
+
default=False,
|
|
163
|
+
)
|
|
164
|
+
if not confirmed:
|
|
165
|
+
data["upgraded"] = False
|
|
166
|
+
data["message"] = "Aborted."
|
|
167
|
+
console.print(
|
|
168
|
+
f"[{COLOR.INFO}]Aborted. Re-run with --force to skip "
|
|
169
|
+
f"confirmation.[/{COLOR.INFO}]"
|
|
170
|
+
)
|
|
171
|
+
return data
|
|
172
|
+
|
|
173
|
+
result = self._run_upgrade(install, config, status)
|
|
174
|
+
data["upgraded"] = result.success
|
|
175
|
+
data["message"] = result.message
|
|
176
|
+
|
|
177
|
+
if result.pyproject is not None:
|
|
178
|
+
data["pyproject_path"] = result.pyproject.path
|
|
179
|
+
data["pyproject_previous_version"] = result.pyproject.previous_version
|
|
180
|
+
data["pyproject_new_version"] = result.pyproject.new_version
|
|
181
|
+
data["pyproject_updated"] = result.pyproject.updated
|
|
182
|
+
data["pyproject_reason"] = result.pyproject.reason
|
|
183
|
+
|
|
184
|
+
if not result.success:
|
|
185
|
+
raise UsecliError(
|
|
186
|
+
result.message or "Upgrade failed.",
|
|
187
|
+
suggestion="Run the upgrade command manually.",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if is_json_mode():
|
|
191
|
+
return data
|
|
192
|
+
|
|
193
|
+
console.print(f"[{COLOR.SUCCESS}]✓ Upgrade complete.[/{COLOR.SUCCESS}]")
|
|
194
|
+
|
|
195
|
+
if result.pyproject is not None:
|
|
196
|
+
if result.pyproject.updated:
|
|
197
|
+
console.print(result.pyproject.summary)
|
|
198
|
+
elif result.pyproject.reason:
|
|
199
|
+
console.print(
|
|
200
|
+
f"[{COLOR.WARNING}]⚠ {result.pyproject.reason}[/{COLOR.WARNING}]"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
from usecli.cli.core.ui.title import get_project_name
|
|
204
|
+
|
|
205
|
+
console.print(
|
|
206
|
+
f"Run `[bold {COLOR.PRIMARY}]{get_project_name()} --version"
|
|
207
|
+
f"[/bold {COLOR.PRIMARY}]` to verify."
|
|
208
|
+
)
|
|
209
|
+
return data
|
|
210
|
+
|
|
211
|
+
def _run_upgrade(
|
|
212
|
+
self,
|
|
213
|
+
install: InstallInfo,
|
|
214
|
+
config: Any,
|
|
215
|
+
status: UpgradeStatus,
|
|
216
|
+
):
|
|
217
|
+
"""Run the upgrade behind a spinner (auto-disabled in JSON mode)."""
|
|
218
|
+
from usecli import Spinner
|
|
219
|
+
|
|
220
|
+
target = status.latest or "latest"
|
|
221
|
+
with Spinner(f"Upgrading {install.package}") as spinner:
|
|
222
|
+
spinner.update(
|
|
223
|
+
f"Upgrading {install.package} ({install.version} → {target})"
|
|
224
|
+
)
|
|
225
|
+
return UpgradeService.upgrade(
|
|
226
|
+
install,
|
|
227
|
+
config,
|
|
228
|
+
target_revision=status.latest_tag,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def _print_check(self, status: UpgradeStatus) -> None:
|
|
232
|
+
"""Render the non-mutating check report."""
|
|
233
|
+
install = status.install
|
|
234
|
+
console.print()
|
|
235
|
+
console.print(f"[bold {COLOR.PRIMARY}]Upgrade Check[/bold {COLOR.PRIMARY}]")
|
|
236
|
+
console.print(f"[{COLOR.PRIMARY}]─" * 78)
|
|
237
|
+
self._print_row("Package", install.package or "unknown")
|
|
238
|
+
self._print_row("Installed", install.version)
|
|
239
|
+
self._print_row("Source", status.detail or install.source)
|
|
240
|
+
if install.source == "git":
|
|
241
|
+
if install.revision:
|
|
242
|
+
self._print_row("Ref", install.revision)
|
|
243
|
+
self._print_row("Commit", _short_commit(install.commit))
|
|
244
|
+
if status.latest:
|
|
245
|
+
self._print_row("Latest", _short_commit(status.latest))
|
|
246
|
+
elif status.latest:
|
|
247
|
+
self._print_row("Latest", status.latest)
|
|
248
|
+
self._print_row("Installer", install.installer or "unknown")
|
|
249
|
+
console.print()
|
|
250
|
+
|
|
251
|
+
if install.pinned:
|
|
252
|
+
console.print(
|
|
253
|
+
f"[{COLOR.WARNING}]This installation is pinned "
|
|
254
|
+
f"({install.pinned_reason}); no automatic upgrade."
|
|
255
|
+
f"[/{COLOR.WARNING}]"
|
|
256
|
+
)
|
|
257
|
+
elif status.update_available:
|
|
258
|
+
from usecli.cli.core.ui.title import get_project_name
|
|
259
|
+
|
|
260
|
+
console.print(
|
|
261
|
+
f"[{COLOR.SUCCESS}]Update available: {install.version} → "
|
|
262
|
+
f"{_short_commit(status.latest)}. Run "
|
|
263
|
+
f"`{get_project_name()} upgrade` to update.[/{COLOR.SUCCESS}]"
|
|
264
|
+
)
|
|
265
|
+
else:
|
|
266
|
+
if status.note:
|
|
267
|
+
console.print(f"[{COLOR.INFO}]{status.note}[/{COLOR.INFO}]")
|
|
268
|
+
else:
|
|
269
|
+
console.print(f"[{COLOR.INFO}]You are up to date.[/{COLOR.INFO}]")
|
|
270
|
+
console.print()
|
|
271
|
+
|
|
272
|
+
def _print_row(self, label: str, value: str) -> None:
|
|
273
|
+
visible_value = console.render_str(value).plain
|
|
274
|
+
right_align_column = 76
|
|
275
|
+
indent_width = 2
|
|
276
|
+
padding_spaces = 2
|
|
277
|
+
dots_length = (
|
|
278
|
+
right_align_column
|
|
279
|
+
- indent_width
|
|
280
|
+
- len(label)
|
|
281
|
+
- padding_spaces
|
|
282
|
+
- len(visible_value)
|
|
283
|
+
)
|
|
284
|
+
dots = "." * max(dots_length, 1)
|
|
285
|
+
console.print(
|
|
286
|
+
f" [{COLOR.FOREGROUND}]{label}[/{COLOR.FOREGROUND}] {dots} {value}"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def _blocked_message(self, install: InstallInfo) -> str:
|
|
290
|
+
if install.frozen:
|
|
291
|
+
return "This application runs from a frozen bundle and cannot self-upgrade."
|
|
292
|
+
if install.source == "editable":
|
|
293
|
+
return "This is an editable install and cannot self-upgrade."
|
|
294
|
+
if install.pinned:
|
|
295
|
+
return (
|
|
296
|
+
f"This installation is pinned ({install.pinned_reason}) and "
|
|
297
|
+
"will not automatically upgrade."
|
|
298
|
+
)
|
|
299
|
+
return (
|
|
300
|
+
"Could not determine how this application was installed; "
|
|
301
|
+
"automatic upgrade is unavailable."
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def _blocked_suggestion(self, install: InstallInfo) -> str:
|
|
305
|
+
if install.frozen:
|
|
306
|
+
return "Download the latest release or rebuild the bundle."
|
|
307
|
+
if install.source == "editable":
|
|
308
|
+
return "Update the source repository and re-sync (e.g. `uv sync`)."
|
|
309
|
+
if install.source == "git" and install.url:
|
|
310
|
+
revision = f"@{install.revision}" if install.revision else ""
|
|
311
|
+
return (
|
|
312
|
+
"Reinstall from the source you want, e.g. "
|
|
313
|
+
f'`uv tool install --force "{install.package} @ '
|
|
314
|
+
f'git+{install.url}{revision}"`.'
|
|
315
|
+
)
|
|
316
|
+
return "Reinstall the package with your package manager to upgrade."
|
|
@@ -9,3 +9,13 @@ templates_dir = "{{ templates_dir | default('cli/templates') }}"
|
|
|
9
9
|
themes_dir = "{{ themes_dir | default('cli/themes') }}"
|
|
10
10
|
theme = "{{ theme | default('default') }}"
|
|
11
11
|
hide_inspire = {{ hide_inspire | default(false) | lower }}
|
|
12
|
+
|
|
13
|
+
[usecli.upgrade]
|
|
14
|
+
enabled = {{ upgrade_enabled | default(true) | lower }}
|
|
15
|
+
# Where upgrades come from: "auto" (detect from install metadata), "pypi", or "git".
|
|
16
|
+
source = "{{ upgrade_source | default('auto') }}"
|
|
17
|
+
# Git only: repository URL. Falls back to the URL in the install metadata.
|
|
18
|
+
# url = "https://github.com/owner/repo.git"
|
|
19
|
+
# Optional: distribution name and Git ref (branch, tag, or commit) overrides.
|
|
20
|
+
# package = "my-cli"
|
|
21
|
+
# ref = "main"
|
|
@@ -5,9 +5,12 @@ thin ``main.py``::
|
|
|
5
5
|
|
|
6
6
|
from usecli import run
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
def main():
|
|
9
9
|
run() # auto-detect the project config
|
|
10
|
-
run("my/custom/cli.toml")
|
|
10
|
+
# run("my/custom/cli.toml") # or point at a specific config
|
|
11
|
+
|
|
12
|
+
if __name__ == "__main__":
|
|
13
|
+
main()
|
|
11
14
|
|
|
12
15
|
It works in three contexts:
|
|
13
16
|
|
|
@@ -284,6 +284,16 @@ class ConfigManager:
|
|
|
284
284
|
"environment": "prod",
|
|
285
285
|
"command_name": "usecli",
|
|
286
286
|
"hide_inspire": False,
|
|
287
|
+
# The built-in `upgrade` command reads [usecli.upgrade]. `source`
|
|
288
|
+
# overrides install-source detection: "auto" (packaging metadata),
|
|
289
|
+
# "pypi", or "git" (with `package`/`url`/`ref` overrides).
|
|
290
|
+
"upgrade": {
|
|
291
|
+
"enabled": True,
|
|
292
|
+
"source": "auto",
|
|
293
|
+
"package": None,
|
|
294
|
+
"url": None,
|
|
295
|
+
"ref": None,
|
|
296
|
+
},
|
|
287
297
|
}
|
|
288
298
|
|
|
289
299
|
def __init__(
|
|
@@ -320,7 +330,8 @@ class ConfigManager:
|
|
|
320
330
|
# find_project_root discovery (up-walk + bounded rglob tree search) that
|
|
321
331
|
# would otherwise be spent rediscovering it. Derive the project root
|
|
322
332
|
# from the config's own directory instead.
|
|
323
|
-
|
|
333
|
+
# Unreachable: the normalization above guarantees a Path here.
|
|
334
|
+
if usecli_config_path is None: # pragma: no cover
|
|
324
335
|
detected_root = find_project_root(start_dir)
|
|
325
336
|
else:
|
|
326
337
|
detected_root = None
|
|
@@ -328,7 +339,7 @@ class ConfigManager:
|
|
|
328
339
|
config_parent = self.usecli_config_path.parent
|
|
329
340
|
if detected_root is None:
|
|
330
341
|
detected_root = config_parent
|
|
331
|
-
else:
|
|
342
|
+
else: # pragma: no cover - detected_root is always None here
|
|
332
343
|
root_config = detected_root / USECLI_CONFIG_TOML
|
|
333
344
|
if self.usecli_config_path.resolve() != root_config.resolve():
|
|
334
345
|
detected_root = config_parent
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Reusable self-upgrade machinery for usecli applications.
|
|
2
|
+
|
|
3
|
+
Every generated CLI inherits a built-in ``upgrade`` command built on this
|
|
4
|
+
package. Installations are classified from standard packaging metadata
|
|
5
|
+
(``INSTALLER`` and PEP 610 ``direct_url.json``) — no CLI-author
|
|
6
|
+
configuration required.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from usecli.shared.upgrades.checker import UpgradeStatus
|
|
10
|
+
from usecli.shared.upgrades.discovery import InstallInfo, InstallSource
|
|
11
|
+
from usecli.shared.upgrades.installer import UpgradeResult
|
|
12
|
+
from usecli.shared.upgrades.pyproject import PyprojectUpdate
|
|
13
|
+
from usecli.shared.upgrades.service import UpgradeService
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"InstallInfo",
|
|
17
|
+
"InstallSource",
|
|
18
|
+
"PyprojectUpdate",
|
|
19
|
+
"UpgradeResult",
|
|
20
|
+
"UpgradeService",
|
|
21
|
+
"UpgradeStatus",
|
|
22
|
+
]
|