feat: port to Python and package as pip-installable distribution

Replaces main.sh with the 'cli-gnome-extension-manager' distribution in a src layout, shipping both the cli-gnome-extension-manager and the older goexma command.

sync_repository keeps the three states of the shell original apart: clone when the folder is missing, pull when it is a checkout, leave it alone otherwise, so a manually installed extension is never overwritten. build_extension still moves the checkout away before make install, but into a TemporaryDirectory instead of a fixed /tmp/<name>, which removes the collision between parallel runs and cleans up on abort.

Every step passes through the exit code of the failing tool, replacing the shell chain of || exit 1; a missing gnome-extensions aborts with exit 127. The e2e suite builds a real git repository, clones it with real git and runs real make install.

Adds --extensions-dir, unit, integration and container-based e2e tests, ruff and markdown/mermaid linting, CodeQL and Dependabot, and pins the core metadata to 2.4 so twine accepts the artifacts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 14:41:17 +02:00
parent 59943dc029
commit ccac936015
23 changed files with 1149 additions and 72 deletions

View File

View File

@@ -0,0 +1,6 @@
import sys
from gnome_extension_manager.cli import main
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,98 @@
import argparse
import sys
from pathlib import Path
from gnome_extension_manager.core import (
EXIT_MISSING_BINARY,
MissingBinaryError,
build_extension,
default_extensions_dir,
gnome_extensions,
sync_repository,
)
ACTIONS = ("enable", "disable")
def build_parser():
"""Return the argument parser for the cli-gnome-extension-manager command."""
parser = argparse.ArgumentParser(
description="Install, enable and disable GNOME shell extensions."
)
parser.add_argument(
"action",
choices=ACTIONS,
help="Whether to enable or disable the extension.",
)
parser.add_argument(
"extension_name",
help="UUID of the GNOME extension.",
)
parser.add_argument(
"repository_path",
nargs="?",
default="",
help="Git repository the extension is installed from (optional).",
)
parser.add_argument(
"--extensions-dir",
default=str(default_extensions_dir()),
help="Directory holding the installed extensions.",
)
return parser
def main(argv=None):
"""Install and toggle a GNOME shell extension.
Args:
argv: Argument list, defaults to sys.argv[1:].
Returns:
Process exit code.
"""
try:
return _run(build_parser().parse_args(argv))
except MissingBinaryError as error:
print(f"Error: {error}", file=sys.stderr)
return EXIT_MISSING_BINARY
def _run(args):
"""Perform the requested action and return the exit code.
Args:
args: Parsed command line arguments.
"""
folder = Path(args.extensions_dir) / args.extension_name
print("=== GNOME Extension Installer ===")
if args.action == "disable":
print(f'Disabling GNOME extension "{args.extension_name}"...')
returncode = gnome_extensions("disable", args.extension_name)
if returncode != 0:
return returncode
print("Installation complete.")
return 0
print(f'Installing GNOME extension "{args.extension_name}"...')
if args.repository_path:
print(f"Generating extension based on {args.repository_path}")
returncode = sync_repository(folder, args.repository_path)
if returncode != 0:
return returncode
returncode = build_extension(folder)
if returncode != 0:
return returncode
print(f'Enabling GNOME extension "{args.extension_name}"...')
returncode = gnome_extensions("enable", args.extension_name)
if returncode != 0:
return returncode
print("Installation complete.")
return 0

View File

@@ -0,0 +1,99 @@
import shutil
import subprocess
import tempfile
from pathlib import Path
REQUIRED_BINARIES = ("gnome-extensions",)
REPOSITORY_BINARIES = ("git", "make")
EXIT_MISSING_BINARY = 127
class MissingBinaryError(RuntimeError):
"""A required external command is not installed.
Args:
binary: Name of the missing executable.
"""
def __init__(self, binary):
super().__init__(f"required command '{binary}' is not installed")
self.binary = binary
def default_extensions_dir():
"""Return the per-user GNOME shell extensions directory."""
return Path.home() / ".local" / "share" / "gnome-shell" / "extensions"
def run(cmd, cwd=None):
"""Run a command and return its exit code.
Args:
cmd: Command argument list.
cwd: Working directory for the command.
"""
try:
return subprocess.run(cmd, cwd=cwd, check=False).returncode
except FileNotFoundError as error:
raise MissingBinaryError(cmd[0]) from error
def sync_repository(folder, repository_path):
"""Clone the extension repository, or pull it when already checked out.
Args:
folder: Target folder of the extension.
repository_path: Git URL or path of the extension repository.
Returns:
Exit code, non-zero when git failed.
"""
if not folder.exists():
print("Cloning repository...")
return run(["git", "clone", repository_path, str(folder)])
if not (folder / ".git").is_dir():
print("No git repository. Extension will not be updated.")
return 0
print("Pulling changes from git...")
return run(["git", "pull"], cwd=str(folder))
def build_extension(folder):
"""Build an extension shipping a Makefile, then drop the build folder.
The checkout is moved into a scratch directory before 'make install' runs,
mirroring the original shell implementation, which installed from /tmp and
removed the checkout afterwards.
Args:
folder: Folder holding the checked out extension.
Returns:
Exit code, non-zero when the build failed.
"""
if not (folder / "Makefile").is_file():
print("No Makefile found. Skipping compilation...")
return 0
with tempfile.TemporaryDirectory() as build_root:
build_dir = Path(build_root) / folder.name
shutil.move(str(folder), str(build_dir))
print("Compiling extension...")
returncode = run(["make", "install"], cwd=str(build_dir))
if returncode != 0:
print("Compilation failed.")
return returncode
def gnome_extensions(action, extension_name):
"""Enable or disable an extension through the gnome-extensions tool.
Args:
action: Either 'enable' or 'disable'.
extension_name: UUID of the GNOME extension.
"""
return run(["gnome-extensions", action, extension_name])