Skip to content

Commit ed47cc1

Browse files
committed
Add CLI upgrade and plugin catalog capabilities
1 parent c9cada2 commit ed47cc1

File tree

5 files changed

+126
-7
lines changed

5 files changed

+126
-7
lines changed

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## Unreleased
9+
10+
## [0.0.3] - 2025-05-09
11+
12+
### Added
13+
14+
- Support for upgrading the CLI to the latest version
15+
- Support for listing all available plugins
16+
17+
## [0.0.2] - 2025-04-08
18+
19+
### Added
20+
21+
- Support for installing the CLI using pipx and making it available as an executable `pba-cli` command
22+
- Support for installing and uninstalling published plugins
23+
- Support for installing and uninstalling local plugins
24+
- Support for listing installed plugins
25+
26+
## [0.0.1] - Unreleased

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
55
[project]
66
requires-python = ">=3.9"
77
name = "minimal-pba-cli"
8-
version = "0.0.2"
8+
version = "0.0.3"
99
description = "A minimal command-line interface using plugin-based architecture"
1010
authors = [
1111
{ name = "Dane Hillard", email = "[email protected]" }

src/minimal_pba_cli/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from trogon import Trogon
88
from rich.console import Console
99

10+
from minimal_pba_cli.upgrade import upgrade
1011
from minimal_pba_cli.plugin import plugin, find_plugins
1112

1213

@@ -29,6 +30,7 @@ def default(context: typer.Context):
2930
def main():
3031
_register_plugins(app)
3132
app.add_typer(plugin, name="plugin", help="Manage plugins.")
33+
app.command()(upgrade)
3234
app()
3335

3436

src/minimal_pba_cli/plugin.py

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import importlib.metadata
2+
import os
23
import subprocess
34
from pathlib import Path
45
from typing import Annotated
@@ -15,6 +16,13 @@
1516
plugin = typer.Typer()
1617

1718

19+
@plugin.command()
20+
def catalog():
21+
"""See all available plugins."""
22+
23+
_display_plugin_list()
24+
25+
1826
@plugin.command(name="list")
1927
def list_plugins():
2028
"""List installed plugins."""
@@ -58,7 +66,7 @@ def install(
5866

5967
if not already_installed or upgrade:
6068
try:
61-
_, version_to_install, _ = _get_latest_version(f"minimal-pba-cli-plugin-{name}")
69+
_, version_to_install, _ = get_latest_version(f"minimal-pba-cli-plugin-{name}")
6270
except HTTPError as e:
6371
if e.response is not None and e.response.status_code == 404:
6472
raise typer.BadParameter(
@@ -76,15 +84,15 @@ def install(
7684
if already_installed:
7785
args.append("--force")
7886

79-
_run_external_subprocess(args)
87+
run_external_subprocess(args)
8088

8189

8290
@plugin.command()
8391
def install_local(path: Annotated[Path, typer.Argument(help="Path to the plugin directory.")]):
8492
"""Install a local plugin."""
8593

8694
typer.echo(f"Installing plugin from '{path}'...")
87-
_run_external_subprocess([
95+
run_external_subprocess([
8896
"pipx",
8997
"inject",
9098
"--editable",
@@ -99,7 +107,7 @@ def uninstall(name: Annotated[str, typer.Argument(help="Name of the plugin to un
99107
"""Uninstall a plugin."""
100108

101109
typer.echo(f"Uninstalling plugin '{name}'...")
102-
_run_external_subprocess([
110+
run_external_subprocess([
103111
"pipx",
104112
"uninject",
105113
"minimal-pba-cli",
@@ -116,7 +124,7 @@ def _get_installed_version(name: str) -> Version | None:
116124
return None
117125

118126

119-
def _get_latest_version(name: str) -> tuple[Version | None, Version, bool]:
127+
def get_latest_version(name: str) -> tuple[Version | None, Version, bool]:
120128
"""Get the latest published version of a package."""
121129

122130
url = f"https://pypi.org/pypi/{name}/json"
@@ -144,7 +152,7 @@ def find_plugins() -> dict[str, dict[str, str]]:
144152
return plugins
145153

146154

147-
def _run_external_subprocess(args: list[str]) -> subprocess.CompletedProcess:
155+
def run_external_subprocess(args: list[str]) -> subprocess.CompletedProcess:
148156
"""Run an external subprocess and return the result."""
149157

150158
result = subprocess.run(args, capture_output=True, encoding="utf-8")
@@ -159,3 +167,60 @@ def _run_external_subprocess(args: list[str]) -> subprocess.CompletedProcess:
159167
raise typer.Exit(code=result.returncode)
160168

161169
return result
170+
171+
172+
def _get_packages_matching_name(prefix: str) -> list[dict[str, str]]:
173+
if "LIBRARIES_IO_API_KEY" not in os.environ:
174+
typer.secho(
175+
"LIBRARIES_IO_API_KEY environment variable not set. "
176+
"Create a free libraries.io account to get an API key and set it to use the plugin catalog.",
177+
fg=typer.colors.RED,
178+
)
179+
raise typer.Exit(1)
180+
results = requests.get("https://libraries.io/api/search", params={"q": prefix, "platforms": "pypi", "api_key": os.getenv("LIBRARIES_IO_API_KEY")})
181+
return [
182+
{
183+
"name": package["name"],
184+
"summary": package["description"],
185+
}
186+
for package in results.json()
187+
if package["name"].startswith(prefix)
188+
]
189+
190+
191+
def _display_plugin_list():
192+
console = Console()
193+
194+
table = Table(
195+
"Name",
196+
"Description",
197+
"Latest version",
198+
"Installed",
199+
title="Available CLI plugins",
200+
min_width=50,
201+
highlight=True,
202+
)
203+
204+
available_plugins = _get_packages_matching_name("minimal-pba-cli-plugin-")
205+
206+
for plugin in sorted(available_plugins, key=lambda x: x["name"]):
207+
plugin_current_version, plugin_latest_version, plugin_outdated = get_latest_version(plugin["name"])
208+
output = "False"
209+
210+
if plugin_current_version:
211+
color = "yellow" if plugin_outdated else "green"
212+
output = f"[{color}]{plugin_current_version}[/{color}]"
213+
214+
plugin_full_name = plugin["name"]
215+
plugin_short_name = plugin_full_name.replace("minimal-pba-cli-plugin-", "")
216+
217+
table.add_row(
218+
f"[link=https://capstan-backstage.prod.cirrostratus.org/catalog/default/component/{plugin_full_name}]{plugin_short_name}[/link]",
219+
plugin["summary"],
220+
str(plugin_latest_version),
221+
output,
222+
)
223+
224+
print()
225+
console.print(table)
226+
console.print("\nInstall a plugin using [bold cyan]pba-cli plugin install <plugin-name>[/bold cyan].")

src/minimal_pba_cli/upgrade.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from rich.console import Console
2+
3+
from minimal_pba_cli.plugin import get_latest_version, run_external_subprocess
4+
5+
6+
def upgrade():
7+
"""Install the latest version of this tool."""
8+
9+
console = Console()
10+
installed_version, latest_version, _ = get_latest_version("minimal-pba-cli")
11+
12+
if installed_version == latest_version:
13+
console.print(f"\n[green]Already on latest version {installed_version}.[/green]")
14+
return
15+
16+
run_external_subprocess(
17+
[
18+
"pipx",
19+
"runpip",
20+
"minimal-pba-cli",
21+
"install",
22+
f"minimal-pba-cli=={latest_version}",
23+
"--force",
24+
]
25+
)
26+
console.print("\n[green]Upgrade complete.[/green]\n\n")

0 commit comments

Comments
 (0)