mirror of
https://github.com/kevinveenbirkenbach/cli-gnome-extension-manager.git
synced 2026-08-16 19:52:49 +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:
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.git
|
||||
.ruff_cache
|
||||
.pytest_cache
|
||||
__pycache__
|
||||
21
.github/dependabot.yml
vendored
Normal file
21
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: docker
|
||||
directories:
|
||||
- "/tests/e2e"
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
- package-ecosystem: pip
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
80
.github/workflows/ci.yml
vendored
Normal file
80
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test-and-lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install package and tooling
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e .
|
||||
python -m pip install ruff
|
||||
|
||||
- name: Ruff (lint)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ruff check .
|
||||
|
||||
- name: Ruff (format check)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ruff format --check .
|
||||
|
||||
- name: Unit tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
make test-unit
|
||||
|
||||
- name: Integration tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
make test-integration
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Markdown and mermaid lint
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
make lint-docs
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: End-to-end tests in a container
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
make test-e2e
|
||||
43
.github/workflows/codeql.yml
vendored
Normal file
43
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "27 4 * * 1"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Check security
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: python
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
13
.markdownlint-cli2.jsonc
Normal file
13
.markdownlint-cli2.jsonc
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"config": {
|
||||
"default": true,
|
||||
"MD013": false,
|
||||
"MD033": false,
|
||||
"MD041": false
|
||||
},
|
||||
"ignores": [
|
||||
"node_modules",
|
||||
".agents",
|
||||
".claude"
|
||||
]
|
||||
}
|
||||
4
MIRRORS
Normal file
4
MIRRORS
Normal file
@@ -0,0 +1,4 @@
|
||||
git@github.com:kevinveenbirkenbach/cli-gnome-extension-manager.git
|
||||
ssh://git@git.veen.world:2201/kevinveenbirkenbach/cli-gnome-extension-manager.git
|
||||
ssh://git@code.infinito.nexus:2201/kevinveenbirkenbach/cli-gnome-extension-manager.git
|
||||
https://pypi.org/project/cli-gnome-extension-manager/
|
||||
50
Makefile
Normal file
50
Makefile
Normal file
@@ -0,0 +1,50 @@
|
||||
SHELL := /usr/bin/env bash
|
||||
|
||||
.PHONY: help lint lint-docs format test test-unit test-integration test-e2e clean
|
||||
|
||||
PY ?= python3
|
||||
E2E_IMAGE ?= cli-gnome-extension-manager-e2e
|
||||
MD_LINT_IMAGE ?= davidanson/markdownlint-cli2:latest
|
||||
MERMAID_IMAGE ?= minlag/mermaid-cli:latest
|
||||
|
||||
export PYTHONPATH := $(CURDIR)/src
|
||||
|
||||
help:
|
||||
@echo "Targets:"
|
||||
@echo " make lint - ruff check + ruff format --check"
|
||||
@echo " make lint-docs - markdownlint + mermaid diagram validation"
|
||||
@echo " make format - apply ruff format"
|
||||
@echo " make test - unit + integration tests"
|
||||
@echo " make test-unit - unit tests only"
|
||||
@echo " make test-integration - integration tests only"
|
||||
@echo " make test-e2e - install and exercise the CLI in a container"
|
||||
@echo " make clean - remove caches"
|
||||
|
||||
lint:
|
||||
ruff check .
|
||||
ruff format --check .
|
||||
|
||||
# The repository is mounted read-only: mermaid-cli writes its SVGs next to the
|
||||
# output file, which must never land in the working tree.
|
||||
lint-docs:
|
||||
docker run --rm -v "$(CURDIR)":/workdir $(MD_LINT_IMAGE) "**/*.md"
|
||||
docker run --rm -v "$(CURDIR)":/data:ro -w /tmp $(MERMAID_IMAGE) -i /data/README.md -o /tmp/out.md
|
||||
|
||||
format:
|
||||
ruff format .
|
||||
|
||||
test: test-unit test-integration
|
||||
|
||||
test-unit:
|
||||
$(PY) -m unittest discover -s tests/unit -t . -p "test_*.py"
|
||||
|
||||
test-integration:
|
||||
$(PY) -m unittest discover -s tests/integration -t . -p "test_*.py"
|
||||
|
||||
test-e2e:
|
||||
docker build -f tests/e2e/Dockerfile -t $(E2E_IMAGE) .
|
||||
docker run --rm $(E2E_IMAGE)
|
||||
|
||||
clean:
|
||||
rm -rf .pytest_cache .ruff_cache .mypy_cache
|
||||
find . -type d -name "__pycache__" -print0 | xargs -0 -r rm -rf
|
||||
92
README.md
92
README.md
@@ -1,10 +1,35 @@
|
||||
# CLI GNOME Extension Manager 🚀
|
||||
|
||||
[](https://github.com/sponsors/kevinveenbirkenbach) [](https://www.patreon.com/c/kevinveenbirkenbach) [](https://buymeacoffee.com/kevinveenbirkenbach) [](https://s.veen.world/paypaldonate)
|
||||
|
||||
[](./LICENSE) [](https://www.python.org) [](https://github.com/kevinveenbirkenbach/cli-gnome-extension-manager/stargazers)
|
||||
|
||||
[](./LICENSE) [](https://www.gnu.org/software/bash/) [](https://github.com/kevinveenbirkenbach/cli-gnome-extension-manager/stargazers)
|
||||
Manage your GNOME extensions easily from the command line with **CLI GNOME Extension Manager**. This Python CLI lets you install, update, enable, and disable GNOME extensions directly from your terminal.
|
||||
|
||||
Manage your GNOME extensions easily from the command line with **CLI GNOME Extension Manager**. This Bash script lets you install, update, enable, and disable GNOME extensions directly from your terminal.
|
||||
## How it works 🧭
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["cli-gnome-extension-manager ACTION NAME [REPO]"] --> B{"action"}
|
||||
B -- disable --> C["gnome-extensions disable"]
|
||||
B -- enable --> D{"REPO argument given?"}
|
||||
D -- no --> H["gnome-extensions enable"]
|
||||
D -- yes --> E{"extension folder already there?"}
|
||||
E -- no --> F["git clone into the extensions dir"]
|
||||
E -- yes --> G{"is it a git checkout?"}
|
||||
G -- yes --> G1["git pull"]
|
||||
G -- no --> G2["leave it untouched"]
|
||||
F --> I{"ships a Makefile?"}
|
||||
G1 --> I
|
||||
G2 --> I
|
||||
I -- yes --> J["move to a scratch dir, make install, drop the scratch dir"]
|
||||
I -- no --> H
|
||||
J --> H
|
||||
C --> Z["Installation complete"]
|
||||
H --> Z
|
||||
```
|
||||
|
||||
Any failing step - `git`, `make` or `gnome-extensions` - stops the run and its exit code is passed through.
|
||||
|
||||
## Features ✨
|
||||
|
||||
@@ -15,54 +40,83 @@ Manage your GNOME extensions easily from the command line with **CLI GNOME Exten
|
||||
|
||||
## Requirements 🔧
|
||||
|
||||
- **Python 3.10+** 🐍
|
||||
- **GNOME Shell** (version 3.36+)
|
||||
- **Bash** (version 4.x+)
|
||||
- **Git** (for cloning repositories)
|
||||
- **Make** (optional, for compiling extensions)
|
||||
- **gnome-extensions** CLI tool
|
||||
- **`gnome-extensions`** on `PATH` — always required
|
||||
- **`git`** on `PATH` — only when a repository argument is given
|
||||
- **`make`** on `PATH` — only when the extension ships a `Makefile`
|
||||
|
||||
If a required command is missing, the tool exits with code `127` and a one‑line error instead of a traceback.
|
||||
|
||||
## Installation 📦
|
||||
|
||||
You can install **CLI GNOME Extension Manager** using [Kevin's Package Manager](https://github.com/kevinveenbirkenbach/pkgmgr):
|
||||
|
||||
```bash
|
||||
pkgmgr install goexma
|
||||
pip install cli-gnome-extension-manager
|
||||
```
|
||||
|
||||
Alternatively, clone this repository:
|
||||
pip is the single supported installation path.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kevinveenbirkenbach/cli-gnome-extension-manager.git
|
||||
cd cli-gnome-extension-manager
|
||||
```
|
||||
The package installs **two** identical commands: `cli-gnome-extension-manager` (primary) and `goexma` (kept for older documentation and scripts).
|
||||
|
||||
## Usage ⚙️
|
||||
|
||||
To **install and enable** an extension, run:
|
||||
|
||||
```bash
|
||||
goexma enable <extension_name> <extension_repository_path>
|
||||
cli-gnome-extension-manager enable <extension_name> <extension_repository_path>
|
||||
```
|
||||
|
||||
To **enable** an already installed extension, drop the repository argument:
|
||||
|
||||
```bash
|
||||
cli-gnome-extension-manager enable <extension_name>
|
||||
```
|
||||
|
||||
To **disable** an extension, run:
|
||||
|
||||
```bash
|
||||
goexma disable <extension_name>
|
||||
cli-gnome-extension-manager disable <extension_name>
|
||||
```
|
||||
|
||||
The script will:
|
||||
The tool will:
|
||||
|
||||
- Clone the repository if the extension isn't installed.
|
||||
- Pull updates if the extension is already a Git repository.
|
||||
- Compile the extension if a Makefile is present.
|
||||
- Compile the extension if a Makefile is present — the checkout is moved into a scratch directory, `make install` runs there, and the scratch directory is removed afterwards.
|
||||
- Enable or disable the extension using `gnome-extensions`.
|
||||
|
||||
Extensions live in `~/.local/share/gnome-shell/extensions` by default; `--extensions-dir` points the tool elsewhere.
|
||||
|
||||
### Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| `0` | Finished. |
|
||||
| `2` | Invalid command line arguments, including an action other than `enable` or `disable`. |
|
||||
| `127` | A required command is not installed. |
|
||||
| other | The exit code of the failing `git`, `make` or `gnome-extensions` call. |
|
||||
|
||||
## Development 🧪
|
||||
|
||||
```bash
|
||||
make lint # ruff check + ruff format --check
|
||||
make format # apply ruff format
|
||||
make test # unit + integration tests
|
||||
make test-unit
|
||||
make test-integration
|
||||
make test-e2e # install the package in a container and exercise the CLI
|
||||
```
|
||||
|
||||
Tests run against the working tree — the `Makefile` puts `src/` on `PYTHONPATH`, so no install is needed. The integration tests build a real throwaway git repository and stub `gnome-extensions` on `PATH`, so no extension of yours is ever touched.
|
||||
|
||||
## License 📜
|
||||
|
||||
This project is licensed under the GNU Affero General Public License v3.0. See the [LICENSE](./LICENSE) file for details.
|
||||
|
||||
## Author 👨💻
|
||||
|
||||
**Kevin Veen-Birkenbach**
|
||||
Developed by **Kevin Veen-Birkenbach**
|
||||
|
||||
- 📧 [kevin@veen.world](mailto:kevin@veen.world)
|
||||
- 🌐 [https://www.veen.world/](https://www.veen.world/)
|
||||
|
||||
|
||||
52
main.sh
52
main.sh
@@ -1,52 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# @param $1 enable|disable
|
||||
# @param $2 extension name
|
||||
# @param $3 repository path [optional]
|
||||
|
||||
action_type="$1"
|
||||
extension_name="$2"
|
||||
extension_repository_path="$3"
|
||||
extension_folder="$HOME/.local/share/gnome-shell/extensions/$extension_name/"
|
||||
|
||||
echo "=== GNOME Extension Installer ==="
|
||||
|
||||
echo "Installing GNOME extension \"$extension_name\"..."
|
||||
|
||||
if [ "$action_type" == "enable" ]; then
|
||||
if [ ! -z "$extension_repository_path" ]; then
|
||||
echo "Generating extension based on $3"
|
||||
if [ -d "$extension_folder" ]; then
|
||||
if [ -d "$extension_folder.git" ]; then
|
||||
echo "Pulling changes from git..."
|
||||
(cd "$extension_folder" && git pull) || exit 1
|
||||
else
|
||||
echo "No git repository. Extension will not be updated."
|
||||
fi
|
||||
else
|
||||
echo "Cloning repository..."
|
||||
git clone "$extension_repository_path" "$extension_folder" || exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$extension_folder/Makefile" ]; then
|
||||
tmp_extension_folder="/tmp/$extension_name"
|
||||
mv "$extension_folder" "$tmp_extension_folder"
|
||||
echo "Compiling extension..."
|
||||
(cd "$tmp_extension_folder" && make install) || exit 1 "Compilation failed."
|
||||
echo "Cleaning up temporary extension folder..."
|
||||
rm -fr "$tmp_extension_folder" || exit 1
|
||||
else
|
||||
echo "No Makefile found. Skipping compilation..."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Enabling GNOME extension \"$extension_name\"..."
|
||||
gnome-extensions enable "$extension_name" || exit 1
|
||||
fi
|
||||
|
||||
if [ "$action_type" == "disable" ]; then
|
||||
echo "Disabling GNOME extension \"$extension_name\"..."
|
||||
gnome-extensions disable "$extension_name" || exit 1
|
||||
fi
|
||||
|
||||
echo "Installation complete."
|
||||
36
pyproject.toml
Normal file
36
pyproject.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "cli-gnome-extension-manager"
|
||||
version = "0.0.0"
|
||||
description = "Install, enable and disable GNOME shell extensions from the shell."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
authors = [{ name = "Kevin Veen-Birkenbach", email = "kevin@veen.world" }]
|
||||
keywords = ["gnome", "extensions", "desktop", "cli"]
|
||||
dependencies = []
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://www.veen.world/"
|
||||
Repository = "https://github.com/kevinveenbirkenbach/cli-gnome-extension-manager"
|
||||
|
||||
[project.scripts]
|
||||
cli-gnome-extension-manager = "gnome_extension_manager.cli:main"
|
||||
goexma = "gnome_extension_manager.cli:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/gnome_extension_manager"]
|
||||
core-metadata-version = "2.4"
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
core-metadata-version = "2.4"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "B", "UP"]
|
||||
ignore = ["E501"]
|
||||
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])
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
12
tests/e2e/Dockerfile
Normal file
12
tests/e2e/Dockerfile
Normal 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
147
tests/e2e/run.sh
Normal 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"
|
||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
222
tests/integration/test_cli.py
Normal file
222
tests/integration/test_cli.py
Normal 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
0
tests/unit/__init__.py
Normal file
96
tests/unit/test_cli.py
Normal file
96
tests/unit/test_cli.py
Normal 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
136
tests/unit/test_core.py
Normal 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()
|
||||
Reference in New Issue
Block a user