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

0
tests/__init__.py Normal file
View File

12
tests/e2e/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends git make \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY . /src
RUN pip install --no-cache-dir .
CMD ["sh", "/src/tests/e2e/run.sh"]

147
tests/e2e/run.sh Normal file
View File

@@ -0,0 +1,147 @@
#!/bin/sh
# gnome-extensions is stubbed on purpose: pulling in GNOME Shell would dwarf
# the package under test. git and make are the real binaries.
set -eu
fail() {
echo "FAIL: $1" >&2
exit 1
}
stub_gnome_extensions() {
mkdir -p /tmp/bin
printf '%s\n' "#!/bin/sh" 'echo "$@" >>/tmp/gnome.log' "$1" >/tmp/bin/gnome-extensions
chmod +x /tmp/bin/gnome-extensions
: >/tmp/gnome.log
}
make_source_repo() {
mkdir -p "$1"
echo '{"uuid": "ext@example"}' >"$1/metadata.json"
if [ "$2" = "with-makefile" ]; then
printf 'install:\n\ttouch /tmp/built.marker\n' >"$1/Makefile"
fi
git -C "$1" init -q -b main
git -C "$1" add -A
git -C "$1" -c user.email=e2e@example.com -c user.name=E2E commit -qm initial
}
MANAGER="$(command -v cli-gnome-extension-manager)" || fail "cli-gnome-extension-manager is not on PATH after install"
echo "ok: cli-gnome-extension-manager installed at ${MANAGER}"
command -v goexma >/dev/null || fail "the goexma alias is not on PATH after install"
echo "ok: goexma alias installed"
case "${MANAGER}" in
/usr/local/bin/*) ;;
*) fail "cli-gnome-extension-manager resolved to ${MANAGER}, outside the install prefix" ;;
esac
echo "ok: resolved from the installed package, not the source tree"
command -v git >/dev/null || fail "git is missing from the image"
command -v make >/dev/null || fail "make is missing from the image"
echo "ok: real git and make present"
cli-gnome-extension-manager --help | grep -q "GNOME shell extensions" || fail "--help does not describe the tool"
echo "ok: --help"
set +e
output="$(cli-gnome-extension-manager restart ext@example 2>&1)"
status=$?
set -e
[ "${status}" -eq 2 ] || fail "an unknown action exited ${status}, expected 2"
echo "${output}" | grep -q "invalid choice" || fail "unknown action message wrong"
echo "ok: an unknown action exits 2"
set +e
output="$(env PATH=/nonexistent-bin "${MANAGER}" disable ext@example 2>&1)"
status=$?
set -e
[ "${status}" -eq 127 ] || fail "missing gnome-extensions exited ${status}, expected 127"
echo "${output}" | grep -q "required command 'gnome-extensions' is not installed" || fail "missing gnome-extensions message wrong"
if echo "${output}" | grep -q "Traceback"; then
fail "missing gnome-extensions produced a traceback"
fi
echo "ok: missing gnome-extensions exits 127 with a clean message"
stub_gnome_extensions "exit 0"
make_source_repo /tmp/source with-makefile
env PATH="/tmp/bin:${PATH}" cli-gnome-extension-manager \
enable ext@example /tmp/source --extensions-dir /tmp/extensions >/tmp/first.log 2>&1 \
|| fail "the first enable run failed: $(cat /tmp/first.log)"
[ -f /tmp/built.marker ] || fail "make install did not run"
grep -q "enable ext@example" /tmp/gnome.log || fail "gnome-extensions enable was not called"
grep -q "Installation complete." /tmp/first.log || fail "the completion banner is missing"
echo "ok: real git clone, real make install, extension enabled"
stub_gnome_extensions "exit 0"
make_source_repo /tmp/source2 without-makefile
env PATH="/tmp/bin:${PATH}" cli-gnome-extension-manager \
enable plain@example /tmp/source2 --extensions-dir /tmp/extensions >/tmp/plain.log 2>&1 \
|| fail "the plain enable run failed: $(cat /tmp/plain.log)"
grep -q "No Makefile found" /tmp/plain.log || fail "the missing Makefile was not reported"
[ -f /tmp/extensions/plain@example/metadata.json ] || fail "the extension without a Makefile was not kept"
echo "ok: an extension without a Makefile stays installed"
env PATH="/tmp/bin:${PATH}" cli-gnome-extension-manager \
enable plain@example /tmp/source2 --extensions-dir /tmp/extensions >/tmp/second.log 2>&1 \
|| fail "the second enable run failed: $(cat /tmp/second.log)"
grep -q "Pulling changes from git..." /tmp/second.log || fail "the second run did not pull"
echo "ok: a second run pulls instead of cloning"
stub_gnome_extensions "exit 0"
set +e
env PATH="/tmp/bin:${PATH}" cli-gnome-extension-manager \
enable broken@example /nonexistent/repo --extensions-dir /tmp/extensions >/tmp/clone.log 2>&1
status=$?
set -e
[ "${status}" -ne 0 ] || fail "a failing git clone was reported as success"
[ ! -s /tmp/gnome.log ] || fail "gnome-extensions ran even though the clone failed"
[ ! -d /tmp/extensions/broken@example ] || fail "a failed clone left an extension folder behind"
echo "ok: a failing git clone stops before enabling and leaves nothing behind"
stub_gnome_extensions "exit 0"
mkdir -p /tmp/source3
echo '{"uuid": "bad@example"}' >/tmp/source3/metadata.json
printf 'install:\n\tfalse\n' >/tmp/source3/Makefile
git -C /tmp/source3 init -q -b main
git -C /tmp/source3 add -A
git -C /tmp/source3 -c user.email=e2e@example.com -c user.name=E2E commit -qm initial
set +e
env PATH="/tmp/bin:${PATH}" cli-gnome-extension-manager \
enable bad@example /tmp/source3 --extensions-dir /tmp/extensions >/tmp/build.log 2>&1
status=$?
set -e
[ "${status}" -ne 0 ] || fail "a failing make install was reported as success"
grep -q "Compilation failed." /tmp/build.log || fail "the build failure was not reported"
[ ! -s /tmp/gnome.log ] || fail "gnome-extensions ran even though the build failed"
echo "ok: a failing make install stops before enabling"
stub_gnome_extensions "exit 0"
mkdir -p /tmp/extensions/manual@example
echo '{"uuid": "manual@example"}' >/tmp/extensions/manual@example/metadata.json
env PATH="/tmp/bin:${PATH}" cli-gnome-extension-manager \
enable manual@example /tmp/source2 --extensions-dir /tmp/extensions >/tmp/manual.log 2>&1 \
|| fail "enabling a manually installed extension failed: $(cat /tmp/manual.log)"
grep -q "No git repository. Extension will not be updated." /tmp/manual.log \
|| fail "a non-git extension folder was not detected"
grep -q "enable manual@example" /tmp/gnome.log || fail "the manual extension was not enabled"
echo "ok: a manually installed extension is not overwritten but still enabled"
stub_gnome_extensions "exit 3"
set +e
env PATH="/tmp/bin:${PATH}" cli-gnome-extension-manager disable ext@example >/dev/null 2>&1
status=$?
set -e
[ "${status}" -eq 3 ] || fail "a failing gnome-extensions exited ${status}, expected 3"
echo "ok: a failing gnome-extensions propagates its exit code"
echo "ALL E2E CHECKS PASSED"

View File

View File

@@ -0,0 +1,222 @@
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()

0
tests/unit/__init__.py Normal file
View File

96
tests/unit/test_cli.py Normal file
View File

@@ -0,0 +1,96 @@
import unittest
from unittest import mock
from gnome_extension_manager.cli import main
class TestMain(unittest.TestCase):
def _patched(self, **returns):
return (
mock.patch(
"gnome_extension_manager.cli.sync_repository",
return_value=returns.get("sync", 0),
),
mock.patch(
"gnome_extension_manager.cli.build_extension",
return_value=returns.get("build", 0),
),
mock.patch(
"gnome_extension_manager.cli.gnome_extensions",
return_value=returns.get("toggle", 0),
),
)
def test_disable_only_toggles(self):
sync, build, toggle = self._patched()
with sync as sync_mock, build as build_mock, toggle as toggle_mock:
exit_code = main(["disable", "ext@example"])
sync_mock.assert_not_called()
build_mock.assert_not_called()
toggle_mock.assert_called_once_with("disable", "ext@example")
self.assertEqual(exit_code, 0)
def test_enable_without_repository_skips_installation(self):
sync, build, toggle = self._patched()
with sync as sync_mock, build as build_mock, toggle as toggle_mock:
exit_code = main(["enable", "ext@example"])
sync_mock.assert_not_called()
build_mock.assert_not_called()
toggle_mock.assert_called_once_with("enable", "ext@example")
self.assertEqual(exit_code, 0)
def test_empty_repository_argument_is_treated_as_absent(self):
sync, build, toggle = self._patched()
with sync as sync_mock, build, toggle:
exit_code = main(["enable", "ext@example", ""])
sync_mock.assert_not_called()
self.assertEqual(exit_code, 0)
def test_enable_with_repository_installs_into_the_extensions_dir(self):
sync, build, toggle = self._patched()
with sync as sync_mock, build as build_mock, toggle:
exit_code = main(
[
"enable",
"ext@example",
"https://example.com/ext.git",
"--extensions-dir",
"/opt/ext",
]
)
folder = sync_mock.call_args.args[0]
self.assertEqual(str(folder), "/opt/ext/ext@example")
build_mock.assert_called_once()
self.assertEqual(exit_code, 0)
def test_failed_clone_stops_before_building(self):
sync, build, toggle = self._patched(sync=128)
with sync, build as build_mock, toggle as toggle_mock:
exit_code = main(["enable", "ext@example", "https://example.com/ext.git"])
build_mock.assert_not_called()
toggle_mock.assert_not_called()
self.assertEqual(exit_code, 128)
def test_failed_build_stops_before_enabling(self):
sync, build, toggle = self._patched(build=2)
with sync, build, toggle as toggle_mock:
exit_code = main(["enable", "ext@example", "https://example.com/ext.git"])
toggle_mock.assert_not_called()
self.assertEqual(exit_code, 2)
def test_failed_toggle_propagates(self):
sync, build, toggle = self._patched(toggle=1)
with sync, build, toggle:
exit_code = main(["enable", "ext@example"])
self.assertEqual(exit_code, 1)
if __name__ == "__main__":
unittest.main()

136
tests/unit/test_core.py Normal file
View File

@@ -0,0 +1,136 @@
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from gnome_extension_manager.core import (
REPOSITORY_BINARIES,
REQUIRED_BINARIES,
MissingBinaryError,
build_extension,
default_extensions_dir,
gnome_extensions,
run,
sync_repository,
)
class TestRequiredBinaries(unittest.TestCase):
def test_declares_every_external_command_the_package_calls(self):
declared = set(REQUIRED_BINARIES) | set(REPOSITORY_BINARIES)
self.assertEqual(declared, {"gnome-extensions", "git", "make"})
def test_repository_binaries_are_not_declared_as_always_required(self):
self.assertFalse(set(REQUIRED_BINARIES) & set(REPOSITORY_BINARIES))
def test_missing_binary_is_reported_with_its_name(self):
with mock.patch(
"gnome_extension_manager.core.subprocess.run"
) as subprocess_run:
subprocess_run.side_effect = FileNotFoundError
with self.assertRaises(MissingBinaryError) as caught:
run(["git", "clone", "x", "y"])
self.assertEqual(caught.exception.binary, "git")
class TestDefaultExtensionsDir(unittest.TestCase):
def test_points_at_the_per_user_gnome_directory(self):
self.assertEqual(
default_extensions_dir(),
Path.home() / ".local" / "share" / "gnome-shell" / "extensions",
)
class TestSyncRepository(unittest.TestCase):
def test_missing_folder_is_cloned(self):
with (
tempfile.TemporaryDirectory() as base,
mock.patch("gnome_extension_manager.core.run", return_value=0) as run,
):
folder = Path(base) / "ext"
self.assertEqual(sync_repository(folder, "https://example.com/ext.git"), 0)
run.assert_called_once_with(
["git", "clone", "https://example.com/ext.git", str(folder)]
)
def test_existing_checkout_is_pulled(self):
with tempfile.TemporaryDirectory() as base:
folder = Path(base) / "ext"
(folder / ".git").mkdir(parents=True)
with mock.patch("gnome_extension_manager.core.run", return_value=0) as run:
self.assertEqual(sync_repository(folder, "https://e.example/x"), 0)
run.assert_called_once_with(["git", "pull"], cwd=str(folder))
def test_existing_folder_without_git_is_left_alone(self):
with tempfile.TemporaryDirectory() as base:
folder = Path(base) / "ext"
folder.mkdir()
with mock.patch("gnome_extension_manager.core.run") as run:
self.assertEqual(sync_repository(folder, "https://e.example/x"), 0)
run.assert_not_called()
def test_failed_clone_propagates_the_exit_code(self):
with (
tempfile.TemporaryDirectory() as base,
mock.patch("gnome_extension_manager.core.run", return_value=128),
):
self.assertEqual(
sync_repository(Path(base) / "ext", "https://e.example/x"), 128
)
class TestBuildExtension(unittest.TestCase):
def test_extension_without_makefile_is_kept_in_place(self):
with tempfile.TemporaryDirectory() as base:
folder = Path(base) / "ext"
folder.mkdir()
with mock.patch("gnome_extension_manager.core.run") as run:
self.assertEqual(build_extension(folder), 0)
run.assert_not_called()
self.assertTrue(folder.exists())
def test_makefile_extension_is_built_and_the_checkout_removed(self):
with tempfile.TemporaryDirectory() as base:
folder = Path(base) / "ext"
folder.mkdir()
(folder / "Makefile").write_text("install:\n\ttrue\n")
with mock.patch("gnome_extension_manager.core.run", return_value=0) as run:
self.assertEqual(build_extension(folder), 0)
self.assertEqual(run.call_args.args[0], ["make", "install"])
self.assertFalse(folder.exists())
def test_failed_build_propagates_the_exit_code(self):
with tempfile.TemporaryDirectory() as base:
folder = Path(base) / "ext"
folder.mkdir()
(folder / "Makefile").write_text("install:\n\tfalse\n")
with mock.patch("gnome_extension_manager.core.run", return_value=2):
self.assertEqual(build_extension(folder), 2)
class TestGnomeExtensions(unittest.TestCase):
def test_delegates_to_the_gnome_extensions_tool(self):
with mock.patch("gnome_extension_manager.core.run", return_value=0) as run:
gnome_extensions("enable", "dash-to-dock@example")
run.assert_called_once_with(
["gnome-extensions", "enable", "dash-to-dock@example"]
)
if __name__ == "__main__":
unittest.main()