Files
Kevin Veen-Birkenbach ccac936015 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>
2026-08-14 14:41:17 +02:00

223 lines
7.3 KiB
Python

import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SRC_DIR = REPO_ROOT / "src"
GNOME_STUB = '#!/bin/sh\necho "$@" >> "$GNOME_LOG"\nexit 0\n'
GNOME_STUB_FAILING = "#!/bin/sh\nexit 3\n"
EXTENSION_MAKEFILE = "install:\n\ttouch $$MARKER\n"
def git(*args, cwd):
"""Run git with a deterministic identity."""
subprocess.run(
[
"git",
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test",
*args,
],
cwd=cwd,
check=True,
capture_output=True,
)
def make_source_repo(path, with_makefile):
"""Create a git repository holding a minimal GNOME extension.
Args:
path: Directory the repository is created in.
with_makefile: Add an install target writing the $MARKER file.
"""
path.mkdir(parents=True)
(path / "metadata.json").write_text('{"uuid": "ext@example"}')
if with_makefile:
(path / "Makefile").write_text(EXTENSION_MAKEFILE)
git("init", "-b", "main", cwd=path)
git("add", "-A", cwd=path)
git("commit", "-m", "initial", cwd=path)
return path
def write_stub(directory, body):
"""Write an executable gnome-extensions stub into directory."""
path = directory / "gnome-extensions"
path.write_text(body)
path.chmod(0o755)
return path
def run_cli(*args, stub_dir=None, gnome_log=None, marker=None, isolated_path=False):
"""Run 'python -m gnome_extension_manager' with a stubbed gnome-extensions.
Args:
args: Command line arguments for the CLI.
stub_dir: Directory holding the gnome-extensions stub.
gnome_log: File the stub appends its arguments to.
marker: File the extension Makefile touches on install.
isolated_path: Use only stub_dir as PATH, hiding the real binaries.
"""
env = dict(os.environ)
env["PYTHONPATH"] = str(SRC_DIR)
if isolated_path:
env["PATH"] = str(stub_dir)
elif stub_dir:
env["PATH"] = f"{stub_dir}{os.pathsep}{env['PATH']}"
if gnome_log:
env["GNOME_LOG"] = str(gnome_log)
if marker:
env["MARKER"] = str(marker)
return subprocess.run(
[sys.executable, "-m", "gnome_extension_manager", *args],
capture_output=True,
text=True,
env=env,
check=False,
)
class TestCli(unittest.TestCase):
def test_help_exits_zero(self):
result = run_cli("--help")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("GNOME shell extensions", result.stdout)
def test_unknown_action_is_rejected(self):
result = run_cli("restart", "ext@example")
self.assertEqual(result.returncode, 2)
self.assertIn("invalid choice", result.stderr)
def test_disable_calls_gnome_extensions(self):
with tempfile.TemporaryDirectory() as base:
base_path = Path(base)
stub_dir = base_path / "bin"
stub_dir.mkdir()
write_stub(stub_dir, GNOME_STUB)
gnome_log = base_path / "calls.log"
result = run_cli(
"disable",
"ext@example",
stub_dir=stub_dir,
gnome_log=gnome_log,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(gnome_log.read_text().strip(), "disable ext@example")
def test_enable_clones_builds_and_enables(self):
with tempfile.TemporaryDirectory() as base:
base_path = Path(base)
stub_dir = base_path / "bin"
stub_dir.mkdir()
write_stub(stub_dir, GNOME_STUB)
gnome_log = base_path / "calls.log"
marker = base_path / "built.marker"
source = make_source_repo(base_path / "source", with_makefile=True)
extensions_dir = base_path / "extensions"
result = run_cli(
"enable",
"ext@example",
str(source),
"--extensions-dir",
str(extensions_dir),
stub_dir=stub_dir,
gnome_log=gnome_log,
marker=marker,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(marker.exists(), "make install did not run")
self.assertEqual(gnome_log.read_text().strip(), "enable ext@example")
self.assertIn("Installation complete.", result.stdout)
def test_extension_without_makefile_stays_installed(self):
with tempfile.TemporaryDirectory() as base:
base_path = Path(base)
stub_dir = base_path / "bin"
stub_dir.mkdir()
write_stub(stub_dir, GNOME_STUB)
gnome_log = base_path / "calls.log"
source = make_source_repo(base_path / "source", with_makefile=False)
extensions_dir = base_path / "extensions"
result = run_cli(
"enable",
"ext@example",
str(source),
"--extensions-dir",
str(extensions_dir),
stub_dir=stub_dir,
gnome_log=gnome_log,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("No Makefile found", result.stdout)
self.assertTrue((extensions_dir / "ext@example" / "metadata.json").exists())
def test_second_run_pulls_instead_of_cloning(self):
with tempfile.TemporaryDirectory() as base:
base_path = Path(base)
stub_dir = base_path / "bin"
stub_dir.mkdir()
write_stub(stub_dir, GNOME_STUB)
gnome_log = base_path / "calls.log"
source = make_source_repo(base_path / "source", with_makefile=False)
extensions_dir = base_path / "extensions"
arguments = (
"enable",
"ext@example",
str(source),
"--extensions-dir",
str(extensions_dir),
)
run_cli(*arguments, stub_dir=stub_dir, gnome_log=gnome_log)
result = run_cli(*arguments, stub_dir=stub_dir, gnome_log=gnome_log)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Pulling changes from git...", result.stdout)
def test_missing_gnome_extensions_fails_cleanly(self):
with tempfile.TemporaryDirectory() as base:
empty = Path(base) / "empty"
empty.mkdir()
result = run_cli(
"disable", "ext@example", stub_dir=empty, isolated_path=True
)
self.assertEqual(result.returncode, 127)
self.assertIn(
"required command 'gnome-extensions' is not installed", result.stderr
)
self.assertNotIn("Traceback", result.stderr)
def test_failing_gnome_extensions_propagates_the_exit_code(self):
with tempfile.TemporaryDirectory() as base:
base_path = Path(base)
stub_dir = base_path / "bin"
stub_dir.mkdir()
write_stub(stub_dir, GNOME_STUB_FAILING)
result = run_cli("disable", "ext@example", stub_dir=stub_dir)
self.assertEqual(result.returncode, 3)
if __name__ == "__main__":
unittest.main()