mirror of
https://github.com/kevinveenbirkenbach/cli-gnome-extension-manager.git
synced 2026-08-25 07:34:33 +00:00
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:
0
src/gnome_extension_manager/__init__.py
Normal file
0
src/gnome_extension_manager/__init__.py
Normal file
6
src/gnome_extension_manager/__main__.py
Normal file
6
src/gnome_extension_manager/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import sys
|
||||
|
||||
from gnome_extension_manager.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
98
src/gnome_extension_manager/cli.py
Normal file
98
src/gnome_extension_manager/cli.py
Normal 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
|
||||
99
src/gnome_extension_manager/core.py
Normal file
99
src/gnome_extension_manager/core.py
Normal 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])
|
||||
Reference in New Issue
Block a user