Skip to content

Commit e4fd2bb

Browse files
committed
Add minimal initial code
0 parents  commit e4fd2bb

File tree

7 files changed

+333
-0
lines changed

7 files changed

+333
-0
lines changed

.github/workflows/packaging.yml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: Packaging
2+
3+
on:
4+
- push
5+
6+
jobs:
7+
build_source_dist:
8+
name: Build source distribution
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
13+
- uses: actions/setup-python@v5
14+
with:
15+
python-version: "3.12"
16+
17+
- name: Install build
18+
run: python -m pip install build
19+
20+
- name: Run build
21+
run: python -m build --sdist
22+
23+
- uses: actions/upload-artifact@v4
24+
with:
25+
path: dist/*.tar.gz
26+
27+
publish:
28+
if: startsWith(github.ref, 'refs/tags')
29+
runs-on: ubuntu-latest
30+
environment: release
31+
permissions:
32+
id-token: write
33+
contents: write
34+
steps:
35+
- uses: actions/checkout@v4
36+
37+
- name: Set up Python
38+
uses: actions/setup-python@v5
39+
with:
40+
python-version: "3.12"
41+
42+
- name: Install pypa/build
43+
run: python -m pip install build
44+
45+
- name: Build distribution
46+
run: python -m build --outdir dist/
47+
48+
- name: Publish distribution to Test PyPI
49+
uses: pypa/gh-action-pypi-publish@release/v1
50+
with:
51+
repository_url: https://test.pypi.org/legacy/
52+
53+
- name: Publish distribution to PyPI
54+
uses: pypa/gh-action-pypi-publish@release/v1
55+
56+
- name: Publish distribution to GitHub release
57+
uses: softprops/action-gh-release@v2
58+
with:
59+
files: |
60+
dist/minimal-pba-cli-*.whl
61+
dist/minimal_pba_cli-*.tar.gz
62+
env:
63+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
*.py[cod]
2+
*.egg-info/
3+
build/

MANIFEST.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
graft src
2+
recursive-exclude __pycache__ *.py[cod]

pyproject.toml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
[build-system]
2+
requires = ["setuptools", "wheel"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
requires-python = ">=3.9"
7+
name = "minimal-pba-cli"
8+
version = "0.0.1"
9+
description = "A minimal command-line interface using plugin-based architecture"
10+
authors = [
11+
{ name = "Dane Hillard", email = "[email protected]" }
12+
]
13+
classifiers = [
14+
"Development Status :: 2 - Pre-Alpha",
15+
"Intended Audience :: Developers",
16+
"Programming Language :: Python",
17+
"Programming Language :: Python :: 3.9",
18+
"Programming Language :: Python :: 3.10",
19+
"Programming Language :: Python :: 3.11",
20+
"Programming Language :: Python :: 3.12",
21+
"Programming Language :: Python :: 3.13",
22+
"Programming Language :: Python :: 3",
23+
"Programming Language :: Python :: 3 :: Only",
24+
"License :: OSI Approved :: MIT License",
25+
]
26+
dependencies = [
27+
"packaging>=24.2",
28+
"requests>=2.32.3",
29+
"rich>=14.0.0",
30+
"trogon[typer]>=0.6.0",
31+
"typer>=0.12.5",
32+
]
33+
34+
[project.urls]
35+
Repository = "https://github.com/easy-as-python/minimal-pba-cli"
36+
Issues = "https://github.com/easy-as-python/minimal-pba-cli/issues"
37+
38+
[tool.setuptools.packages.find]
39+
where = ["src"]
40+
41+
[project.scripts]
42+
pba-cli = "minimal_pba_cli.cli:main"

src/minimal_pba_cli/__init__.py

Whitespace-only changes.

src/minimal_pba_cli/cli.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import traceback
2+
import importlib
3+
from typing import Annotated
4+
5+
import typer
6+
from typer.main import get_group
7+
from trogon import Trogon
8+
from rich.console import Console
9+
10+
from minimal_pba_cli.plugin import plugin, find_plugins
11+
12+
13+
app = typer.Typer()
14+
15+
16+
@app.command()
17+
def hello(name: Annotated[str, typer.Argument()] = "world"):
18+
"""Say hello to NAME."""
19+
20+
typer.echo(f"Hello, {name}!")
21+
22+
23+
@app.callback(invoke_without_command=True)
24+
def default(context: typer.Context):
25+
if context.invoked_subcommand is None:
26+
Trogon(get_group(app), click_context=context).run()
27+
28+
29+
def main():
30+
_register_plugins(app)
31+
app.add_typer(plugin, name="plugin", help="Manage plugins.")
32+
app()
33+
34+
35+
def _register_plugins(app: typer.Typer):
36+
"""Register commands from installed packages that provide CLI plugins."""
37+
38+
console = Console(stderr=True)
39+
plugins = find_plugins()
40+
41+
# Import the plugin and add its commands to the app
42+
for plugin_name, plugin_info in sorted(plugins.items()):
43+
try:
44+
plugin_module = importlib.__import__(plugin_info["path"])
45+
commands = getattr(plugin_module.plugin, "commands", {})
46+
groups = getattr(plugin_module.plugin, "groups", {})
47+
48+
for group_name, group in groups.items():
49+
app.add_typer(group, name=group_name)
50+
51+
for command in commands.values():
52+
app.command()(command)
53+
except Exception:
54+
console.print(f'\n[red]Failed to load plugin "{plugin_name}". Stack trace:[/red]\n')
55+
console.print(traceback.format_exc())
56+
console.print(
57+
"[red]You can set the [bold cyan]DISABLE_PLUGINS[/bold cyan] environment variable to [bold cyan]true[/bold cyan] to run the CLI without plugins.[/red]"
58+
)
59+
console.print(
60+
"[red]As an example, you can run [bold cyan]DISABLE_PLUGINS=true pba-cli plugin uninstall <plugin>[/bold cyan] to remove a problematic plugin or [bold cyan]DISABLE_PLUGINS=true pba-cli plugin install <plugin>[/bold cyan] to attempt to upgrade a plugin.[/red]\n"
61+
)
62+
continue

src/minimal_pba_cli/plugin.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import importlib.metadata
2+
import subprocess
3+
from pathlib import Path
4+
from typing import Annotated
5+
from importlib.metadata import PackageNotFoundError, version
6+
7+
import requests
8+
import typer
9+
from packaging.version import Version
10+
from requests.exceptions import HTTPError
11+
from rich.console import Console
12+
from rich.table import Table
13+
14+
15+
plugin = typer.Typer()
16+
17+
18+
@plugin.command(name="list")
19+
def list_plugins():
20+
"""List installed plugins."""
21+
22+
console = Console()
23+
table = Table("Name", "Version", title="Installed CLI plugins", min_width=50, highlight=True)
24+
25+
for name, plugin_info in sorted(find_plugins().items(), key=lambda x: x[0]):
26+
table.add_row(name, plugin_info["version"])
27+
28+
if table.rows:
29+
print()
30+
console.print(table)
31+
else:
32+
typer.secho("No plugins installed.", fg=typer.colors.BRIGHT_BLACK)
33+
34+
35+
@plugin.command()
36+
def install(
37+
name: Annotated[
38+
str,
39+
typer.Argument(
40+
help="Name of the plugin to install, excluding the `minimal-pba-cli-plugin-` prefix."
41+
),
42+
],
43+
):
44+
"""Install a published plugin."""
45+
46+
installed_plugins = find_plugins()
47+
already_installed = name in installed_plugins
48+
version_to_install: str | Version | None = None
49+
upgrade = False
50+
51+
if already_installed:
52+
typer.secho(f"Plugin '{name}' is already installed.", fg=typer.colors.BRIGHT_YELLOW)
53+
upgrade = typer.confirm("Do you want to upgrade to the latest version?")
54+
55+
if already_installed and not upgrade:
56+
typer.confirm("Do you want to reinstall the plugin at its current version?", abort=True)
57+
version_to_install = installed_plugins[name]["version"]
58+
59+
if not already_installed or upgrade:
60+
try:
61+
_, version_to_install, _ = _get_latest_version(f"minimal-pba-cli-plugin-{name}")
62+
except HTTPError as e:
63+
if e.response is not None and e.response.status_code == 404:
64+
raise typer.BadParameter(
65+
f"Plugin '{name}' not found."
66+
) from None
67+
68+
typer.echo(f"Installing plugin '{name}' version '{version_to_install}'...")
69+
70+
args = [
71+
"pipx",
72+
"inject",
73+
"minimal-pba-cli",
74+
f"minimal-pba-cli-plugin-{name}=={version_to_install}",
75+
]
76+
if already_installed:
77+
args.append("--force")
78+
79+
_run_external_subprocess(args)
80+
81+
82+
@plugin.command()
83+
def install_local(path: Annotated[Path, typer.Argument(help="Path to the plugin directory.")]):
84+
"""Install a local plugin."""
85+
86+
typer.echo(f"Installing plugin from '{path}'...")
87+
_run_external_subprocess([
88+
"pipx",
89+
"inject",
90+
"--editable",
91+
"--force",
92+
"minimal-pba-cli",
93+
str(path),
94+
])
95+
96+
97+
@plugin.command()
98+
def uninstall(name: Annotated[str, typer.Argument(help="Name of the plugin to uninstall, excluding the `minimal-pba-cli-plugin-` prefix.")]):
99+
"""Uninstall a plugin."""
100+
101+
typer.echo(f"Uninstalling plugin '{name}'...")
102+
_run_external_subprocess([
103+
"pipx",
104+
"uninject",
105+
"minimal-pba-cli",
106+
f"minimal-pba-cli-plugin-{name}",
107+
])
108+
109+
110+
def _get_installed_version(name: str) -> Version | None:
111+
"""Determine the currently-installed version of the specified package."""
112+
113+
try:
114+
return Version(version(name))
115+
except PackageNotFoundError:
116+
return None
117+
118+
119+
def _get_latest_version(name: str) -> tuple[Version | None, Version, bool]:
120+
"""Get the latest published version of a package."""
121+
122+
url = f"https://pypi.org/pypi/{name}/json"
123+
response = requests.get(url)
124+
125+
data = response.json()
126+
latest = Version(data["info"]["version"])
127+
current = _get_installed_version(name)
128+
return current, latest, current < latest if current else True
129+
130+
131+
def find_plugins() -> dict[str, dict[str, str]]:
132+
"""Discover installed packages that provide CLI plugins."""
133+
134+
plugins = {}
135+
136+
for installed_package in importlib.metadata.distributions():
137+
for entry_point in installed_package.entry_points:
138+
if entry_point.group == "minimal_pba_cli":
139+
plugins[entry_point.name] = {
140+
"path": entry_point.value,
141+
"version": installed_package.version,
142+
}
143+
144+
return plugins
145+
146+
147+
def _run_external_subprocess(args: list[str]) -> subprocess.CompletedProcess:
148+
"""Run an external subprocess and return the result."""
149+
150+
result = subprocess.run(args, capture_output=True, encoding="utf-8")
151+
152+
if result.stdout:
153+
typer.echo(result.stdout)
154+
155+
if result.stderr:
156+
typer.echo(result.stderr, err=True)
157+
158+
if result.returncode != 0:
159+
raise typer.Exit(code=result.returncode)
160+
161+
return result

0 commit comments

Comments
 (0)