Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a6c2f2988 | ||
|
|
0c90e984ad | ||
|
|
0a0cbbfe6d | ||
|
|
15c44cd484 | ||
|
|
6d7ee6fc04 | ||
|
|
5a022db0db | ||
|
|
37ac22e0b4 | ||
|
|
bcea440e40 | ||
|
|
6edde2d65b | ||
|
|
74189c1e14 | ||
|
|
b5ddf7402a | ||
|
|
900224ed2e | ||
|
|
e290043089 | ||
|
|
a7fd37d646 | ||
|
|
d4b00046d3 | ||
|
|
545d345ea4 | ||
|
|
a29b831e41 | ||
|
|
bc9ca140bd | ||
|
|
ad8e3cd07c | ||
|
|
22efe0b32e | ||
|
|
d23a0a94d5 | ||
|
|
e42b79c9d8 | ||
|
|
3b2c657bfa | ||
|
|
e335ab05a1 | ||
|
|
75f963d6e2 | ||
|
|
94b998741f | ||
|
|
172c734866 | ||
|
|
1b483e178d | ||
|
|
78693225f1 | ||
|
|
ca08c84789 | ||
|
|
e930b422e5 | ||
|
|
0833d04376 | ||
|
|
55f36d76ec | ||
|
|
6a838ee84f | ||
|
|
4285bf4a54 | ||
|
|
640b1042c2 | ||
|
|
9357c4632e | ||
|
|
ca5d0d22f3 | ||
|
|
3875338fb7 | ||
|
|
196f55c58e | ||
|
|
9a149715f6 | ||
|
|
bf40533469 | ||
|
|
7bc7259988 | ||
|
|
66b96ac3a5 | ||
|
|
f974e0b14a | ||
|
|
de8c3f768d | ||
|
|
05ff250251 | ||
|
|
ab52d37467 |
2
.github/workflows/test-container.yml
vendored
2
.github/workflows/test-container.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Test Distribution Containers
|
||||
name: Test OS Containers
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
2
.github/workflows/test-e2e.yml
vendored
2
.github/workflows/test-e2e.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Test package-manager (e2e)
|
||||
name: Test End-To-End
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
8
.github/workflows/test-integration.yml
vendored
8
.github/workflows/test-integration.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Test package-manager (integration)
|
||||
name: Test Code Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -21,9 +21,5 @@ jobs:
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
# Build Arch test image (same as used in test-unit and test-e2e)
|
||||
- name: Build test images
|
||||
run: make build
|
||||
|
||||
- name: Run integration tests via make (Arch container)
|
||||
run: make test-integration
|
||||
run: make test-integration DISTROS="arch"
|
||||
|
||||
4
.github/workflows/test-unit.yml
vendored
4
.github/workflows/test-unit.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Test package-manager (unit)
|
||||
name: Test Units
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -22,4 +22,4 @@ jobs:
|
||||
run: docker version
|
||||
|
||||
- name: Run unit tests via make (Arch container)
|
||||
run: make test-unit
|
||||
run: make test-unit DISTROS="arch"
|
||||
|
||||
64
.github/workflows/test-virgin-root.yml
vendored
Normal file
64
.github/workflows/test-virgin-root.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Test Virgin Root
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- develop
|
||||
- "*"
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test-virgin-root:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
- name: Virgin Arch pkgmgr flake test (root)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo ">>> Starting virgin ArchLinux container test (root, with shared caches)..."
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD":/src \
|
||||
-v pkgmgr_repos:/root/Repositories \
|
||||
-v pkgmgr_pip_cache:/root/.cache/pip \
|
||||
-w /src \
|
||||
archlinux:latest \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
|
||||
echo ">>> Updating and upgrading Arch system..."
|
||||
pacman -Syu --noconfirm git python python-pip nix >/dev/null
|
||||
|
||||
echo ">>> Creating isolated virtual environment for pkgmgr..."
|
||||
python -m venv /tmp/pkgmgr-venv
|
||||
|
||||
echo ">>> Activating virtual environment..."
|
||||
source /tmp/pkgmgr-venv/bin/activate
|
||||
|
||||
echo ">>> Upgrading pip (cached)..."
|
||||
python -m pip install --upgrade pip >/dev/null
|
||||
|
||||
echo ">>> Installing pkgmgr from current source tree (cached pip)..."
|
||||
python -m pip install /src >/dev/null
|
||||
|
||||
echo ">>> Enabling Nix experimental features..."
|
||||
export NIX_CONFIG="experimental-features = nix-command flakes"
|
||||
|
||||
echo ">>> Running: pkgmgr update pkgmgr --clone-mode shallow --no-verification"
|
||||
pkgmgr update pkgmgr --clone-mode shallow --no-verification
|
||||
|
||||
echo ">>> Running: pkgmgr version pkgmgr"
|
||||
pkgmgr version pkgmgr
|
||||
|
||||
echo ">>> Virgin Arch (root) test completed successfully."
|
||||
'
|
||||
79
.github/workflows/test-virgin-user.yml
vendored
Normal file
79
.github/workflows/test-virgin-user.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
name: Test Virgin User
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- develop
|
||||
- "*"
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test-virgin-user:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
- name: Virgin Arch pkgmgr user test (non-root with sudo)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo ">>> Starting virgin ArchLinux container test (non-root user with sudo)..."
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD":/src \
|
||||
archlinux:latest \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
|
||||
echo ">>> [root] Updating and upgrading Arch system..."
|
||||
pacman -Syu --noconfirm git python python-pip sudo base-devel debugedit
|
||||
|
||||
echo ">>> [root] Creating non-root user dev..."
|
||||
useradd -m dev
|
||||
|
||||
echo ">>> [root] Allowing passwordless sudo for dev..."
|
||||
echo "dev ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/dev
|
||||
chmod 0440 /etc/sudoers.d/dev
|
||||
|
||||
echo ">>> [root] Adjusting ownership of /src for dev..."
|
||||
chown -R dev:dev /src
|
||||
|
||||
echo ">>> [root] Running pkgmgr flow as non-root user dev..."
|
||||
sudo -u dev env PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 bash -lc "
|
||||
set -euo pipefail
|
||||
cd /src
|
||||
|
||||
echo \">>> [dev] Using user: \$(whoami)\"
|
||||
echo \">>> [dev] Running scripts/installation/main.sh...\"
|
||||
bash scripts/installation/main.sh
|
||||
|
||||
echo \">>> [dev] Activating venv...\"
|
||||
. \"\$HOME/.venvs/pkgmgr/bin/activate\"
|
||||
|
||||
echo \">>> [dev] Installing pkgmgr into venv via pip...\"
|
||||
python -m pip install /src >/dev/null
|
||||
|
||||
echo \">>> [dev] PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=\$PKGMGR_DISABLE_NIX_FLAKE_INSTALLER\"
|
||||
echo \">>> [dev] Updating managed repo package-manager via pkgmgr...\"
|
||||
pkgmgr update pkgmgr --clone-mode shallow --no-verification
|
||||
|
||||
echo \">>> [dev] PATH:\"
|
||||
echo \"\$PATH\"
|
||||
|
||||
echo \">>> [dev] which pkgmgr:\"
|
||||
which pkgmgr || echo \">>> [dev] pkgmgr not found in PATH\"
|
||||
|
||||
echo \">>> [dev] Running: pkgmgr version pkgmgr\"
|
||||
pkgmgr version pkgmgr
|
||||
"
|
||||
|
||||
echo ">>> [root] Container flow finished."
|
||||
'
|
||||
21
.gitignore
vendored
21
.gitignore
vendored
@@ -1,9 +1,6 @@
|
||||
|
||||
# Prevents unwanted files from being committed to version control.
|
||||
|
||||
# Custom Config file
|
||||
config/config.yaml
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -17,6 +14,16 @@ venv/
|
||||
dist/
|
||||
build/*
|
||||
*.egg-info/
|
||||
pkg
|
||||
src/source
|
||||
package-manager-*
|
||||
|
||||
# debian
|
||||
debian/package-manager/
|
||||
debian/debhelper-build-stamp
|
||||
debian/files
|
||||
debian/.debhelper/
|
||||
debian/package-manager.substvars
|
||||
|
||||
# Editor files
|
||||
.vscode/
|
||||
@@ -31,11 +38,3 @@ Thumbs.db
|
||||
|
||||
# Ignore logs
|
||||
*.log
|
||||
package-manager-*
|
||||
|
||||
# debian
|
||||
debian/package-manager/
|
||||
debian/debhelper-build-stamp
|
||||
debian/files
|
||||
debian/.debhelper/
|
||||
debian/package-manager.substvars
|
||||
95
CHANGELOG.md
95
CHANGELOG.md
@@ -1,3 +1,98 @@
|
||||
## [0.9.1] - 2025-12-10
|
||||
|
||||
* * Refactored installer: new `venv-create.sh`, cleaner root/user setup flow, updated README with architecture map.
|
||||
* Split virgin tests into root/user workflows; stabilized Nix installer across distros; improved test scripts with dynamic distro selection and isolated Nix stores.
|
||||
* Fixed repository directory resolution; improved `pkgmgr path` and `pkgmgr shell`; added full unit/E2E coverage.
|
||||
* Removed deprecated files and updated `.gitignore`.
|
||||
|
||||
|
||||
## [0.9.0] - 2025-12-10
|
||||
|
||||
* Introduce a virgin Arch-based Nix flake E2E workflow that validates pkgmgr’s full flake installation path using shared caches for faster and reproducible CI runs.
|
||||
|
||||
|
||||
## [0.8.0] - 2025-12-10
|
||||
|
||||
* **v0.7.15 — Installer & Command Resolution Improvements**
|
||||
|
||||
* Introduced a unified **layer-based installer pipeline** with clear precedence (OS-packages, Nix, Python, Makefile).
|
||||
* Reworked installer structure and improved Python/Nix/Makefile installers, including isolated Python venvs and refined flake-output handling.
|
||||
* Fully rewrote **command resolution** with stronger typing, safer fallbacks, and explicit support for `command: null` to mark library-only repositories.
|
||||
* Added extensive **unit and integration tests** for installer capability ordering, command resolution, and Nix/Python installer behavior.
|
||||
* Expanded documentation with capability hierarchy diagrams and scenario matrices.
|
||||
* Removed deprecated repository entries and obsolete configuration files.
|
||||
|
||||
|
||||
## [0.7.14] - 2025-12-10
|
||||
|
||||
* Fixed the clone-all integration test so that `SystemExit(0)` from the proxy is treated as a successful command instead of a failure.
|
||||
|
||||
|
||||
## [0.7.13] - 2025-12-10
|
||||
|
||||
### Fix tools path resolution and add tests
|
||||
|
||||
- Fixed a crash in `pkgmgr code` caused by missing `directory` metadata by introducing `_resolve_repository_path()` with proper fallbacks to `repositories_base_dir` / `repositories_dir`.
|
||||
- Updated `explore`, `terminal` and `code` tool commands to use the new resolver.
|
||||
- Improved VS Code workspace generation and path handling.
|
||||
- Added unit & E2E tests for tool commands.
|
||||
|
||||
|
||||
## [0.7.12] - 2025-12-09
|
||||
|
||||
* Fixed self refering alias during setup
|
||||
|
||||
|
||||
## [0.7.11] - 2025-12-09
|
||||
|
||||
* test: fix installer unit tests for OS packages and Nix dev shell
|
||||
|
||||
|
||||
## [0.7.10] - 2025-12-09
|
||||
|
||||
* Fixed test_install_pkgmgr_shallow.py
|
||||
|
||||
|
||||
## [0.7.9] - 2025-12-09
|
||||
|
||||
* 'main' and 'master' are now both accepted as branches for branch close merge
|
||||
|
||||
|
||||
## [0.7.8] - 2025-12-09
|
||||
|
||||
* Missing pyproject.toml doesn't lead to an error during release
|
||||
|
||||
|
||||
## [0.7.7] - 2025-12-09
|
||||
|
||||
* Added TEST_PATTERN parameter to execute dedicated tests
|
||||
|
||||
|
||||
## [0.7.6] - 2025-12-09
|
||||
|
||||
* Fixed pull --preview bug in e2e test
|
||||
|
||||
|
||||
## [0.7.5] - 2025-12-09
|
||||
|
||||
* Fixed wrong directory permissions for nix
|
||||
|
||||
|
||||
## [0.7.4] - 2025-12-09
|
||||
|
||||
* Fixed missing build in test workflow -> Tests pass now
|
||||
|
||||
|
||||
## [0.7.3] - 2025-12-09
|
||||
|
||||
* Fixed bug: Ignored packages are now ignored
|
||||
|
||||
|
||||
## [0.7.2] - 2025-12-09
|
||||
|
||||
* Implemented Changelog Support for Fedora and Debian
|
||||
|
||||
|
||||
## [0.7.1] - 2025-12-09
|
||||
|
||||
* Fix floating 'latest' tag logic: dereference annotated target (vX.Y.Z^{}), add tag message to avoid Git errors, ensure best-effort update without blocking releases, and update unit tests (see ChatGPT conversation: https://chatgpt.com/share/69383024-efa4-800f-a875-129b81fa40ff).
|
||||
|
||||
24
Makefile
24
Makefile
@@ -2,17 +2,11 @@
|
||||
test build build-no-cache test-unit test-e2e test-integration \
|
||||
test-container
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Local Nix cache directories in the repo
|
||||
# ------------------------------------------------------------
|
||||
NIX_STORE_VOLUME := pkgmgr_nix_store
|
||||
NIX_CACHE_VOLUME := pkgmgr_nix_cache
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Distro list and base images
|
||||
# (kept for documentation/reference; actual build logic is in scripts/build)
|
||||
# ------------------------------------------------------------
|
||||
DISTROS := arch debian ubuntu fedora centos
|
||||
DISTROS := arch debian ubuntu fedora centos
|
||||
BASE_IMAGE_ARCH := archlinux:latest
|
||||
BASE_IMAGE_DEBIAN := debian:stable-slim
|
||||
BASE_IMAGE_UBUNTU := ubuntu:latest
|
||||
@@ -27,6 +21,10 @@ export BASE_IMAGE_UBUNTU
|
||||
export BASE_IMAGE_FEDORA
|
||||
export BASE_IMAGE_CENTOS
|
||||
|
||||
# PYthon Unittest Pattern
|
||||
TEST_PATTERN := test_*.py
|
||||
export TEST_PATTERN
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# PKGMGR setup (developer wrapper -> scripts/installation/main.sh)
|
||||
# ------------------------------------------------------------
|
||||
@@ -46,16 +44,16 @@ build:
|
||||
# Test targets (delegated to scripts/test)
|
||||
# ------------------------------------------------------------
|
||||
|
||||
test-unit:
|
||||
test-unit: build-missing
|
||||
@bash scripts/test/test-unit.sh
|
||||
|
||||
test-integration:
|
||||
test-integration: build-missing
|
||||
@bash scripts/test/test-integration.sh
|
||||
|
||||
test-e2e:
|
||||
test-e2e: build-missing
|
||||
@bash scripts/test/test-e2e.sh
|
||||
|
||||
test-container:
|
||||
test-container: build-missing
|
||||
@bash scripts/test/test-container.sh
|
||||
|
||||
# ------------------------------------------------------------
|
||||
@@ -64,8 +62,8 @@ test-container:
|
||||
build-missing:
|
||||
@bash scripts/build/build-image-missing.sh
|
||||
|
||||
# Combined test target for local + CI (unit + e2e + integration)
|
||||
test: build-missing test-container test-unit test-e2e test-integration
|
||||
# Combined test target for local + CI (unit + integration + e2e)
|
||||
test: test-container test-unit test-integration test-e2e
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# System install (native packages, calls scripts/installation/run-package.sh)
|
||||
|
||||
2
PKGBUILD
2
PKGBUILD
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
||||
|
||||
pkgname=package-manager
|
||||
pkgver=0.7.1
|
||||
pkgver=0.9.1
|
||||
pkgrel=1
|
||||
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
||||
arch=('any')
|
||||
|
||||
64
README.md
64
README.md
@@ -24,6 +24,15 @@
|
||||
- **Custom Aliases:**
|
||||
Generate and manage custom aliases for easy command invocation.
|
||||
|
||||
## Architecture & Setup Map 🗺️
|
||||
|
||||
The following diagram provides a full overview of PKGMGR’s package structure,
|
||||
installation layers, and setup controller flow:
|
||||
|
||||

|
||||
|
||||
**Diagram status:** *Stand: 10. Dezember 2025*
|
||||
**Always-up-to-date version:** https://s.veen.world/pkgmgrmp
|
||||
|
||||
## Installation ⚙️
|
||||
|
||||
@@ -51,55 +60,6 @@ The `make setup` command will:
|
||||
- Install required packages from `requirements.txt`.
|
||||
- Execute `python main.py install` to complete the installation.
|
||||
|
||||
## Docker Quickstart 🐳
|
||||
|
||||
Alternatively to installing locally, you can use Docker: build the image with
|
||||
|
||||
```bash
|
||||
docker build --no-cache -t pkgmgr .
|
||||
```
|
||||
|
||||
or alternativ pull it via
|
||||
|
||||
```bash
|
||||
docker pull kevinveenbirkenbach/pkgmgr:latest
|
||||
```
|
||||
|
||||
and then run
|
||||
|
||||
```bash
|
||||
docker run --rm pkgmgr --help
|
||||
```
|
||||
|
||||
## Usage 📖
|
||||
|
||||
Run the script with different commands. For example:
|
||||
|
||||
- **Install all packages:**
|
||||
```bash
|
||||
pkgmgr install --all
|
||||
```
|
||||
- **Pull updates for a specific repository:**
|
||||
```bash
|
||||
pkgmgr pull pkgmgr
|
||||
```
|
||||
- **Commit changes with extra Git parameters:**
|
||||
```bash
|
||||
pkgmgr commit pkgmgr -- -m "Your commit message"
|
||||
```
|
||||
- **List all configured packages:**
|
||||
```bash
|
||||
pkgmgr config show
|
||||
```
|
||||
- **Manage configuration:**
|
||||
```bash
|
||||
pkgmgr config init
|
||||
pkgmgr config add
|
||||
pkgmgr config edit
|
||||
pkgmgr config delete <identifier>
|
||||
pkgmgr config ignore <identifier> --set true
|
||||
```
|
||||
|
||||
## License 📄
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
@@ -108,9 +68,3 @@ This project is licensed under the MIT License.
|
||||
|
||||
Kevin Veen-Birkenbach
|
||||
[https://www.veen.world](https://www.veen.world)
|
||||
|
||||
---
|
||||
|
||||
**Repository:** [github.com/kevinveenbirkenbach/package-manager](https://github.com/kevinveenbirkenbach/package-manager)
|
||||
|
||||
*Created with AI 🤖 - [View conversation](https://chatgpt.com/share/67c728c4-92d0-800f-8945-003fa9bf27c6)*
|
||||
|
||||
BIN
assets/map.png
Normal file
BIN
assets/map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -380,17 +380,6 @@ repositories:
|
||||
- 44D8F11FD62F878E
|
||||
- B5690EEEBB952194
|
||||
|
||||
- account: kevinveenbirkenbach
|
||||
alias: infinito-presentation
|
||||
description: This repository contains a Infinito.Nexus presentation designed for customers, end-users, investors, developers, and administrators, offering tailored content and insights for each group.
|
||||
homepage: https://github.com/kevinveenbirkenbach/infinito-presentation
|
||||
provider: github.com
|
||||
repository: infinito-presentation
|
||||
verified:
|
||||
gpg_keys:
|
||||
- 44D8F11FD62F878E
|
||||
- B5690EEEBB952194
|
||||
|
||||
- account: kevinveenbirkenbach
|
||||
description: A lightweight Python utility to generate dynamic color schemes from a single base color. Provides HSL-based color transformations for theming, UI design, and CSS variable generation. Optimized for integration in Python projects, Flask applications, and Ansible roles.
|
||||
homepage: https://github.com/kevinveenbirkenbach/colorscheme-generator
|
||||
@@ -599,17 +588,6 @@ repositories:
|
||||
- 44D8F11FD62F878E
|
||||
- B5690EEEBB952194
|
||||
|
||||
- account: kevinveenbirkenbach
|
||||
desciption: Infinito Inventory Builder — a containerized web application that dynamically generates Ansible inventory files from invokable Infinito.Nexus roles through an interactive, browser-based interface.
|
||||
homepage: https://github.com/kevinveenbirkenbach/infinito-inventory-builder
|
||||
alias: invbuild
|
||||
provider: github.com
|
||||
repository: infinito-inventory-builder
|
||||
verified:
|
||||
gpg_keys:
|
||||
- 44D8F11FD62F878E
|
||||
- B5690EEEBB952194
|
||||
|
||||
- account: kevinveenbirkenbach
|
||||
desciption: A simple Python CLI tool to safely rename Linux user accounts using usermod — including home directory migration and validation checks.
|
||||
homepage: https://github.com/kevinveenbirkenbach/user-rename
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
- account: kevinveenbirkenbach
|
||||
alias: gkfdrtdtcntr
|
||||
provider: github.com
|
||||
repository: federated-to-central-social-network-bridge
|
||||
verified:
|
||||
gpg_keys:
|
||||
- 44D8F11FD62F878E
|
||||
106
debian/changelog
vendored
106
debian/changelog
vendored
@@ -1,3 +1,109 @@
|
||||
package-manager (0.9.1-1) unstable; urgency=medium
|
||||
|
||||
* * Refactored installer: new `venv-create.sh`, cleaner root/user setup flow, updated README with architecture map.
|
||||
* Split virgin tests into root/user workflows; stabilized Nix installer across distros; improved test scripts with dynamic distro selection and isolated Nix stores.
|
||||
* Fixed repository directory resolution; improved `pkgmgr path` and `pkgmgr shell`; added full unit/E2E coverage.
|
||||
* Removed deprecated files and updated `.gitignore`.
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 10 Dec 2025 22:56:01 +0100
|
||||
|
||||
package-manager (0.9.0-1) unstable; urgency=medium
|
||||
|
||||
* Introduce a virgin Arch-based Nix flake E2E workflow that validates pkgmgr’s full flake installation path using shared caches for faster and reproducible CI runs.
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 10 Dec 2025 18:38:07 +0100
|
||||
|
||||
package-manager (0.8.0-1) unstable; urgency=medium
|
||||
|
||||
* **v0.7.15 — Installer & Command Resolution Improvements**
|
||||
|
||||
* Introduced a unified **layer-based installer pipeline** with clear precedence (OS-packages, Nix, Python, Makefile).
|
||||
* Reworked installer structure and improved Python/Nix/Makefile installers, including isolated Python venvs and refined flake-output handling.
|
||||
* Fully rewrote **command resolution** with stronger typing, safer fallbacks, and explicit support for `command: null` to mark library-only repositories.
|
||||
* Added extensive **unit and integration tests** for installer capability ordering, command resolution, and Nix/Python installer behavior.
|
||||
* Expanded documentation with capability hierarchy diagrams and scenario matrices.
|
||||
* Removed deprecated repository entries and obsolete configuration files.
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 10 Dec 2025 17:31:57 +0100
|
||||
|
||||
package-manager (0.7.14-1) unstable; urgency=medium
|
||||
|
||||
* Fixed the clone-all integration test so that `SystemExit(0)` from the proxy is treated as a successful command instead of a failure.
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 10 Dec 2025 10:38:33 +0100
|
||||
|
||||
package-manager (0.7.13-1) unstable; urgency=medium
|
||||
|
||||
* Automated release.
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 10 Dec 2025 10:27:24 +0100
|
||||
|
||||
package-manager (0.7.12-1) unstable; urgency=medium
|
||||
|
||||
* Fixed self refering alias during setup
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 23:36:35 +0100
|
||||
|
||||
package-manager (0.7.11-1) unstable; urgency=medium
|
||||
|
||||
* test: fix installer unit tests for OS packages and Nix dev shell
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 23:16:46 +0100
|
||||
|
||||
package-manager (0.7.10-1) unstable; urgency=medium
|
||||
|
||||
* Fixed test_install_pkgmgr_shallow.py
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 22:57:08 +0100
|
||||
|
||||
package-manager (0.7.9-1) unstable; urgency=medium
|
||||
|
||||
* 'main' and 'master' are now both accepted as branches for branch close merge
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 21:19:13 +0100
|
||||
|
||||
package-manager (0.7.8-1) unstable; urgency=medium
|
||||
|
||||
* Missing pyproject.toml doesn't lead to an error during release
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 21:03:24 +0100
|
||||
|
||||
package-manager (0.7.7-1) unstable; urgency=medium
|
||||
|
||||
* Added TEST_PATTERN parameter to execute dedicated tests
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 17:54:38 +0100
|
||||
|
||||
package-manager (0.7.6-1) unstable; urgency=medium
|
||||
|
||||
* Fixed pull --preview bug in e2e test
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 17:14:19 +0100
|
||||
|
||||
package-manager (0.7.5-1) unstable; urgency=medium
|
||||
|
||||
* Fixed wrong directory permissions for nix
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 16:45:42 +0100
|
||||
|
||||
package-manager (0.7.4-1) unstable; urgency=medium
|
||||
|
||||
* Fixed missing build in test workflow -> Tests pass now
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 16:22:00 +0100
|
||||
|
||||
package-manager (0.7.3-1) unstable; urgency=medium
|
||||
|
||||
* Fixed bug: Ignored packages are now ignored
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 16:08:31 +0100
|
||||
|
||||
package-manager (0.7.2-1) unstable; urgency=medium
|
||||
|
||||
* Implemented Changelog Support for Fedora and Debian
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 15:48:58 +0100
|
||||
|
||||
package-manager (0.7.1-1) unstable; urgency=medium
|
||||
|
||||
* Fix floating 'latest' tag logic: dereference annotated target (vX.Y.Z^{}), add tag message to avoid Git errors, ensure best-effort update without blocking releases, and update unit tests (see ChatGPT conversation: https://chatgpt.com/share/69383024-efa4-800f-a875-129b81fa40ff).
|
||||
|
||||
12
flake.nix
12
flake.nix
@@ -31,7 +31,7 @@
|
||||
rec {
|
||||
pkgmgr = pyPkgs.buildPythonApplication {
|
||||
pname = "package-manager";
|
||||
version = "0.7.1";
|
||||
version = "0.9.1";
|
||||
|
||||
# Use the git repo as source
|
||||
src = ./.;
|
||||
@@ -48,9 +48,7 @@
|
||||
# Runtime dependencies (matches [project.dependencies])
|
||||
propagatedBuildInputs = [
|
||||
pyPkgs.pyyaml
|
||||
# Add more here if needed, e.g.:
|
||||
# pyPkgs.click
|
||||
# pyPkgs.rich
|
||||
pyPkgs.pip
|
||||
];
|
||||
|
||||
doCheck = false;
|
||||
@@ -72,10 +70,16 @@
|
||||
ansiblePkg =
|
||||
if pkgs ? ansible-core then pkgs.ansible-core
|
||||
else pkgs.ansible;
|
||||
|
||||
# Python 3 + pip für alles, was "python3 -m pip" macht
|
||||
pythonWithPip = pkgs.python3.withPackages (ps: [
|
||||
ps.pip
|
||||
]);
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pythonWithPip
|
||||
pkgmgrPkg
|
||||
pkgs.git
|
||||
ansiblePkg
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: package-manager
|
||||
Version: 0.7.1
|
||||
Version: 0.9.1
|
||||
Release: 1%{?dist}
|
||||
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
||||
|
||||
@@ -77,5 +77,63 @@ echo ">>> package-manager removed. Nix itself was not removed."
|
||||
/usr/lib/package-manager/
|
||||
|
||||
%changelog
|
||||
* Wed Dec 10 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.9.1-1
|
||||
- * Refactored installer: new `venv-create.sh`, cleaner root/user setup flow, updated README with architecture map.
|
||||
* Split virgin tests into root/user workflows; stabilized Nix installer across distros; improved test scripts with dynamic distro selection and isolated Nix stores.
|
||||
* Fixed repository directory resolution; improved `pkgmgr path` and `pkgmgr shell`; added full unit/E2E coverage.
|
||||
* Removed deprecated files and updated `.gitignore`.
|
||||
|
||||
* Wed Dec 10 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.9.0-1
|
||||
- Introduce a virgin Arch-based Nix flake E2E workflow that validates pkgmgr’s full flake installation path using shared caches for faster and reproducible CI runs.
|
||||
|
||||
* Wed Dec 10 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.8.0-1
|
||||
- **v0.7.15 — Installer & Command Resolution Improvements**
|
||||
|
||||
* Introduced a unified **layer-based installer pipeline** with clear precedence (OS-packages, Nix, Python, Makefile).
|
||||
* Reworked installer structure and improved Python/Nix/Makefile installers, including isolated Python venvs and refined flake-output handling.
|
||||
* Fully rewrote **command resolution** with stronger typing, safer fallbacks, and explicit support for `command: null` to mark library-only repositories.
|
||||
* Added extensive **unit and integration tests** for installer capability ordering, command resolution, and Nix/Python installer behavior.
|
||||
* Expanded documentation with capability hierarchy diagrams and scenario matrices.
|
||||
* Removed deprecated repository entries and obsolete configuration files.
|
||||
|
||||
* Wed Dec 10 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.14-1
|
||||
- Fixed the clone-all integration test so that `SystemExit(0)` from the proxy is treated as a successful command instead of a failure.
|
||||
|
||||
* Wed Dec 10 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.13-1
|
||||
- Automated release.
|
||||
|
||||
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.12-1
|
||||
- Fixed self refering alias during setup
|
||||
|
||||
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.11-1
|
||||
- test: fix installer unit tests for OS packages and Nix dev shell
|
||||
|
||||
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.10-1
|
||||
- Fixed test_install_pkgmgr_shallow.py
|
||||
|
||||
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.9-1
|
||||
- 'main' and 'master' are now both accepted as branches for branch close merge
|
||||
|
||||
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.8-1
|
||||
- Missing pyproject.toml doesn't lead to an error during release
|
||||
|
||||
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.7-1
|
||||
- Added TEST_PATTERN parameter to execute dedicated tests
|
||||
|
||||
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.6-1
|
||||
- Fixed pull --preview bug in e2e test
|
||||
|
||||
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.5-1
|
||||
- Fixed wrong directory permissions for nix
|
||||
|
||||
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.4-1
|
||||
- Fixed missing build in test workflow -> Tests pass now
|
||||
|
||||
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.3-1
|
||||
- Fixed bug: Ignored packages are now ignored
|
||||
|
||||
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.2-1
|
||||
- Implemented Changelog Support for Fedora and Debian
|
||||
|
||||
* Sat Dec 06 2025 Kevin Veen-Birkenbach <info@veen.world> - 0.1.1-1
|
||||
- Initial RPM packaging for package-manager
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
version: 1
|
||||
|
||||
author: "Kevin Veen-Birkenbach"
|
||||
url: "https://github.com/kevinveenbirkenbach/package-manager"
|
||||
description: "A configurable Python-based package manager for managing multiple repositories via Bash."
|
||||
|
||||
dependencies: []
|
||||
@@ -1,4 +1,4 @@
|
||||
# pkgmgr/branch_commands.py
|
||||
# pkgmgr/actions/branch/__init__.py
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
@@ -16,30 +16,43 @@ from typing import Optional
|
||||
from pkgmgr.core.git import run_git, GitError, get_current_branch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Branch creation (open)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def open_branch(
|
||||
name: Optional[str],
|
||||
base_branch: str = "main",
|
||||
fallback_base: str = "master",
|
||||
cwd: str = ".",
|
||||
) -> None:
|
||||
"""
|
||||
Create and push a new feature branch on top of `base_branch`.
|
||||
Create and push a new feature branch on top of a base branch.
|
||||
|
||||
The base branch is resolved by:
|
||||
1. Trying 'base_branch' (default: 'main')
|
||||
2. Falling back to 'fallback_base' (default: 'master')
|
||||
|
||||
Steps:
|
||||
1) git fetch origin
|
||||
2) git checkout <base_branch>
|
||||
3) git pull origin <base_branch>
|
||||
4) git checkout -b <name>
|
||||
5) git push -u origin <name>
|
||||
1) git fetch origin
|
||||
2) git checkout <resolved_base>
|
||||
3) git pull origin <resolved_base>
|
||||
4) git checkout -b <name>
|
||||
5) git push -u origin <name>
|
||||
|
||||
If `name` is None or empty, the user is prompted on stdin.
|
||||
If `name` is None or empty, the user is prompted to enter one.
|
||||
"""
|
||||
|
||||
# Request name interactively if not provided
|
||||
if not name:
|
||||
name = input("Enter new branch name: ").strip()
|
||||
|
||||
if not name:
|
||||
raise RuntimeError("Branch name must not be empty.")
|
||||
|
||||
# Resolve which base branch to use (main or master)
|
||||
resolved_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
|
||||
|
||||
# 1) Fetch from origin
|
||||
try:
|
||||
run_git(["fetch", "origin"], cwd=cwd)
|
||||
@@ -50,18 +63,18 @@ def open_branch(
|
||||
|
||||
# 2) Checkout base branch
|
||||
try:
|
||||
run_git(["checkout", base_branch], cwd=cwd)
|
||||
run_git(["checkout", resolved_base], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to checkout base branch {base_branch!r}: {exc}"
|
||||
f"Failed to checkout base branch {resolved_base!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# 3) Pull latest changes on base
|
||||
# 3) Pull latest changes for base branch
|
||||
try:
|
||||
run_git(["pull", "origin", base_branch], cwd=cwd)
|
||||
run_git(["pull", "origin", resolved_base], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to pull latest changes for base branch {base_branch!r}: {exc}"
|
||||
f"Failed to pull latest changes for base branch {resolved_base!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# 4) Create new branch
|
||||
@@ -69,10 +82,10 @@ def open_branch(
|
||||
run_git(["checkout", "-b", name], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to create new branch {name!r} from base {base_branch!r}: {exc}"
|
||||
f"Failed to create new branch {name!r} from base {resolved_base!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# 5) Push and set upstream
|
||||
# 5) Push new branch to origin
|
||||
try:
|
||||
run_git(["push", "-u", "origin", name], cwd=cwd)
|
||||
except GitError as exc:
|
||||
@@ -81,15 +94,21 @@ def open_branch(
|
||||
) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base branch resolver (shared by open/close)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_base_branch(
|
||||
preferred: str,
|
||||
fallback: str,
|
||||
cwd: str,
|
||||
) -> str:
|
||||
"""
|
||||
Resolve the base branch to use for merging.
|
||||
Resolve the base branch to use.
|
||||
|
||||
Try `preferred` first (default: main),
|
||||
fall back to `fallback` (default: master).
|
||||
|
||||
Try `preferred` (default: main) first, then `fallback` (default: master).
|
||||
Raise RuntimeError if neither exists.
|
||||
"""
|
||||
for candidate in (preferred, fallback):
|
||||
@@ -104,6 +123,10 @@ def _resolve_base_branch(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Branch closing (merge + deletion)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def close_branch(
|
||||
name: Optional[str],
|
||||
base_branch: str = "main",
|
||||
@@ -111,23 +134,22 @@ def close_branch(
|
||||
cwd: str = ".",
|
||||
) -> None:
|
||||
"""
|
||||
Merge a feature branch into the main/master branch and optionally delete it.
|
||||
Merge a feature branch into the base branch and delete it afterwards.
|
||||
|
||||
Steps:
|
||||
1) Determine branch name (argument or current branch)
|
||||
2) Resolve base branch (prefers `base_branch`, falls back to `fallback_base`)
|
||||
3) Ask for confirmation (y/N)
|
||||
4) git fetch origin
|
||||
5) git checkout <base>
|
||||
6) git pull origin <base>
|
||||
7) git merge --no-ff <name>
|
||||
8) git push origin <base>
|
||||
9) Delete branch locally and on origin
|
||||
|
||||
If the user does not confirm with 'y', the operation is aborted.
|
||||
1) Determine the branch name (argument or current branch)
|
||||
2) Resolve base branch (main/master)
|
||||
3) Ask for confirmation
|
||||
4) git fetch origin
|
||||
5) git checkout <base>
|
||||
6) git pull origin <base>
|
||||
7) git merge --no-ff <name>
|
||||
8) git push origin <base>
|
||||
9) Delete branch locally
|
||||
10) Delete branch on origin (best effort)
|
||||
"""
|
||||
|
||||
# 1) Determine which branch to close
|
||||
# 1) Determine which branch should be closed
|
||||
if not name:
|
||||
try:
|
||||
name = get_current_branch(cwd=cwd)
|
||||
@@ -137,7 +159,7 @@ def close_branch(
|
||||
if not name:
|
||||
raise RuntimeError("Branch name must not be empty.")
|
||||
|
||||
# 2) Resolve base branch (main/master)
|
||||
# 2) Resolve base branch
|
||||
target_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
|
||||
|
||||
if name == target_base:
|
||||
@@ -146,7 +168,7 @@ def close_branch(
|
||||
"Please specify a feature branch."
|
||||
)
|
||||
|
||||
# 3) Confirmation prompt
|
||||
# 3) Ask user for confirmation
|
||||
prompt = (
|
||||
f"Merge branch '{name}' into '{target_base}' and delete it afterwards? "
|
||||
"(y/N): "
|
||||
@@ -164,7 +186,7 @@ def close_branch(
|
||||
f"Failed to fetch from origin before closing branch {name!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# 5) Checkout base branch
|
||||
# 5) Checkout base
|
||||
try:
|
||||
run_git(["checkout", target_base], cwd=cwd)
|
||||
except GitError as exc:
|
||||
@@ -172,7 +194,7 @@ def close_branch(
|
||||
f"Failed to checkout base branch {target_base!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# 6) Pull latest base
|
||||
# 6) Pull latest base state
|
||||
try:
|
||||
run_git(["pull", "origin", target_base], cwd=cwd)
|
||||
except GitError as exc:
|
||||
@@ -180,7 +202,7 @@ def close_branch(
|
||||
f"Failed to pull latest changes for base branch {target_base!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# 7) Merge feature branch into base
|
||||
# 7) Merge the feature branch
|
||||
try:
|
||||
run_git(["merge", "--no-ff", name], cwd=cwd)
|
||||
except GitError as exc:
|
||||
@@ -193,22 +215,21 @@ def close_branch(
|
||||
run_git(["push", "origin", target_base], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to push base branch {target_base!r} to origin after merge: {exc}"
|
||||
f"Failed to push base branch {target_base!r} after merge: {exc}"
|
||||
) from exc
|
||||
|
||||
# 9) Delete feature branch locally
|
||||
# 9) Delete branch locally
|
||||
try:
|
||||
run_git(["branch", "-d", name], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to delete local branch {name!r} after merge: {exc}"
|
||||
f"Failed to delete local branch {name!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# 10) Delete feature branch on origin (best effort)
|
||||
# 10) Delete branch on origin (best effort)
|
||||
try:
|
||||
run_git(["push", "origin", "--delete", name], cwd=cwd)
|
||||
except GitError as exc:
|
||||
# Remote delete is nice-to-have; surface as RuntimeError for clarity.
|
||||
raise RuntimeError(
|
||||
f"Branch {name!r} was deleted locally, but remote deletion failed: {exc}"
|
||||
) from exc
|
||||
|
||||
218
pkgmgr/actions/install/__init__.py
Normal file
218
pkgmgr/actions/install/__init__.py
Normal file
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
High-level entry point for repository installation.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Ensure the repository directory exists (clone if necessary).
|
||||
- Verify the repository (GPG / commit checks).
|
||||
- Build a RepoContext object.
|
||||
- Delegate the actual installation decision logic to InstallationPipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.verify import verify_repository
|
||||
from pkgmgr.actions.repository.clone import clone_repos
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.os_packages import (
|
||||
ArchPkgbuildInstaller,
|
||||
DebianControlInstaller,
|
||||
RpmSpecInstaller,
|
||||
)
|
||||
from pkgmgr.actions.install.installers.nix_flake import (
|
||||
NixFlakeInstaller,
|
||||
)
|
||||
from pkgmgr.actions.install.installers.python import PythonInstaller
|
||||
from pkgmgr.actions.install.installers.makefile import (
|
||||
MakefileInstaller,
|
||||
)
|
||||
from pkgmgr.actions.install.pipeline import InstallationPipeline
|
||||
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
|
||||
# All available installers, in the order they should be considered.
|
||||
INSTALLERS = [
|
||||
ArchPkgbuildInstaller(),
|
||||
DebianControlInstaller(),
|
||||
RpmSpecInstaller(),
|
||||
NixFlakeInstaller(),
|
||||
PythonInstaller(),
|
||||
MakefileInstaller(),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ensure_repo_dir(
|
||||
repo: Repository,
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Repository],
|
||||
preview: bool,
|
||||
no_verification: bool,
|
||||
clone_mode: str,
|
||||
identifier: str,
|
||||
) -> str | None:
|
||||
"""
|
||||
Compute and, if necessary, clone the repository directory.
|
||||
|
||||
Returns the absolute repository path or None if cloning ultimately failed.
|
||||
"""
|
||||
repo_dir = get_repo_dir(repositories_base_dir, repo)
|
||||
|
||||
if not os.path.exists(repo_dir):
|
||||
print(
|
||||
f"Repository directory '{repo_dir}' does not exist. "
|
||||
f"Cloning it now..."
|
||||
)
|
||||
clone_repos(
|
||||
[repo],
|
||||
repositories_base_dir,
|
||||
all_repos,
|
||||
preview,
|
||||
no_verification,
|
||||
clone_mode,
|
||||
)
|
||||
if not os.path.exists(repo_dir):
|
||||
print(
|
||||
f"Cloning failed for repository {identifier}. "
|
||||
f"Skipping installation."
|
||||
)
|
||||
return None
|
||||
|
||||
return repo_dir
|
||||
|
||||
|
||||
def _verify_repo(
|
||||
repo: Repository,
|
||||
repo_dir: str,
|
||||
no_verification: bool,
|
||||
identifier: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Verify a repository using the configured verification data.
|
||||
|
||||
Returns True if verification is considered okay and installation may continue.
|
||||
"""
|
||||
verified_info = repo.get("verified")
|
||||
verified_ok, errors, _commit_hash, _signing_key = verify_repository(
|
||||
repo,
|
||||
repo_dir,
|
||||
mode="local",
|
||||
no_verification=no_verification,
|
||||
)
|
||||
|
||||
if not no_verification and verified_info and not verified_ok:
|
||||
print(f"Warning: Verification failed for {identifier}:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
choice = input("Continue anyway? [y/N]: ").strip().lower()
|
||||
if choice != "y":
|
||||
print(f"Skipping installation for {identifier}.")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _create_context(
|
||||
repo: Repository,
|
||||
identifier: str,
|
||||
repo_dir: str,
|
||||
repositories_base_dir: str,
|
||||
bin_dir: str,
|
||||
all_repos: List[Repository],
|
||||
no_verification: bool,
|
||||
preview: bool,
|
||||
quiet: bool,
|
||||
clone_mode: str,
|
||||
update_dependencies: bool,
|
||||
) -> RepoContext:
|
||||
"""
|
||||
Build a RepoContext instance for the given repository.
|
||||
"""
|
||||
return RepoContext(
|
||||
repo=repo,
|
||||
identifier=identifier,
|
||||
repo_dir=repo_dir,
|
||||
repositories_base_dir=repositories_base_dir,
|
||||
bin_dir=bin_dir,
|
||||
all_repos=all_repos,
|
||||
no_verification=no_verification,
|
||||
preview=preview,
|
||||
quiet=quiet,
|
||||
clone_mode=clone_mode,
|
||||
update_dependencies=update_dependencies,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def install_repos(
|
||||
selected_repos: List[Repository],
|
||||
repositories_base_dir: str,
|
||||
bin_dir: str,
|
||||
all_repos: List[Repository],
|
||||
no_verification: bool,
|
||||
preview: bool,
|
||||
quiet: bool,
|
||||
clone_mode: str,
|
||||
update_dependencies: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Install one or more repositories according to the configured installers
|
||||
and the CLI layer precedence rules.
|
||||
"""
|
||||
pipeline = InstallationPipeline(INSTALLERS)
|
||||
|
||||
for repo in selected_repos:
|
||||
identifier = get_repo_identifier(repo, all_repos)
|
||||
|
||||
repo_dir = _ensure_repo_dir(
|
||||
repo=repo,
|
||||
repositories_base_dir=repositories_base_dir,
|
||||
all_repos=all_repos,
|
||||
preview=preview,
|
||||
no_verification=no_verification,
|
||||
clone_mode=clone_mode,
|
||||
identifier=identifier,
|
||||
)
|
||||
if not repo_dir:
|
||||
continue
|
||||
|
||||
if not _verify_repo(
|
||||
repo=repo,
|
||||
repo_dir=repo_dir,
|
||||
no_verification=no_verification,
|
||||
identifier=identifier,
|
||||
):
|
||||
continue
|
||||
|
||||
ctx = _create_context(
|
||||
repo=repo,
|
||||
identifier=identifier,
|
||||
repo_dir=repo_dir,
|
||||
repositories_base_dir=repositories_base_dir,
|
||||
bin_dir=bin_dir,
|
||||
all_repos=all_repos,
|
||||
no_verification=no_verification,
|
||||
preview=preview,
|
||||
quiet=quiet,
|
||||
clone_mode=clone_mode,
|
||||
update_dependencies=update_dependencies,
|
||||
)
|
||||
|
||||
pipeline.run(ctx)
|
||||
@@ -38,7 +38,7 @@ from abc import ABC, abstractmethod
|
||||
from typing import Iterable, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
19
pkgmgr/actions/install/installers/__init__.py
Normal file
19
pkgmgr/actions/install/installers/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer package for pkgmgr.
|
||||
|
||||
This exposes all installer classes so users can import them directly from
|
||||
pkgmgr.actions.install.installers.
|
||||
"""
|
||||
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller # noqa: F401
|
||||
from pkgmgr.actions.install.installers.nix_flake import NixFlakeInstaller # noqa: F401
|
||||
from pkgmgr.actions.install.installers.python import PythonInstaller # noqa: F401
|
||||
from pkgmgr.actions.install.installers.makefile import MakefileInstaller # noqa: F401
|
||||
|
||||
# OS-specific installers
|
||||
from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller # noqa: F401
|
||||
from pkgmgr.actions.install.installers.os_packages.debian_control import DebianControlInstaller # noqa: F401
|
||||
from pkgmgr.actions.install.installers.os_packages.rpm_spec import RpmSpecInstaller # noqa: F401
|
||||
@@ -8,8 +8,8 @@ Base interface for all installer components in the pkgmgr installation pipeline.
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Set
|
||||
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.repository.install.capabilities import CAPABILITY_MATCHERS
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.capabilities import CAPABILITY_MATCHERS
|
||||
|
||||
|
||||
class BaseInstaller(ABC):
|
||||
97
pkgmgr/actions/install/installers/makefile.py
Normal file
97
pkgmgr/actions/install/installers/makefile.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
|
||||
class MakefileInstaller(BaseInstaller):
|
||||
"""
|
||||
Generic installer that runs `make install` if a Makefile with an
|
||||
install target is present.
|
||||
|
||||
Safety rules:
|
||||
- If PKGMGR_DISABLE_MAKEFILE_INSTALLER=1 is set, this installer
|
||||
is globally disabled.
|
||||
- The higher-level InstallationPipeline ensures that Makefile
|
||||
installation does not run if a stronger CLI layer already owns
|
||||
the command (e.g. Nix or OS packages).
|
||||
"""
|
||||
|
||||
layer = "makefile"
|
||||
MAKEFILE_NAME = "Makefile"
|
||||
|
||||
def supports(self, ctx: RepoContext) -> bool:
|
||||
"""
|
||||
Return True if this repository has a Makefile and the installer
|
||||
is not globally disabled.
|
||||
"""
|
||||
# Optional global kill switch.
|
||||
if os.environ.get("PKGMGR_DISABLE_MAKEFILE_INSTALLER") == "1":
|
||||
if not ctx.quiet:
|
||||
print(
|
||||
"[INFO] MakefileInstaller is disabled via "
|
||||
"PKGMGR_DISABLE_MAKEFILE_INSTALLER."
|
||||
)
|
||||
return False
|
||||
|
||||
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
||||
return os.path.exists(makefile_path)
|
||||
|
||||
def _has_install_target(self, makefile_path: str) -> bool:
|
||||
"""
|
||||
Heuristically check whether the Makefile defines an install target.
|
||||
|
||||
We look for:
|
||||
|
||||
- a plain 'install:' target, or
|
||||
- any 'install-*:' style target.
|
||||
"""
|
||||
try:
|
||||
with open(makefile_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
content = f.read()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
# Simple heuristics: look for "install:" or targets starting with "install-"
|
||||
if re.search(r"^install\s*:", content, flags=re.MULTILINE):
|
||||
return True
|
||||
|
||||
if re.search(r"^install-[a-zA-Z0-9_-]*\s*:", content, flags=re.MULTILINE):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def run(self, ctx: RepoContext) -> None:
|
||||
"""
|
||||
Execute `make install` in the repository directory if an install
|
||||
target exists.
|
||||
"""
|
||||
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
||||
|
||||
if not os.path.exists(makefile_path):
|
||||
if not ctx.quiet:
|
||||
print(
|
||||
f"[pkgmgr] Makefile '{makefile_path}' not found, "
|
||||
"skipping MakefileInstaller."
|
||||
)
|
||||
return
|
||||
|
||||
if not self._has_install_target(makefile_path):
|
||||
if not ctx.quiet:
|
||||
print(
|
||||
f"[pkgmgr] No 'install' target found in {makefile_path}."
|
||||
)
|
||||
return
|
||||
|
||||
if not ctx.quiet:
|
||||
print(
|
||||
f"[pkgmgr] Running 'make install' in {ctx.repo_dir} "
|
||||
f"(MakefileInstaller)"
|
||||
)
|
||||
|
||||
cmd = "make install"
|
||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
160
pkgmgr/actions/install/installers/nix_flake.py
Normal file
160
pkgmgr/actions/install/installers/nix_flake.py
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer for Nix flakes.
|
||||
|
||||
If a repository contains flake.nix and the 'nix' command is available, this
|
||||
installer will try to install profile outputs from the flake.
|
||||
|
||||
Behavior:
|
||||
- If flake.nix is present and `nix` exists on PATH:
|
||||
* First remove any existing `package-manager` profile entry (best-effort).
|
||||
* Then install one or more flake outputs via `nix profile install`.
|
||||
- For the package-manager repo:
|
||||
* `pkgmgr` is mandatory (CLI), `default` is optional.
|
||||
- For all other repos:
|
||||
* `default` is mandatory.
|
||||
|
||||
Special handling:
|
||||
- If PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 is set, the installer is
|
||||
globally disabled (useful for CI or debugging).
|
||||
|
||||
The higher-level InstallationPipeline and CLI-layer model decide when this
|
||||
installer is allowed to run, based on where the current CLI comes from
|
||||
(e.g. Nix, OS packages, Python, Makefile).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import TYPE_CHECKING, List, Tuple
|
||||
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install import InstallContext
|
||||
|
||||
|
||||
class NixFlakeInstaller(BaseInstaller):
|
||||
"""Install Nix flake profiles for repositories that define flake.nix."""
|
||||
|
||||
# Logical layer name, used by capability matchers.
|
||||
layer = "nix"
|
||||
|
||||
FLAKE_FILE = "flake.nix"
|
||||
PROFILE_NAME = "package-manager"
|
||||
|
||||
def supports(self, ctx: "RepoContext") -> bool:
|
||||
"""
|
||||
Only support repositories that:
|
||||
- Are NOT explicitly disabled via PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1,
|
||||
- Have a flake.nix,
|
||||
- And have the `nix` command available.
|
||||
"""
|
||||
# Optional global kill-switch for CI or debugging.
|
||||
if os.environ.get("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER") == "1":
|
||||
print(
|
||||
"[INFO] PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 – "
|
||||
"NixFlakeInstaller is disabled."
|
||||
)
|
||||
return False
|
||||
|
||||
# Nix must be available.
|
||||
if shutil.which("nix") is None:
|
||||
return False
|
||||
|
||||
# flake.nix must exist in the repository.
|
||||
flake_path = os.path.join(ctx.repo_dir, self.FLAKE_FILE)
|
||||
return os.path.exists(flake_path)
|
||||
|
||||
def _ensure_old_profile_removed(self, ctx: "RepoContext") -> None:
|
||||
"""
|
||||
Best-effort removal of an existing profile entry.
|
||||
|
||||
This handles the "already provides the following file" conflict by
|
||||
removing previous `package-manager` installations before we install
|
||||
the new one.
|
||||
|
||||
Any error in `nix profile remove` is intentionally ignored, because
|
||||
a missing profile entry is not a fatal condition.
|
||||
"""
|
||||
if shutil.which("nix") is None:
|
||||
return
|
||||
|
||||
cmd = f"nix profile remove {self.PROFILE_NAME} || true"
|
||||
try:
|
||||
# NOTE: no allow_failure here → matches the existing unit tests
|
||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
except SystemExit:
|
||||
# Unit tests explicitly assert this is swallowed
|
||||
pass
|
||||
|
||||
def _profile_outputs(self, ctx: "RepoContext") -> List[Tuple[str, bool]]:
|
||||
"""
|
||||
Decide which flake outputs to install and whether failures are fatal.
|
||||
|
||||
Returns a list of (output_name, allow_failure) tuples.
|
||||
|
||||
Rules:
|
||||
- For the package-manager repo (identifier 'pkgmgr' or 'package-manager'):
|
||||
[("pkgmgr", False), ("default", True)]
|
||||
- For all other repos:
|
||||
[("default", False)]
|
||||
"""
|
||||
ident = ctx.identifier
|
||||
|
||||
if ident in {"pkgmgr", "package-manager"}:
|
||||
# pkgmgr: main CLI output is "pkgmgr" (mandatory),
|
||||
# "default" is nice-to-have (non-fatal).
|
||||
return [("pkgmgr", False), ("default", True)]
|
||||
|
||||
# Generic repos: we expect a sensible "default" package/app.
|
||||
# Failure to install it is considered fatal.
|
||||
return [("default", False)]
|
||||
|
||||
def run(self, ctx: "InstallContext") -> None:
|
||||
"""
|
||||
Install Nix flake profile outputs.
|
||||
|
||||
For the package-manager repo, failure installing 'pkgmgr' is fatal,
|
||||
failure installing 'default' is non-fatal.
|
||||
For other repos, failure installing 'default' is fatal.
|
||||
"""
|
||||
# Reuse supports() to keep logic in one place.
|
||||
if not self.supports(ctx): # type: ignore[arg-type]
|
||||
return
|
||||
|
||||
outputs = self._profile_outputs(ctx) # list of (name, allow_failure)
|
||||
|
||||
print(
|
||||
"Nix flake detected in "
|
||||
f"{ctx.identifier}, attempting to install profile outputs: "
|
||||
+ ", ".join(name for name, _ in outputs)
|
||||
)
|
||||
|
||||
# Handle the "already installed" case up-front for the shared profile.
|
||||
self._ensure_old_profile_removed(ctx) # type: ignore[arg-type]
|
||||
|
||||
for output, allow_failure in outputs:
|
||||
cmd = f"nix profile install {ctx.repo_dir}#{output}"
|
||||
|
||||
try:
|
||||
run_command(
|
||||
cmd,
|
||||
cwd=ctx.repo_dir,
|
||||
preview=ctx.preview,
|
||||
allow_failure=allow_failure,
|
||||
)
|
||||
print(f"Nix flake output '{output}' successfully installed.")
|
||||
except SystemExit as e:
|
||||
print(f"[Error] Failed to install Nix flake output '{output}': {e}")
|
||||
if not allow_failure:
|
||||
# Mandatory output failed → fatal for the pipeline.
|
||||
raise
|
||||
# Optional output failed → log and continue.
|
||||
print(
|
||||
"[Warning] Continuing despite failure to install "
|
||||
f"optional output '{output}'."
|
||||
)
|
||||
@@ -3,8 +3,8 @@
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
|
||||
@@ -17,11 +17,10 @@ apt/dpkg tooling are available.
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from typing import List
|
||||
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
|
||||
@@ -68,6 +67,32 @@ class DebianControlInstaller(BaseInstaller):
|
||||
pattern = os.path.join(parent, "*.deb")
|
||||
return sorted(glob.glob(pattern))
|
||||
|
||||
def _privileged_prefix(self) -> str | None:
|
||||
"""
|
||||
Determine how to run privileged commands:
|
||||
|
||||
- If 'sudo' is available, return 'sudo '.
|
||||
- If we are running as root (e.g. inside CI/container), return ''.
|
||||
- Otherwise, return None, meaning we cannot safely elevate.
|
||||
|
||||
Callers are responsible for handling the None case (usually by
|
||||
warning and skipping automatic installation).
|
||||
"""
|
||||
sudo_path = shutil.which("sudo")
|
||||
|
||||
is_root = False
|
||||
try:
|
||||
is_root = os.geteuid() == 0
|
||||
except AttributeError: # pragma: no cover - non-POSIX platforms
|
||||
# On non-POSIX systems, fall back to assuming "not root".
|
||||
is_root = False
|
||||
|
||||
if sudo_path is not None:
|
||||
return "sudo "
|
||||
if is_root:
|
||||
return ""
|
||||
return None
|
||||
|
||||
def _install_build_dependencies(self, ctx: RepoContext) -> None:
|
||||
"""
|
||||
Install build dependencies using `apt-get build-dep ./`.
|
||||
@@ -86,12 +111,25 @@ class DebianControlInstaller(BaseInstaller):
|
||||
)
|
||||
return
|
||||
|
||||
prefix = self._privileged_prefix()
|
||||
if prefix is None:
|
||||
print(
|
||||
"[Warning] Neither 'sudo' is available nor running as root. "
|
||||
"Skipping automatic build-dep installation for Debian. "
|
||||
"Please install build dependencies from debian/control manually."
|
||||
)
|
||||
return
|
||||
|
||||
# Update package lists first for reliable build-dep resolution.
|
||||
run_command("sudo apt-get update", cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
run_command(
|
||||
f"{prefix}apt-get update",
|
||||
cwd=ctx.repo_dir,
|
||||
preview=ctx.preview,
|
||||
)
|
||||
|
||||
# Install build dependencies based on debian/control in the current tree.
|
||||
# `apt-get build-dep ./` uses the source in the current directory.
|
||||
builddep_cmd = "sudo apt-get build-dep -y ./"
|
||||
builddep_cmd = f"{prefix}apt-get build-dep -y ./"
|
||||
run_command(builddep_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
|
||||
def run(self, ctx: RepoContext) -> None:
|
||||
@@ -101,7 +139,7 @@ class DebianControlInstaller(BaseInstaller):
|
||||
Steps:
|
||||
1. apt-get build-dep ./ (automatic build dependency installation)
|
||||
2. dpkg-buildpackage -b -us -uc
|
||||
3. sudo dpkg -i ../*.deb
|
||||
3. sudo dpkg -i ../*.deb (or plain dpkg -i when running as root)
|
||||
"""
|
||||
control_path = self._control_path(ctx)
|
||||
if not os.path.exists(control_path):
|
||||
@@ -123,7 +161,17 @@ class DebianControlInstaller(BaseInstaller):
|
||||
)
|
||||
return
|
||||
|
||||
prefix = self._privileged_prefix()
|
||||
if prefix is None:
|
||||
print(
|
||||
"[Warning] Neither 'sudo' is available nor running as root. "
|
||||
"Skipping automatic .deb installation. "
|
||||
"You can manually install the following files with dpkg -i:\n "
|
||||
+ "\n ".join(debs)
|
||||
)
|
||||
return
|
||||
|
||||
# 4) Install .deb files
|
||||
install_cmd = "sudo dpkg -i " + " ".join(os.path.basename(d) for d in debs)
|
||||
install_cmd = prefix + "dpkg -i " + " ".join(os.path.basename(d) for d in debs)
|
||||
parent = os.path.dirname(ctx.repo_dir)
|
||||
run_command(install_cmd, cwd=parent, preview=ctx.preview)
|
||||
282
pkgmgr/actions/install/installers/os_packages/rpm_spec.py
Normal file
282
pkgmgr/actions/install/installers/os_packages/rpm_spec.py
Normal file
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer for RPM-based packages defined in *.spec files.
|
||||
|
||||
This installer:
|
||||
|
||||
1. Installs build dependencies via dnf/yum builddep (where available)
|
||||
2. Prepares a source tarball in ~/rpmbuild/SOURCES based on the .spec
|
||||
3. Uses rpmbuild to build RPMs from the provided .spec file
|
||||
4. Installs the resulting RPMs via the system package manager (dnf/yum)
|
||||
or rpm as a fallback.
|
||||
|
||||
It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.).
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
|
||||
class RpmSpecInstaller(BaseInstaller):
|
||||
"""
|
||||
Build and install RPM-based packages from *.spec files.
|
||||
|
||||
This installer is responsible for the full build + install of the
|
||||
application on RPM-like systems.
|
||||
"""
|
||||
|
||||
# Logical layer name, used by capability matchers.
|
||||
layer = "os-packages"
|
||||
|
||||
def _is_rpm_like(self) -> bool:
|
||||
"""
|
||||
Basic RPM-like detection:
|
||||
|
||||
- rpmbuild must be available
|
||||
- at least one of dnf / yum / yum-builddep must be present
|
||||
"""
|
||||
if shutil.which("rpmbuild") is None:
|
||||
return False
|
||||
|
||||
has_dnf = shutil.which("dnf") is not None
|
||||
has_yum = shutil.which("yum") is not None
|
||||
has_yum_builddep = shutil.which("yum-builddep") is not None
|
||||
|
||||
return has_dnf or has_yum or has_yum_builddep
|
||||
|
||||
def _spec_path(self, ctx: RepoContext) -> Optional[str]:
|
||||
"""Return the first *.spec file in the repository root, if any."""
|
||||
pattern = os.path.join(ctx.repo_dir, "*.spec")
|
||||
matches = sorted(glob.glob(pattern))
|
||||
if not matches:
|
||||
return None
|
||||
return matches[0]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers for preparing rpmbuild topdir and source tarball
|
||||
# ------------------------------------------------------------------
|
||||
def _rpmbuild_topdir(self) -> str:
|
||||
"""
|
||||
Return the rpmbuild topdir that rpmbuild will use by default.
|
||||
|
||||
By default this is: ~/rpmbuild
|
||||
|
||||
In the self-install tests, $HOME is set to /tmp/pkgmgr-self-install,
|
||||
so this becomes /tmp/pkgmgr-self-install/rpmbuild which matches the
|
||||
paths in the RPM build logs.
|
||||
"""
|
||||
home = os.path.expanduser("~")
|
||||
return os.path.join(home, "rpmbuild")
|
||||
|
||||
def _ensure_rpmbuild_tree(self, topdir: str) -> None:
|
||||
"""
|
||||
Ensure the standard rpmbuild directory tree exists:
|
||||
|
||||
<topdir>/
|
||||
BUILD/
|
||||
BUILDROOT/
|
||||
RPMS/
|
||||
SOURCES/
|
||||
SPECS/
|
||||
SRPMS/
|
||||
"""
|
||||
for sub in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"):
|
||||
os.makedirs(os.path.join(topdir, sub), exist_ok=True)
|
||||
|
||||
def _parse_name_version(self, spec_path: str) -> Optional[Tuple[str, str]]:
|
||||
"""
|
||||
Parse Name and Version from the given .spec file.
|
||||
|
||||
Returns (name, version) or None if either cannot be determined.
|
||||
"""
|
||||
name = None
|
||||
version = None
|
||||
|
||||
with open(spec_path, "r", encoding="utf-8") as f:
|
||||
for raw_line in f:
|
||||
line = raw_line.strip()
|
||||
# Ignore comments
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
lower = line.lower()
|
||||
if lower.startswith("name:"):
|
||||
# e.g. "Name: package-manager"
|
||||
parts = line.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
name = parts[1].strip()
|
||||
elif lower.startswith("version:"):
|
||||
# e.g. "Version: 0.7.7"
|
||||
parts = line.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
version = parts[1].strip()
|
||||
|
||||
if name and version:
|
||||
break
|
||||
|
||||
if not name or not version:
|
||||
print(
|
||||
"[Warning] Could not determine Name/Version from spec file "
|
||||
f"'{spec_path}'. Skipping RPM source tarball preparation."
|
||||
)
|
||||
return None
|
||||
|
||||
return name, version
|
||||
|
||||
def _prepare_source_tarball(self, ctx: RepoContext, spec_path: str) -> None:
|
||||
"""
|
||||
Prepare a source tarball in <HOME>/rpmbuild/SOURCES that matches
|
||||
the Name/Version in the .spec file.
|
||||
"""
|
||||
parsed = self._parse_name_version(spec_path)
|
||||
if parsed is None:
|
||||
return
|
||||
|
||||
name, version = parsed
|
||||
topdir = self._rpmbuild_topdir()
|
||||
self._ensure_rpmbuild_tree(topdir)
|
||||
|
||||
build_dir = os.path.join(topdir, "BUILD")
|
||||
sources_dir = os.path.join(topdir, "SOURCES")
|
||||
|
||||
source_root = os.path.join(build_dir, f"{name}-{version}")
|
||||
tarball_path = os.path.join(sources_dir, f"{name}-{version}.tar.gz")
|
||||
|
||||
# Clean any previous build directory for this name/version.
|
||||
if os.path.exists(source_root):
|
||||
shutil.rmtree(source_root)
|
||||
|
||||
# Copy the repository tree into BUILD/<name>-<version>.
|
||||
shutil.copytree(ctx.repo_dir, source_root)
|
||||
|
||||
# Create the tarball with the top-level directory <name>-<version>.
|
||||
if os.path.exists(tarball_path):
|
||||
os.remove(tarball_path)
|
||||
|
||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
||||
tar.add(source_root, arcname=f"{name}-{version}")
|
||||
|
||||
print(
|
||||
f"[INFO] Prepared RPM source tarball at '{tarball_path}' "
|
||||
f"from '{ctx.repo_dir}'."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def supports(self, ctx: RepoContext) -> bool:
|
||||
"""
|
||||
This installer is supported if:
|
||||
- we are on an RPM-based system (rpmbuild + dnf/yum/yum-builddep available), and
|
||||
- a *.spec file exists in the repository root.
|
||||
"""
|
||||
if not self._is_rpm_like():
|
||||
return False
|
||||
|
||||
return self._spec_path(ctx) is not None
|
||||
|
||||
def _find_built_rpms(self) -> List[str]:
|
||||
"""
|
||||
Find RPMs built by rpmbuild.
|
||||
|
||||
By default, rpmbuild outputs RPMs into:
|
||||
~/rpmbuild/RPMS/*/*.rpm
|
||||
"""
|
||||
topdir = self._rpmbuild_topdir()
|
||||
pattern = os.path.join(topdir, "RPMS", "**", "*.rpm")
|
||||
return sorted(glob.glob(pattern, recursive=True))
|
||||
|
||||
def _install_build_dependencies(self, ctx: RepoContext, spec_path: str) -> None:
|
||||
"""
|
||||
Install build dependencies for the given .spec file.
|
||||
"""
|
||||
spec_basename = os.path.basename(spec_path)
|
||||
|
||||
if shutil.which("dnf") is not None:
|
||||
cmd = f"sudo dnf builddep -y {spec_basename}"
|
||||
elif shutil.which("yum-builddep") is not None:
|
||||
cmd = f"sudo yum-builddep -y {spec_basename}"
|
||||
elif shutil.which("yum") is not None:
|
||||
cmd = f"sudo yum-builddep -y {spec_basename}"
|
||||
else:
|
||||
print(
|
||||
"[Warning] No suitable RPM builddep tool (dnf/yum-builddep/yum) found. "
|
||||
"Skipping automatic build dependency installation for RPM."
|
||||
)
|
||||
return
|
||||
|
||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
|
||||
def _install_built_rpms(self, ctx: RepoContext, rpms: List[str]) -> None:
|
||||
"""
|
||||
Install or upgrade the built RPMs.
|
||||
|
||||
Strategy:
|
||||
- Prefer dnf install -y <rpms> (handles upgrades cleanly)
|
||||
- Else yum install -y <rpms>
|
||||
- Else fallback to rpm -Uvh <rpms> (upgrade/replace existing)
|
||||
"""
|
||||
if not rpms:
|
||||
print(
|
||||
"[Warning] No RPM files found after rpmbuild. "
|
||||
"Skipping RPM package installation."
|
||||
)
|
||||
return
|
||||
|
||||
dnf = shutil.which("dnf")
|
||||
yum = shutil.which("yum")
|
||||
rpm = shutil.which("rpm")
|
||||
|
||||
if dnf is not None:
|
||||
install_cmd = "sudo dnf install -y " + " ".join(rpms)
|
||||
elif yum is not None:
|
||||
install_cmd = "sudo yum install -y " + " ".join(rpms)
|
||||
elif rpm is not None:
|
||||
# Fallback: use rpm in upgrade mode so an existing older
|
||||
# version is replaced instead of causing file conflicts.
|
||||
install_cmd = "sudo rpm -Uvh " + " ".join(rpms)
|
||||
else:
|
||||
print(
|
||||
"[Warning] No suitable RPM installer (dnf/yum/rpm) found. "
|
||||
"Cannot install built RPMs."
|
||||
)
|
||||
return
|
||||
|
||||
run_command(install_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
|
||||
def run(self, ctx: RepoContext) -> None:
|
||||
"""
|
||||
Build and install RPM-based packages.
|
||||
|
||||
Steps:
|
||||
1. Prepare source tarball in ~/rpmbuild/SOURCES matching Name/Version
|
||||
2. dnf/yum builddep <spec> (automatic build dependency installation)
|
||||
3. rpmbuild -ba path/to/spec
|
||||
4. Install built RPMs via dnf/yum (or rpm as fallback)
|
||||
"""
|
||||
spec_path = self._spec_path(ctx)
|
||||
if not spec_path:
|
||||
return
|
||||
|
||||
# 1) Prepare source tarball so rpmbuild finds Source0 in SOURCES.
|
||||
self._prepare_source_tarball(ctx, spec_path)
|
||||
|
||||
# 2) Install build dependencies
|
||||
self._install_build_dependencies(ctx, spec_path)
|
||||
|
||||
# 3) Build RPMs
|
||||
spec_basename = os.path.basename(spec_path)
|
||||
build_cmd = f"rpmbuild -ba {spec_basename}"
|
||||
run_command(build_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
|
||||
# 4) Find and install built RPMs
|
||||
rpms = self._find_built_rpms()
|
||||
self._install_built_rpms(ctx, rpms)
|
||||
139
pkgmgr/actions/install/installers/python.py
Normal file
139
pkgmgr/actions/install/installers/python.py
Normal file
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
PythonInstaller — install Python projects defined via pyproject.toml.
|
||||
|
||||
Installation rules:
|
||||
|
||||
1. pip command resolution:
|
||||
a) If PKGMGR_PIP is set → use it exactly as provided.
|
||||
b) Else if running inside a virtualenv → use `sys.executable -m pip`.
|
||||
c) Else → create/use a per-repository virtualenv under ~/.venvs/<repo>/.
|
||||
|
||||
2. Installation target:
|
||||
- Always install into the resolved pip environment.
|
||||
- Never modify system Python, never rely on --user.
|
||||
- Nix-immutable systems (PEP 668) are automatically avoided because we
|
||||
never touch system Python.
|
||||
|
||||
3. The installer is skipped when:
|
||||
- PKGMGR_DISABLE_PYTHON_INSTALLER=1 is set.
|
||||
- The repository has no pyproject.toml.
|
||||
|
||||
All pip failures are treated as fatal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install import InstallContext
|
||||
|
||||
|
||||
class PythonInstaller(BaseInstaller):
|
||||
"""Install Python projects and dependencies via pip using isolated environments."""
|
||||
|
||||
layer = "python"
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Installer activation logic
|
||||
# ----------------------------------------------------------------------
|
||||
def supports(self, ctx: "RepoContext") -> bool:
|
||||
"""
|
||||
Return True if this installer should handle this repository.
|
||||
|
||||
The installer is active only when:
|
||||
- A pyproject.toml exists in the repo, and
|
||||
- PKGMGR_DISABLE_PYTHON_INSTALLER is not set.
|
||||
"""
|
||||
if os.environ.get("PKGMGR_DISABLE_PYTHON_INSTALLER") == "1":
|
||||
print("[INFO] PythonInstaller disabled via PKGMGR_DISABLE_PYTHON_INSTALLER.")
|
||||
return False
|
||||
|
||||
return os.path.exists(os.path.join(ctx.repo_dir, "pyproject.toml"))
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Virtualenv handling
|
||||
# ----------------------------------------------------------------------
|
||||
def _in_virtualenv(self) -> bool:
|
||||
"""Detect whether the current interpreter is inside a venv."""
|
||||
if os.environ.get("VIRTUAL_ENV"):
|
||||
return True
|
||||
|
||||
base = getattr(sys, "base_prefix", sys.prefix)
|
||||
return sys.prefix != base
|
||||
|
||||
def _ensure_repo_venv(self, ctx: "InstallContext") -> str:
|
||||
"""
|
||||
Ensure that ~/.venvs/<identifier>/ exists and contains a minimal venv.
|
||||
|
||||
Returns the venv directory path.
|
||||
"""
|
||||
venv_dir = os.path.expanduser(f"~/.venvs/{ctx.identifier}")
|
||||
python = sys.executable
|
||||
|
||||
if not os.path.isdir(venv_dir):
|
||||
print(f"[python-installer] Creating virtualenv: {venv_dir}")
|
||||
subprocess.check_call([python, "-m", "venv", venv_dir])
|
||||
|
||||
return venv_dir
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# pip command resolution
|
||||
# ----------------------------------------------------------------------
|
||||
def _pip_cmd(self, ctx: "InstallContext") -> str:
|
||||
"""
|
||||
Determine which pip command to use.
|
||||
|
||||
Priority:
|
||||
1. PKGMGR_PIP override given by user or automation.
|
||||
2. Active virtualenv → use sys.executable -m pip.
|
||||
3. Per-repository venv → ~/.venvs/<repo>/bin/pip
|
||||
"""
|
||||
explicit = os.environ.get("PKGMGR_PIP", "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
if self._in_virtualenv():
|
||||
return f"{sys.executable} -m pip"
|
||||
|
||||
venv_dir = self._ensure_repo_venv(ctx)
|
||||
pip_path = os.path.join(venv_dir, "bin", "pip")
|
||||
return pip_path
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Execution
|
||||
# ----------------------------------------------------------------------
|
||||
def run(self, ctx: "InstallContext") -> None:
|
||||
"""
|
||||
Install the project defined by pyproject.toml.
|
||||
|
||||
Uses the resolved pip environment. Installation is isolated and never
|
||||
touches system Python.
|
||||
"""
|
||||
if not self.supports(ctx): # type: ignore[arg-type]
|
||||
return
|
||||
|
||||
pyproject = os.path.join(ctx.repo_dir, "pyproject.toml")
|
||||
if not os.path.exists(pyproject):
|
||||
return
|
||||
|
||||
print(f"[python-installer] Installing Python project for {ctx.identifier}...")
|
||||
|
||||
pip_cmd = self._pip_cmd(ctx)
|
||||
|
||||
# Final install command: ALWAYS isolated, never system-wide.
|
||||
install_cmd = f"{pip_cmd} install ."
|
||||
|
||||
run_command(install_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
|
||||
print(f"[python-installer] Installation finished for {ctx.identifier}.")
|
||||
91
pkgmgr/actions/install/layers.py
Normal file
91
pkgmgr/actions/install/layers.py
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
CLI layer model for the pkgmgr installation pipeline.
|
||||
|
||||
We treat CLI entry points as coming from one of four conceptual layers:
|
||||
|
||||
- os-packages : system package managers (pacman/apt/dnf/…)
|
||||
- nix : Nix flake / nix profile
|
||||
- python : pip / virtualenv / user-local scripts
|
||||
- makefile : repo-local Makefile / scripts inside the repo
|
||||
|
||||
The layer order defines precedence: higher layers "own" the CLI and
|
||||
lower layers will not be executed once a higher-priority CLI exists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class CliLayer(str, Enum):
|
||||
OS_PACKAGES = "os-packages"
|
||||
NIX = "nix"
|
||||
PYTHON = "python"
|
||||
MAKEFILE = "makefile"
|
||||
|
||||
|
||||
# Highest priority first
|
||||
CLI_LAYERS: list[CliLayer] = [
|
||||
CliLayer.OS_PACKAGES,
|
||||
CliLayer.NIX,
|
||||
CliLayer.PYTHON,
|
||||
CliLayer.MAKEFILE,
|
||||
]
|
||||
|
||||
|
||||
def layer_priority(layer: Optional[CliLayer]) -> int:
|
||||
"""
|
||||
Return a numeric priority index for a given layer.
|
||||
|
||||
Lower index → higher priority.
|
||||
Unknown / None → very low priority.
|
||||
"""
|
||||
if layer is None:
|
||||
return len(CLI_LAYERS)
|
||||
try:
|
||||
return CLI_LAYERS.index(layer)
|
||||
except ValueError:
|
||||
return len(CLI_LAYERS)
|
||||
|
||||
|
||||
def classify_command_layer(command: str, repo_dir: str) -> CliLayer:
|
||||
"""
|
||||
Heuristically classify a resolved command path into a CLI layer.
|
||||
|
||||
Rules (best effort):
|
||||
|
||||
- /usr/... or /bin/... → os-packages
|
||||
- /nix/store/... or ~/.nix-profile → nix
|
||||
- ~/.local/bin/... → python
|
||||
- inside repo_dir → makefile
|
||||
- everything else → python (user/venv scripts, etc.)
|
||||
"""
|
||||
command_abs = os.path.abspath(os.path.expanduser(command))
|
||||
repo_abs = os.path.abspath(repo_dir)
|
||||
home = os.path.expanduser("~")
|
||||
|
||||
# OS package managers
|
||||
if command_abs.startswith("/usr/") or command_abs.startswith("/bin/"):
|
||||
return CliLayer.OS_PACKAGES
|
||||
|
||||
# Nix store / profile
|
||||
if command_abs.startswith("/nix/store/") or command_abs.startswith(
|
||||
os.path.join(home, ".nix-profile")
|
||||
):
|
||||
return CliLayer.NIX
|
||||
|
||||
# User-local bin
|
||||
if command_abs.startswith(os.path.join(home, ".local", "bin")):
|
||||
return CliLayer.PYTHON
|
||||
|
||||
# Inside the repository → usually a Makefile/script
|
||||
if command_abs.startswith(repo_abs):
|
||||
return CliLayer.MAKEFILE
|
||||
|
||||
# Fallback: treat as Python-style/user-level script
|
||||
return CliLayer.PYTHON
|
||||
257
pkgmgr/actions/install/pipeline.py
Normal file
257
pkgmgr/actions/install/pipeline.py
Normal file
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installation pipeline orchestration for repositories.
|
||||
|
||||
This module implements the "Setup Controller" logic:
|
||||
|
||||
1. Detect current CLI command for the repo (if any).
|
||||
2. Classify it into a layer (os-packages, nix, python, makefile).
|
||||
3. Iterate over installers in layer order:
|
||||
- Skip installers whose layer is weaker than an already-loaded one.
|
||||
- Run only installers that support() the repo and add new capabilities.
|
||||
- After each installer, re-resolve the command and update the layer.
|
||||
4. Maintain the repo["command"] field and create/update symlinks via create_ink().
|
||||
|
||||
The goal is to prevent conflicting installations and make the layering
|
||||
behaviour explicit and testable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Sequence, Set
|
||||
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||
from pkgmgr.actions.install.layers import (
|
||||
CliLayer,
|
||||
classify_command_layer,
|
||||
layer_priority,
|
||||
)
|
||||
from pkgmgr.core.command.ink import create_ink
|
||||
from pkgmgr.core.command.resolve import resolve_command_for_repo
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandState:
|
||||
"""
|
||||
Represents the current CLI state for a repository:
|
||||
|
||||
- command: absolute or relative path to the CLI entry point
|
||||
- layer: which conceptual layer this command belongs to
|
||||
"""
|
||||
|
||||
command: Optional[str]
|
||||
layer: Optional[CliLayer]
|
||||
|
||||
|
||||
class CommandResolver:
|
||||
"""
|
||||
Small helper responsible for resolving the current command for a repo
|
||||
and mapping it into a CommandState.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: RepoContext) -> None:
|
||||
self._ctx = ctx
|
||||
|
||||
def resolve(self) -> CommandState:
|
||||
"""
|
||||
Resolve the current command for this repository.
|
||||
|
||||
If resolve_command_for_repo raises SystemExit (e.g. Python package
|
||||
without installed entry point), we treat this as "no command yet"
|
||||
from the point of view of the installers.
|
||||
"""
|
||||
repo = self._ctx.repo
|
||||
identifier = self._ctx.identifier
|
||||
repo_dir = self._ctx.repo_dir
|
||||
|
||||
try:
|
||||
cmd = resolve_command_for_repo(
|
||||
repo=repo,
|
||||
repo_identifier=identifier,
|
||||
repo_dir=repo_dir,
|
||||
)
|
||||
except SystemExit:
|
||||
cmd = None
|
||||
|
||||
if not cmd:
|
||||
return CommandState(command=None, layer=None)
|
||||
|
||||
layer = classify_command_layer(cmd, repo_dir)
|
||||
return CommandState(command=cmd, layer=layer)
|
||||
|
||||
|
||||
class InstallationPipeline:
|
||||
"""
|
||||
High-level orchestrator that applies a sequence of installers
|
||||
to a repository based on CLI layer precedence.
|
||||
"""
|
||||
|
||||
def __init__(self, installers: Sequence[BaseInstaller]) -> None:
|
||||
self._installers = list(installers)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
def run(self, ctx: RepoContext) -> None:
|
||||
"""
|
||||
Execute the installation pipeline for a single repository.
|
||||
|
||||
- Detect initial command & layer.
|
||||
- Optionally create a symlink.
|
||||
- Run installers in order, skipping those whose layer is weaker
|
||||
than an already-loaded CLI.
|
||||
- After each installer, re-resolve the command and refresh the
|
||||
symlink if needed.
|
||||
"""
|
||||
repo = ctx.repo
|
||||
repo_dir = ctx.repo_dir
|
||||
identifier = ctx.identifier
|
||||
repositories_base_dir = ctx.repositories_base_dir
|
||||
bin_dir = ctx.bin_dir
|
||||
all_repos = ctx.all_repos
|
||||
quiet = ctx.quiet
|
||||
preview = ctx.preview
|
||||
|
||||
resolver = CommandResolver(ctx)
|
||||
state = resolver.resolve()
|
||||
|
||||
# Persist initial command (if any) and create a symlink.
|
||||
if state.command:
|
||||
repo["command"] = state.command
|
||||
create_ink(
|
||||
repo,
|
||||
repositories_base_dir,
|
||||
bin_dir,
|
||||
all_repos,
|
||||
quiet=quiet,
|
||||
preview=preview,
|
||||
)
|
||||
else:
|
||||
repo.pop("command", None)
|
||||
|
||||
provided_capabilities: Set[str] = set()
|
||||
|
||||
# Main installer loop
|
||||
for installer in self._installers:
|
||||
layer_name = getattr(installer, "layer", None)
|
||||
|
||||
# Installers without a layer participate without precedence logic.
|
||||
if layer_name is None:
|
||||
self._run_installer(installer, ctx, identifier, repo_dir, quiet)
|
||||
continue
|
||||
|
||||
try:
|
||||
installer_layer = CliLayer(layer_name)
|
||||
except ValueError:
|
||||
# Unknown layer string → treat as lowest priority.
|
||||
installer_layer = None
|
||||
|
||||
# "Previous/Current layer already loaded?"
|
||||
if state.layer is not None and installer_layer is not None:
|
||||
current_prio = layer_priority(state.layer)
|
||||
installer_prio = layer_priority(installer_layer)
|
||||
|
||||
if current_prio < installer_prio:
|
||||
# Current CLI comes from a higher-priority layer,
|
||||
# so we skip this installer entirely.
|
||||
if not quiet:
|
||||
print(
|
||||
f"[pkgmgr] Skipping installer "
|
||||
f"{installer.__class__.__name__} for {identifier} – "
|
||||
f"CLI already provided by layer {state.layer.value!r}."
|
||||
)
|
||||
continue
|
||||
|
||||
if current_prio == installer_prio:
|
||||
# Same layer already provides a CLI; usually there is no
|
||||
# need to run another installer on top of it.
|
||||
if not quiet:
|
||||
print(
|
||||
f"[pkgmgr] Skipping installer "
|
||||
f"{installer.__class__.__name__} for {identifier} – "
|
||||
f"layer {installer_layer.value!r} is already loaded."
|
||||
)
|
||||
continue
|
||||
|
||||
# Check if this installer is applicable at all.
|
||||
if not installer.supports(ctx):
|
||||
continue
|
||||
|
||||
# Capabilities: if everything this installer would provide is already
|
||||
# covered, we can safely skip it.
|
||||
caps = installer.discover_capabilities(ctx)
|
||||
if caps and caps.issubset(provided_capabilities):
|
||||
if not quiet:
|
||||
print(
|
||||
f"Skipping installer {installer.__class__.__name__} "
|
||||
f"for {identifier} – capabilities {caps} already provided."
|
||||
)
|
||||
continue
|
||||
|
||||
if not quiet:
|
||||
print(
|
||||
f"[pkgmgr] Running installer {installer.__class__.__name__} "
|
||||
f"for {identifier} in '{repo_dir}' "
|
||||
f"(new capabilities: {caps or set()})..."
|
||||
)
|
||||
|
||||
# Run the installer with error reporting.
|
||||
self._run_installer(installer, ctx, identifier, repo_dir, quiet)
|
||||
|
||||
provided_capabilities.update(caps)
|
||||
|
||||
# After running an installer, re-resolve the command and layer.
|
||||
new_state = resolver.resolve()
|
||||
if new_state.command:
|
||||
repo["command"] = new_state.command
|
||||
create_ink(
|
||||
repo,
|
||||
repositories_base_dir,
|
||||
bin_dir,
|
||||
all_repos,
|
||||
quiet=quiet,
|
||||
preview=preview,
|
||||
)
|
||||
else:
|
||||
repo.pop("command", None)
|
||||
|
||||
state = new_state
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _run_installer(
|
||||
installer: BaseInstaller,
|
||||
ctx: RepoContext,
|
||||
identifier: str,
|
||||
repo_dir: str,
|
||||
quiet: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Execute a single installer with unified error handling.
|
||||
"""
|
||||
try:
|
||||
installer.run(ctx)
|
||||
except SystemExit as exc:
|
||||
exit_code = exc.code if isinstance(exc.code, int) else str(exc.code)
|
||||
print(
|
||||
f"[ERROR] Installer {installer.__class__.__name__} failed "
|
||||
f"for repository {identifier} (dir: {repo_dir}) "
|
||||
f"with exit code {exit_code}."
|
||||
)
|
||||
print(
|
||||
"[ERROR] This usually means an underlying command failed "
|
||||
"(e.g. 'make install', 'nix build', 'pip install', ...)."
|
||||
)
|
||||
print(
|
||||
"[ERROR] Check the log above for the exact command output. "
|
||||
"You can also run this repository in isolation via:\n"
|
||||
f" pkgmgr install {identifier} "
|
||||
"--clone-mode shallow --no-verification"
|
||||
)
|
||||
raise
|
||||
@@ -1,761 +0,0 @@
|
||||
# pkgmgr/release.py
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
pkgmgr/release.py
|
||||
|
||||
Release helper for pkgmgr.
|
||||
|
||||
Responsibilities (Milestone 7):
|
||||
- Determine the next semantic version based on existing Git tags.
|
||||
- Update pyproject.toml with the new version.
|
||||
- Update additional packaging files (flake.nix, PKGBUILD,
|
||||
debian/changelog, RPM spec) where present.
|
||||
- Prepend a basic entry to CHANGELOG.md.
|
||||
- Commit, tag, and push the release on the current branch.
|
||||
|
||||
Additional behaviour:
|
||||
- If `preview=True` (from --preview), no files are written and no
|
||||
Git commands are executed. Instead, a detailed summary of the
|
||||
planned changes and commands is printed.
|
||||
- If `preview=False` and not forced, the release is executed in two
|
||||
phases:
|
||||
1) Preview-only run (dry-run).
|
||||
2) Interactive confirmation, then real release if confirmed.
|
||||
This confirmation can be skipped with the `force=True` flag.
|
||||
- If `close=True` is used and the current branch is not main/master,
|
||||
the branch will be closed via branch_commands.close_branch() after
|
||||
a successful release.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import date, datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from pkgmgr.core.git import get_tags, get_current_branch, GitError
|
||||
from pkgmgr.actions.branch import close_branch
|
||||
from pkgmgr.core.version.semver import (
|
||||
SemVer,
|
||||
find_latest_version,
|
||||
bump_major,
|
||||
bump_minor,
|
||||
bump_patch,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers for Git + version discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _determine_current_version() -> SemVer:
|
||||
"""
|
||||
Determine the current semantic version from Git tags.
|
||||
|
||||
Behaviour:
|
||||
- If there are no tags or no SemVer-compatible tags, return 0.0.0.
|
||||
- Otherwise, use the latest SemVer tag as current version.
|
||||
"""
|
||||
tags = get_tags()
|
||||
if not tags:
|
||||
return SemVer(0, 0, 0)
|
||||
|
||||
latest = find_latest_version(tags)
|
||||
if latest is None:
|
||||
return SemVer(0, 0, 0)
|
||||
|
||||
_tag, ver = latest
|
||||
return ver
|
||||
|
||||
|
||||
def _bump_semver(current: SemVer, release_type: str) -> SemVer:
|
||||
"""
|
||||
Bump the given SemVer according to the release type.
|
||||
|
||||
release_type must be one of: "major", "minor", "patch".
|
||||
"""
|
||||
if release_type == "major":
|
||||
return bump_major(current)
|
||||
if release_type == "minor":
|
||||
return bump_minor(current)
|
||||
if release_type == "patch":
|
||||
return bump_patch(current)
|
||||
|
||||
raise ValueError(f"Unknown release type: {release_type!r}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Low-level Git command helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_git_command(cmd: str) -> None:
|
||||
"""
|
||||
Run a Git (or shell) command with basic error reporting.
|
||||
|
||||
The command is executed via the shell, primarily for readability
|
||||
when printed (as in 'git commit -am "msg"').
|
||||
"""
|
||||
print(f"[GIT] {cmd}")
|
||||
try:
|
||||
subprocess.run(cmd, shell=True, check=True)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f"[ERROR] Git command failed: {cmd}")
|
||||
print(f" Exit code: {exc.returncode}")
|
||||
if exc.stdout:
|
||||
print("--- stdout ---")
|
||||
print(exc.stdout)
|
||||
if exc.stderr:
|
||||
print("--- stderr ---")
|
||||
print(exc.stderr)
|
||||
raise GitError(f"Git command failed: {cmd}") from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Editor helper for interactive changelog messages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
|
||||
"""
|
||||
Open $EDITOR (fallback 'nano') so the user can enter a changelog message.
|
||||
|
||||
The temporary file is pre-filled with commented instructions and an
|
||||
optional initial_message. Lines starting with '#' are ignored when the
|
||||
message is read back.
|
||||
|
||||
Returns the final message (may be empty string if user leaves it blank).
|
||||
"""
|
||||
editor = os.environ.get("EDITOR", "nano")
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w+",
|
||||
delete=False,
|
||||
encoding="utf-8",
|
||||
) as tmp:
|
||||
tmp_path = tmp.name
|
||||
tmp.write(
|
||||
"# Write the changelog entry for this release.\n"
|
||||
"# Lines starting with '#' will be ignored.\n"
|
||||
"# Empty result will fall back to a generic message.\n\n"
|
||||
)
|
||||
if initial_message:
|
||||
tmp.write(initial_message.strip() + "\n")
|
||||
tmp.flush()
|
||||
|
||||
try:
|
||||
subprocess.call([editor, tmp_path])
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
f"[WARN] Editor {editor!r} not found; proceeding without "
|
||||
"interactive changelog message."
|
||||
)
|
||||
|
||||
try:
|
||||
with open(tmp_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
finally:
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
lines = [
|
||||
line for line in content.splitlines()
|
||||
if not line.strip().startswith("#")
|
||||
]
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File update helpers (pyproject + extra packaging + changelog)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def update_pyproject_version(
|
||||
pyproject_path: str,
|
||||
new_version: str,
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Update the version in pyproject.toml with the new version.
|
||||
|
||||
The function looks for a line matching:
|
||||
|
||||
version = "X.Y.Z"
|
||||
|
||||
and replaces the version part with the given new_version string.
|
||||
"""
|
||||
try:
|
||||
with open(pyproject_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except FileNotFoundError:
|
||||
print(f"[ERROR] pyproject.toml not found at: {pyproject_path}")
|
||||
sys.exit(1)
|
||||
|
||||
pattern = r'^(version\s*=\s*")([^"]+)(")'
|
||||
new_content, count = re.subn(
|
||||
pattern,
|
||||
lambda m: f'{m.group(1)}{new_version}{m.group(3)}',
|
||||
content,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
if count == 0:
|
||||
print("[ERROR] Could not find version line in pyproject.toml")
|
||||
sys.exit(1)
|
||||
|
||||
if preview:
|
||||
print(f"[PREVIEW] Would update pyproject.toml version to {new_version}")
|
||||
return
|
||||
|
||||
with open(pyproject_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
|
||||
print(f"Updated pyproject.toml version to {new_version}")
|
||||
|
||||
|
||||
def update_flake_version(
|
||||
flake_path: str,
|
||||
new_version: str,
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Update the version in flake.nix, if present.
|
||||
"""
|
||||
if not os.path.exists(flake_path):
|
||||
print("[INFO] flake.nix not found, skipping.")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(flake_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except Exception as exc:
|
||||
print(f"[WARN] Could not read flake.nix: {exc}")
|
||||
return
|
||||
|
||||
pattern = r'(version\s*=\s*")([^"]+)(")'
|
||||
new_content, count = re.subn(
|
||||
pattern,
|
||||
lambda m: f'{m.group(1)}{new_version}{m.group(3)}',
|
||||
content,
|
||||
)
|
||||
|
||||
if count == 0:
|
||||
print("[WARN] No version assignment found in flake.nix, skipping.")
|
||||
return
|
||||
|
||||
if preview:
|
||||
print(f"[PREVIEW] Would update flake.nix version to {new_version}")
|
||||
return
|
||||
|
||||
with open(flake_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
|
||||
print(f"Updated flake.nix version to {new_version}")
|
||||
|
||||
|
||||
def update_pkgbuild_version(
|
||||
pkgbuild_path: str,
|
||||
new_version: str,
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Update the version in PKGBUILD, if present.
|
||||
|
||||
Expects:
|
||||
pkgver=1.2.3
|
||||
pkgrel=1
|
||||
"""
|
||||
if not os.path.exists(pkgbuild_path):
|
||||
print("[INFO] PKGBUILD not found, skipping.")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(pkgbuild_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except Exception as exc:
|
||||
print(f"[WARN] Could not read PKGBUILD: {exc}")
|
||||
return
|
||||
|
||||
ver_pattern = r"^(pkgver\s*=\s*)(.+)$"
|
||||
new_content, ver_count = re.subn(
|
||||
ver_pattern,
|
||||
lambda m: f"{m.group(1)}{new_version}",
|
||||
content,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
if ver_count == 0:
|
||||
print("[WARN] No pkgver line found in PKGBUILD.")
|
||||
new_content = content
|
||||
|
||||
rel_pattern = r"^(pkgrel\s*=\s*)(.+)$"
|
||||
new_content, rel_count = re.subn(
|
||||
rel_pattern,
|
||||
lambda m: f"{m.group(1)}1",
|
||||
new_content,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
if rel_count == 0:
|
||||
print("[WARN] No pkgrel line found in PKGBUILD.")
|
||||
|
||||
if preview:
|
||||
print(f"[PREVIEW] Would update PKGBUILD to pkgver={new_version}, pkgrel=1")
|
||||
return
|
||||
|
||||
with open(pkgbuild_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
|
||||
print(f"Updated PKGBUILD to pkgver={new_version}, pkgrel=1")
|
||||
|
||||
|
||||
def update_spec_version(
|
||||
spec_path: str,
|
||||
new_version: str,
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Update the version in an RPM spec file, if present.
|
||||
"""
|
||||
if not os.path.exists(spec_path):
|
||||
print("[INFO] RPM spec file not found, skipping.")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(spec_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except Exception as exc:
|
||||
print(f"[WARN] Could not read spec file: {exc}")
|
||||
return
|
||||
|
||||
ver_pattern = r"^(Version:\s*)(.+)$"
|
||||
new_content, ver_count = re.subn(
|
||||
ver_pattern,
|
||||
lambda m: f"{m.group(1)}{new_version}",
|
||||
content,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
if ver_count == 0:
|
||||
print("[WARN] No 'Version:' line found in spec file.")
|
||||
|
||||
rel_pattern = r"^(Release:\s*)(.+)$"
|
||||
|
||||
def _release_repl(m: re.Match[str]) -> str: # type: ignore[name-defined]
|
||||
rest = m.group(2).strip()
|
||||
match = re.match(r"^(\d+)(.*)$", rest)
|
||||
if match:
|
||||
suffix = match.group(2)
|
||||
else:
|
||||
suffix = ""
|
||||
return f"{m.group(1)}1{suffix}"
|
||||
|
||||
new_content, rel_count = re.subn(
|
||||
rel_pattern,
|
||||
_release_repl,
|
||||
new_content,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
if rel_count == 0:
|
||||
print("[WARN] No 'Release:' line found in spec file.")
|
||||
|
||||
if preview:
|
||||
print(
|
||||
f"[PREVIEW] Would update spec file "
|
||||
f"{os.path.basename(spec_path)} to Version: {new_version}, Release: 1..."
|
||||
)
|
||||
return
|
||||
|
||||
with open(spec_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
|
||||
print(
|
||||
f"Updated spec file {os.path.basename(spec_path)} "
|
||||
f"to Version: {new_version}, Release: 1..."
|
||||
)
|
||||
|
||||
|
||||
def update_changelog(
|
||||
changelog_path: str,
|
||||
new_version: str,
|
||||
message: Optional[str] = None,
|
||||
preview: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Prepend a new release section to CHANGELOG.md with the new version,
|
||||
current date, and a message.
|
||||
"""
|
||||
today = date.today().isoformat()
|
||||
|
||||
if message is None:
|
||||
if preview:
|
||||
message = "Automated release."
|
||||
else:
|
||||
print(
|
||||
"\n[INFO] No release message provided, opening editor for "
|
||||
"changelog entry...\n"
|
||||
)
|
||||
editor_message = _open_editor_for_changelog()
|
||||
if not editor_message:
|
||||
message = "Automated release."
|
||||
else:
|
||||
message = editor_message
|
||||
|
||||
header = f"## [{new_version}] - {today}\n"
|
||||
header += f"\n* {message}\n\n"
|
||||
|
||||
if os.path.exists(changelog_path):
|
||||
try:
|
||||
with open(changelog_path, "r", encoding="utf-8") as f:
|
||||
changelog = f.read()
|
||||
except Exception as exc:
|
||||
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
|
||||
changelog = ""
|
||||
else:
|
||||
changelog = ""
|
||||
|
||||
new_changelog = header + "\n" + changelog if changelog else header
|
||||
|
||||
print("\n================ CHANGELOG ENTRY ================")
|
||||
print(header.rstrip())
|
||||
print("=================================================\n")
|
||||
|
||||
if preview:
|
||||
print(f"[PREVIEW] Would prepend new entry for {new_version} to CHANGELOG.md")
|
||||
return message
|
||||
|
||||
with open(changelog_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_changelog)
|
||||
|
||||
print(f"Updated CHANGELOG.md with version {new_version}")
|
||||
|
||||
return message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Debian changelog helpers (with Git config fallback for maintainer)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_git_config_value(key: str) -> Optional[str]:
|
||||
"""
|
||||
Try to read a value from `git config --get <key>`.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "config", "--get", key],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
value = result.stdout.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _get_debian_author() -> Tuple[str, str]:
|
||||
"""
|
||||
Determine the maintainer name/email for debian/changelog entries.
|
||||
"""
|
||||
name = os.environ.get("DEBFULLNAME")
|
||||
email = os.environ.get("DEBEMAIL")
|
||||
|
||||
if not name:
|
||||
name = os.environ.get("GIT_AUTHOR_NAME")
|
||||
if not email:
|
||||
email = os.environ.get("GIT_AUTHOR_EMAIL")
|
||||
|
||||
if not name:
|
||||
name = _get_git_config_value("user.name")
|
||||
if not email:
|
||||
email = _get_git_config_value("user.email")
|
||||
|
||||
if not name:
|
||||
name = "Unknown Maintainer"
|
||||
if not email:
|
||||
email = "unknown@example.com"
|
||||
|
||||
return name, email
|
||||
|
||||
|
||||
def update_debian_changelog(
|
||||
debian_changelog_path: str,
|
||||
package_name: str,
|
||||
new_version: str,
|
||||
message: Optional[str] = None,
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Prepend a new entry to debian/changelog, if it exists.
|
||||
"""
|
||||
if not os.path.exists(debian_changelog_path):
|
||||
print("[INFO] debian/changelog not found, skipping.")
|
||||
return
|
||||
|
||||
debian_version = f"{new_version}-1"
|
||||
now = datetime.now().astimezone()
|
||||
date_str = now.strftime("%a, %d %b %Y %H:%M:%S %z")
|
||||
|
||||
author_name, author_email = _get_debian_author()
|
||||
|
||||
first_line = f"{package_name} ({debian_version}) unstable; urgency=medium"
|
||||
body_line = message.strip() if message else f"Automated release {new_version}."
|
||||
stanza = (
|
||||
f"{first_line}\n\n"
|
||||
f" * {body_line}\n\n"
|
||||
f" -- {author_name} <{author_email}> {date_str}\n\n"
|
||||
)
|
||||
|
||||
if preview:
|
||||
print(
|
||||
"[PREVIEW] Would prepend the following stanza to debian/changelog:\n"
|
||||
f"{stanza}"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
with open(debian_changelog_path, "r", encoding="utf-8") as f:
|
||||
existing = f.read()
|
||||
except Exception as exc:
|
||||
print(f"[WARN] Could not read debian/changelog: {exc}")
|
||||
existing = ""
|
||||
|
||||
new_content = stanza + existing
|
||||
|
||||
with open(debian_changelog_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
|
||||
print(f"Updated debian/changelog with version {debian_version}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal implementation (single-phase, preview or real)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _release_impl(
|
||||
pyproject_path: str = "pyproject.toml",
|
||||
changelog_path: str = "CHANGELOG.md",
|
||||
release_type: str = "patch",
|
||||
message: Optional[str] = None,
|
||||
preview: bool = False,
|
||||
close: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Internal implementation that performs a single-phase release.
|
||||
"""
|
||||
current_ver = _determine_current_version()
|
||||
new_ver = _bump_semver(current_ver, release_type)
|
||||
new_ver_str = str(new_ver)
|
||||
new_tag = new_ver.to_tag(with_prefix=True)
|
||||
|
||||
mode = "PREVIEW" if preview else "REAL"
|
||||
print(f"Release mode: {mode}")
|
||||
print(f"Current version: {current_ver}")
|
||||
print(f"New version: {new_ver_str} ({release_type})")
|
||||
|
||||
repo_root = os.path.dirname(os.path.abspath(pyproject_path))
|
||||
|
||||
update_pyproject_version(pyproject_path, new_ver_str, preview=preview)
|
||||
changelog_message = update_changelog(
|
||||
changelog_path,
|
||||
new_ver_str,
|
||||
message=message,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
flake_path = os.path.join(repo_root, "flake.nix")
|
||||
update_flake_version(flake_path, new_ver_str, preview=preview)
|
||||
|
||||
pkgbuild_path = os.path.join(repo_root, "PKGBUILD")
|
||||
update_pkgbuild_version(pkgbuild_path, new_ver_str, preview=preview)
|
||||
|
||||
spec_path = os.path.join(repo_root, "package-manager.spec")
|
||||
update_spec_version(spec_path, new_ver_str, preview=preview)
|
||||
|
||||
effective_message: Optional[str] = message
|
||||
if effective_message is None and isinstance(changelog_message, str):
|
||||
if changelog_message.strip():
|
||||
effective_message = changelog_message.strip()
|
||||
|
||||
debian_changelog_path = os.path.join(repo_root, "debian", "changelog")
|
||||
package_name = os.path.basename(repo_root) or "package-manager"
|
||||
update_debian_changelog(
|
||||
debian_changelog_path,
|
||||
package_name=package_name,
|
||||
new_version=new_ver_str,
|
||||
message=effective_message,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
commit_msg = f"Release version {new_ver_str}"
|
||||
tag_msg = effective_message or commit_msg
|
||||
|
||||
try:
|
||||
branch = get_current_branch() or "main"
|
||||
except GitError:
|
||||
branch = "main"
|
||||
print(f"Releasing on branch: {branch}")
|
||||
|
||||
files_to_add = [
|
||||
pyproject_path,
|
||||
changelog_path,
|
||||
flake_path,
|
||||
pkgbuild_path,
|
||||
spec_path,
|
||||
debian_changelog_path,
|
||||
]
|
||||
existing_files = [p for p in files_to_add if p and os.path.exists(p)]
|
||||
|
||||
if preview:
|
||||
for path in existing_files:
|
||||
print(f"[PREVIEW] Would run: git add {path}")
|
||||
print(f'[PREVIEW] Would run: git commit -am "{commit_msg}"')
|
||||
print(f'[PREVIEW] Would run: git tag -a {new_tag} -m "{tag_msg}"')
|
||||
print(f"[PREVIEW] Would run: git push origin {branch}")
|
||||
print("[PREVIEW] Would run: git push origin --tags")
|
||||
|
||||
if close and branch not in ("main", "master"):
|
||||
print(
|
||||
f"[PREVIEW] Would also close branch {branch} after the release "
|
||||
"(close=True and branch is not main/master)."
|
||||
)
|
||||
elif close:
|
||||
print(
|
||||
f"[PREVIEW] close=True but current branch is {branch}; "
|
||||
"no branch would be closed."
|
||||
)
|
||||
|
||||
print("Preview completed. No changes were made.")
|
||||
return
|
||||
|
||||
for path in existing_files:
|
||||
_run_git_command(f"git add {path}")
|
||||
|
||||
_run_git_command(f'git commit -am "{commit_msg}"')
|
||||
_run_git_command(f'git tag -a {new_tag} -m "{tag_msg}"')
|
||||
_run_git_command(f"git push origin {branch}")
|
||||
_run_git_command("git push origin --tags")
|
||||
|
||||
print(f"Release {new_ver_str} completed.")
|
||||
|
||||
if close:
|
||||
if branch in ("main", "master"):
|
||||
print(
|
||||
f"[INFO] close=True but current branch is {branch}; "
|
||||
"nothing to close."
|
||||
)
|
||||
return
|
||||
|
||||
print(
|
||||
f"[INFO] Closing branch {branch} after successful release "
|
||||
"(close=True and branch is not main/master)..."
|
||||
)
|
||||
try:
|
||||
close_branch(name=branch, base_branch="main", cwd=".")
|
||||
except Exception as exc: # pragma: no cover
|
||||
print(f"[WARN] Failed to close branch {branch} automatically: {exc}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public release entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def release(
|
||||
pyproject_path: str = "pyproject.toml",
|
||||
changelog_path: str = "CHANGELOG.md",
|
||||
release_type: str = "patch",
|
||||
message: Optional[str] = None,
|
||||
preview: bool = False,
|
||||
force: bool = False,
|
||||
close: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
High-level release entry point.
|
||||
|
||||
Modes:
|
||||
|
||||
- preview=True:
|
||||
* Single-phase PREVIEW only.
|
||||
|
||||
- preview=False, force=True:
|
||||
* Single-phase REAL release, no interactive preview.
|
||||
|
||||
- preview=False, force=False:
|
||||
* Two-phase flow (intended default for interactive CLI use).
|
||||
"""
|
||||
if preview:
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=True,
|
||||
close=close,
|
||||
)
|
||||
return
|
||||
|
||||
if force:
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=False,
|
||||
close=close,
|
||||
)
|
||||
return
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=False,
|
||||
close=close,
|
||||
)
|
||||
return
|
||||
|
||||
print("[INFO] Running preview before actual release...\n")
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=True,
|
||||
close=close,
|
||||
)
|
||||
|
||||
try:
|
||||
answer = input("Proceed with the actual release? [y/N]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\n[INFO] Release aborted (no confirmation).")
|
||||
return
|
||||
|
||||
if answer not in ("y", "yes"):
|
||||
print("Release aborted by user. No changes were made.")
|
||||
return
|
||||
|
||||
print("\n[INFO] Running REAL release...\n")
|
||||
_release_impl(
|
||||
pyproject_path=pyproject_path,
|
||||
changelog_path=changelog_path,
|
||||
release_type=release_type,
|
||||
message=message,
|
||||
preview=False,
|
||||
close=close,
|
||||
)
|
||||
@@ -49,6 +49,7 @@ from .files import (
|
||||
update_spec_version,
|
||||
update_changelog,
|
||||
update_debian_changelog,
|
||||
update_spec_changelog,
|
||||
)
|
||||
|
||||
|
||||
@@ -98,6 +99,8 @@ def _release_impl(
|
||||
spec_path = os.path.join(repo_root, "package-manager.spec")
|
||||
update_spec_version(spec_path, new_ver_str, preview=preview)
|
||||
|
||||
# Determine a single effective_message to be reused across all
|
||||
# changelog targets (project, Debian, Fedora).
|
||||
effective_message: Optional[str] = message
|
||||
if effective_message is None and isinstance(changelog_message, str):
|
||||
if changelog_message.strip():
|
||||
@@ -105,6 +108,8 @@ def _release_impl(
|
||||
|
||||
debian_changelog_path = os.path.join(repo_root, "debian", "changelog")
|
||||
package_name = os.path.basename(repo_root) or "package-manager"
|
||||
|
||||
# Debian changelog
|
||||
update_debian_changelog(
|
||||
debian_changelog_path,
|
||||
package_name=package_name,
|
||||
@@ -113,6 +118,15 @@ def _release_impl(
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
# Fedora / RPM %changelog
|
||||
update_spec_changelog(
|
||||
spec_path=spec_path,
|
||||
package_name=package_name,
|
||||
new_version=new_ver_str,
|
||||
message=effective_message,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
commit_msg = f"Release version {new_ver_str}"
|
||||
tag_msg = effective_message or commit_msg
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ Responsibilities:
|
||||
- Update pyproject.toml with the new version.
|
||||
- Update flake.nix, PKGBUILD, RPM spec files where present.
|
||||
- Prepend release entries to CHANGELOG.md.
|
||||
- Maintain debian/changelog entries, including maintainer metadata.
|
||||
- Maintain distribution-specific changelog files:
|
||||
* debian/changelog
|
||||
* RPM spec %changelog section
|
||||
including maintainer metadata where applicable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -82,7 +85,6 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
|
||||
# File update helpers (pyproject + extra packaging + changelog)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def update_pyproject_version(
|
||||
pyproject_path: str,
|
||||
new_version: str,
|
||||
@@ -96,13 +98,25 @@ def update_pyproject_version(
|
||||
version = "X.Y.Z"
|
||||
|
||||
and replaces the version part with the given new_version string.
|
||||
|
||||
If the file does not exist, it is skipped without failing the release.
|
||||
"""
|
||||
if not os.path.exists(pyproject_path):
|
||||
print(
|
||||
f"[INFO] pyproject.toml not found at: {pyproject_path}, "
|
||||
"skipping version update."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
with open(pyproject_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except FileNotFoundError:
|
||||
print(f"[ERROR] pyproject.toml not found at: {pyproject_path}")
|
||||
sys.exit(1)
|
||||
except OSError as exc:
|
||||
print(
|
||||
f"[WARN] Could not read pyproject.toml at {pyproject_path}: {exc}. "
|
||||
"Skipping version update."
|
||||
)
|
||||
return
|
||||
|
||||
pattern = r'^(version\s*=\s*")([^"]+)(")'
|
||||
new_content, count = re.subn(
|
||||
@@ -442,3 +456,82 @@ def update_debian_changelog(
|
||||
f.write(new_content)
|
||||
|
||||
print(f"Updated debian/changelog with version {debian_version}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fedora / RPM spec %changelog helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def update_spec_changelog(
|
||||
spec_path: str,
|
||||
package_name: str,
|
||||
new_version: str,
|
||||
message: Optional[str] = None,
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Prepend a new entry to the %changelog section of an RPM spec file,
|
||||
if present.
|
||||
|
||||
Typical RPM-style entry:
|
||||
|
||||
* Tue Dec 09 2025 John Doe <john@example.com> - 0.5.1-1
|
||||
- Your changelog message
|
||||
"""
|
||||
if not os.path.exists(spec_path):
|
||||
print("[INFO] RPM spec file not found, skipping spec changelog update.")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(spec_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except Exception as exc:
|
||||
print(f"[WARN] Could not read spec file for changelog update: {exc}")
|
||||
return
|
||||
|
||||
debian_version = f"{new_version}-1"
|
||||
now = datetime.now().astimezone()
|
||||
date_str = now.strftime("%a %b %d %Y")
|
||||
|
||||
# Reuse Debian maintainer discovery for author name/email.
|
||||
author_name, author_email = _get_debian_author()
|
||||
|
||||
body_line = message.strip() if message else f"Automated release {new_version}."
|
||||
|
||||
stanza = (
|
||||
f"* {date_str} {author_name} <{author_email}> - {debian_version}\n"
|
||||
f"- {body_line}\n\n"
|
||||
)
|
||||
|
||||
marker = "%changelog"
|
||||
idx = content.find(marker)
|
||||
|
||||
if idx == -1:
|
||||
# No %changelog section yet: append one at the end.
|
||||
new_content = content.rstrip() + "\n\n%changelog\n" + stanza
|
||||
else:
|
||||
# Insert stanza right after the %changelog line.
|
||||
before = content[: idx + len(marker)]
|
||||
after = content[idx + len(marker) :]
|
||||
new_content = before + "\n" + stanza + after.lstrip("\n")
|
||||
|
||||
if preview:
|
||||
print(
|
||||
"[PREVIEW] Would update RPM %changelog section with the following "
|
||||
"stanza:\n"
|
||||
f"{stanza}"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
with open(spec_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
except Exception as exc:
|
||||
print(f"[WARN] Failed to write updated spec changelog section: {exc}")
|
||||
return
|
||||
|
||||
print(
|
||||
f"Updated RPM %changelog section in {os.path.basename(spec_path)} "
|
||||
f"for {package_name} {debian_version}"
|
||||
)
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Repository installation pipeline for pkgmgr.
|
||||
|
||||
This module orchestrates the installation of repositories by:
|
||||
|
||||
1. Ensuring the repository directory exists (cloning if necessary).
|
||||
2. Verifying the repository according to the configured policies.
|
||||
3. Creating executable links using create_ink(), after resolving the
|
||||
appropriate command via resolve_command_for_repo().
|
||||
4. Running a sequence of modular installer components that handle
|
||||
specific technologies or manifests (PKGBUILD, Nix flakes, Python
|
||||
via pyproject.toml, Makefile, OS-specific package metadata).
|
||||
|
||||
The goal is to keep this file thin and delegate most logic to small,
|
||||
focused installer classes.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.command.ink import create_ink
|
||||
from pkgmgr.core.repository.verify import verify_repository
|
||||
from pkgmgr.actions.repository.clone import clone_repos
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.core.command.resolve import resolve_command_for_repo
|
||||
|
||||
# Installer implementations
|
||||
from pkgmgr.actions.repository.install.installers.os_packages import (
|
||||
ArchPkgbuildInstaller,
|
||||
DebianControlInstaller,
|
||||
RpmSpecInstaller,
|
||||
)
|
||||
from pkgmgr.actions.repository.install.installers.nix_flake import NixFlakeInstaller
|
||||
from pkgmgr.actions.repository.install.installers.python import PythonInstaller
|
||||
from pkgmgr.actions.repository.install.installers.makefile import MakefileInstaller
|
||||
|
||||
|
||||
# Layering:
|
||||
# 1) OS packages: PKGBUILD / debian/control / RPM spec → os-deps.*
|
||||
# 2) Nix flakes (flake.nix) → e.g. python-runtime, make-install
|
||||
# 3) Python (pyproject.toml) → e.g. python-runtime, make-install
|
||||
# 4) Makefile fallback → e.g. make-install
|
||||
INSTALLERS = [
|
||||
ArchPkgbuildInstaller(), # Arch
|
||||
DebianControlInstaller(), # Debian/Ubuntu
|
||||
RpmSpecInstaller(), # Fedora/RHEL/CentOS
|
||||
NixFlakeInstaller(), # flake.nix (Nix layer)
|
||||
PythonInstaller(), # pyproject.toml
|
||||
MakefileInstaller(), # generic 'make install'
|
||||
]
|
||||
|
||||
|
||||
def _ensure_repo_dir(
|
||||
repo: Dict[str, Any],
|
||||
repositories_base_dir: str,
|
||||
all_repos: List[Dict[str, Any]],
|
||||
preview: bool,
|
||||
no_verification: bool,
|
||||
clone_mode: str,
|
||||
identifier: str,
|
||||
) -> str:
|
||||
"""
|
||||
Ensure the repository directory exists. If not, attempt to clone it.
|
||||
|
||||
Returns the repository directory path or an empty string if cloning failed.
|
||||
"""
|
||||
repo_dir = get_repo_dir(repositories_base_dir, repo)
|
||||
|
||||
if not os.path.exists(repo_dir):
|
||||
print(f"Repository directory '{repo_dir}' does not exist. Cloning it now...")
|
||||
clone_repos(
|
||||
[repo],
|
||||
repositories_base_dir,
|
||||
all_repos,
|
||||
preview,
|
||||
no_verification,
|
||||
clone_mode,
|
||||
)
|
||||
if not os.path.exists(repo_dir):
|
||||
print(f"Cloning failed for repository {identifier}. Skipping installation.")
|
||||
return ""
|
||||
|
||||
return repo_dir
|
||||
|
||||
|
||||
def _verify_repo(
|
||||
repo: Dict[str, Any],
|
||||
repo_dir: str,
|
||||
no_verification: bool,
|
||||
identifier: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Verify the repository using verify_repository().
|
||||
|
||||
Returns True if installation should proceed, False if it should be skipped.
|
||||
"""
|
||||
verified_info = repo.get("verified")
|
||||
verified_ok, errors, commit_hash, signing_key = verify_repository(
|
||||
repo,
|
||||
repo_dir,
|
||||
mode="local",
|
||||
no_verification=no_verification,
|
||||
)
|
||||
|
||||
if not no_verification and verified_info and not verified_ok:
|
||||
print(f"Warning: Verification failed for {identifier}:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
choice = input("Proceed with installation? (y/N): ").strip().lower()
|
||||
if choice != "y":
|
||||
print(f"Skipping installation for {identifier}.")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _create_context(
|
||||
repo: Dict[str, Any],
|
||||
identifier: str,
|
||||
repo_dir: str,
|
||||
repositories_base_dir: str,
|
||||
bin_dir: str,
|
||||
all_repos: List[Dict[str, Any]],
|
||||
no_verification: bool,
|
||||
preview: bool,
|
||||
quiet: bool,
|
||||
clone_mode: str,
|
||||
update_dependencies: bool,
|
||||
) -> RepoContext:
|
||||
"""
|
||||
Build a RepoContext for the given repository and parameters.
|
||||
"""
|
||||
return RepoContext(
|
||||
repo=repo,
|
||||
identifier=identifier,
|
||||
repo_dir=repo_dir,
|
||||
repositories_base_dir=repositories_base_dir,
|
||||
bin_dir=bin_dir,
|
||||
all_repos=all_repos,
|
||||
no_verification=no_verification,
|
||||
preview=preview,
|
||||
quiet=quiet,
|
||||
clone_mode=clone_mode,
|
||||
update_dependencies=update_dependencies,
|
||||
)
|
||||
|
||||
|
||||
def install_repos(
|
||||
selected_repos: List[Dict[str, Any]],
|
||||
repositories_base_dir: str,
|
||||
bin_dir: str,
|
||||
all_repos: List[Dict[str, Any]],
|
||||
no_verification: bool,
|
||||
preview: bool,
|
||||
quiet: bool,
|
||||
clone_mode: str,
|
||||
update_dependencies: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Install repositories by creating symbolic links and processing standard
|
||||
manifest files (PKGBUILD, flake.nix, Python manifests, Makefile, etc.)
|
||||
via dedicated installer components.
|
||||
|
||||
Any installer failure (SystemExit) is treated as fatal and will abort
|
||||
the current installation.
|
||||
"""
|
||||
for repo in selected_repos:
|
||||
identifier = get_repo_identifier(repo, all_repos)
|
||||
repo_dir = _ensure_repo_dir(
|
||||
repo=repo,
|
||||
repositories_base_dir=repositories_base_dir,
|
||||
all_repos=all_repos,
|
||||
preview=preview,
|
||||
no_verification=no_verification,
|
||||
clone_mode=clone_mode,
|
||||
identifier=identifier,
|
||||
)
|
||||
if not repo_dir:
|
||||
continue
|
||||
|
||||
if not _verify_repo(
|
||||
repo=repo,
|
||||
repo_dir=repo_dir,
|
||||
no_verification=no_verification,
|
||||
identifier=identifier,
|
||||
):
|
||||
continue
|
||||
|
||||
ctx = _create_context(
|
||||
repo=repo,
|
||||
identifier=identifier,
|
||||
repo_dir=repo_dir,
|
||||
repositories_base_dir=repositories_base_dir,
|
||||
bin_dir=bin_dir,
|
||||
all_repos=all_repos,
|
||||
no_verification=no_verification,
|
||||
preview=preview,
|
||||
quiet=quiet,
|
||||
clone_mode=clone_mode,
|
||||
update_dependencies=update_dependencies,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Resolve the command for this repository before creating the link.
|
||||
# If no command is resolved, no link will be created.
|
||||
# ------------------------------------------------------------
|
||||
resolved_command = resolve_command_for_repo(
|
||||
repo=repo,
|
||||
repo_identifier=identifier,
|
||||
repo_dir=repo_dir,
|
||||
)
|
||||
|
||||
if resolved_command:
|
||||
repo["command"] = resolved_command
|
||||
else:
|
||||
repo.pop("command", None)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Create the symlink using create_ink (if a command is set).
|
||||
# ------------------------------------------------------------
|
||||
create_ink(
|
||||
repo,
|
||||
repositories_base_dir,
|
||||
bin_dir,
|
||||
all_repos,
|
||||
quiet=quiet,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
# Track which logical capabilities have already been provided by
|
||||
# earlier installers for this repository. This allows us to skip
|
||||
# installers that would only duplicate work (e.g. Python runtime
|
||||
# already provided by Nix flake → skip pyproject/Makefile).
|
||||
provided_capabilities: set[str] = set()
|
||||
|
||||
# Run all installers that support this repository, but only if they
|
||||
# provide at least one capability that is not yet satisfied.
|
||||
for installer in INSTALLERS:
|
||||
if not installer.supports(ctx):
|
||||
continue
|
||||
|
||||
caps = installer.discover_capabilities(ctx)
|
||||
|
||||
# If the installer declares capabilities and *all* of them are
|
||||
# already provided, we can safely skip it.
|
||||
if caps and caps.issubset(provided_capabilities):
|
||||
if not quiet:
|
||||
print(
|
||||
f"Skipping installer {installer.__class__.__name__} "
|
||||
f"for {identifier} – capabilities {caps} already provided."
|
||||
)
|
||||
continue
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Debug output + clear error if an installer fails
|
||||
# ------------------------------------------------------------
|
||||
if not quiet:
|
||||
print(
|
||||
f"[pkgmgr] Running installer {installer.__class__.__name__} "
|
||||
f"for {identifier} in '{repo_dir}' "
|
||||
f"(new capabilities: {caps or '∅'})..."
|
||||
)
|
||||
|
||||
try:
|
||||
installer.run(ctx)
|
||||
except SystemExit as exc:
|
||||
exit_code = exc.code if isinstance(exc.code, int) else str(exc.code)
|
||||
|
||||
print(
|
||||
f"[ERROR] Installer {installer.__class__.__name__} failed "
|
||||
f"for repository {identifier} (dir: {repo_dir}) "
|
||||
f"with exit code {exit_code}."
|
||||
)
|
||||
print(
|
||||
"[ERROR] This usually means an underlying command failed "
|
||||
"(e.g. 'make install', 'nix build', 'pip install', ...)."
|
||||
)
|
||||
print(
|
||||
"[ERROR] Check the log above for the exact command output. "
|
||||
"You can also run this repository in isolation via:\n"
|
||||
f" pkgmgr install {identifier} --clone-mode shallow --no-verification"
|
||||
)
|
||||
|
||||
# Re-raise so that CLI/tests fail clearly,
|
||||
# but now with much more context.
|
||||
raise
|
||||
|
||||
# Only merge capabilities if the installer succeeded
|
||||
provided_capabilities.update(caps)
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer package for pkgmgr.
|
||||
|
||||
This exposes all installer classes so users can import them directly from
|
||||
pkgmgr.actions.repository.install.installers.
|
||||
"""
|
||||
|
||||
from pkgmgr.actions.repository.install.installers.base import BaseInstaller # noqa: F401
|
||||
from pkgmgr.actions.repository.install.installers.nix_flake import NixFlakeInstaller # noqa: F401
|
||||
from pkgmgr.actions.repository.install.installers.python import PythonInstaller # noqa: F401
|
||||
from pkgmgr.actions.repository.install.installers.makefile import MakefileInstaller # noqa: F401
|
||||
|
||||
# OS-specific installers
|
||||
from pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller # noqa: F401
|
||||
from pkgmgr.actions.repository.install.installers.os_packages.debian_control import DebianControlInstaller # noqa: F401
|
||||
from pkgmgr.actions.repository.install.installers.os_packages.rpm_spec import RpmSpecInstaller # noqa: F401
|
||||
@@ -1,93 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer that triggers `make install` if a Makefile is present and
|
||||
the Makefile actually defines an 'install' target.
|
||||
|
||||
This is useful for repositories that expose a standard Makefile-based
|
||||
installation step.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
|
||||
class MakefileInstaller(BaseInstaller):
|
||||
"""Run `make install` if a Makefile with an 'install' target exists."""
|
||||
|
||||
# Logical layer name, used by capability matchers.
|
||||
layer = "makefile"
|
||||
|
||||
MAKEFILE_NAME = "Makefile"
|
||||
|
||||
def supports(self, ctx: RepoContext) -> bool:
|
||||
"""Return True if a Makefile exists in the repository directory."""
|
||||
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
||||
return os.path.exists(makefile_path)
|
||||
|
||||
def _has_install_target(self, makefile_path: str) -> bool:
|
||||
"""
|
||||
Check whether the Makefile defines an 'install' target.
|
||||
|
||||
We treat the presence of a real install target as either:
|
||||
- a line starting with 'install:' (optionally preceded by whitespace), or
|
||||
- a .PHONY line that lists 'install' as one of the targets.
|
||||
"""
|
||||
try:
|
||||
with open(makefile_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
content = f.read()
|
||||
except OSError:
|
||||
# If we cannot read the Makefile for some reason, assume no target.
|
||||
return False
|
||||
|
||||
# install: ...
|
||||
if re.search(r"^\s*install\s*:", content, flags=re.MULTILINE):
|
||||
return True
|
||||
|
||||
# .PHONY: ... install ...
|
||||
if re.search(r"^\s*\.PHONY\s*:\s*.*\binstall\b", content, flags=re.MULTILINE):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def run(self, ctx: RepoContext) -> None:
|
||||
"""
|
||||
Execute `make install` in the repository directory, but only if an
|
||||
'install' target is actually defined in the Makefile.
|
||||
|
||||
Any failure in `make install` is treated as a fatal error and will
|
||||
propagate as SystemExit from run_command().
|
||||
"""
|
||||
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
||||
|
||||
if not os.path.exists(makefile_path):
|
||||
# Should normally not happen if supports() was checked before,
|
||||
# but keep this guard for robustness.
|
||||
if not ctx.quiet:
|
||||
print(
|
||||
f"[pkgmgr] Makefile '{makefile_path}' not found, "
|
||||
"skipping make install."
|
||||
)
|
||||
return
|
||||
|
||||
if not self._has_install_target(makefile_path):
|
||||
if not ctx.quiet:
|
||||
print(
|
||||
"[pkgmgr] Skipping Makefile install: no 'install' target "
|
||||
f"found in {makefile_path}."
|
||||
)
|
||||
return
|
||||
|
||||
if not ctx.quiet:
|
||||
print(
|
||||
f"[pkgmgr] Running 'make install' in {ctx.repo_dir} "
|
||||
"(install target detected in Makefile)."
|
||||
)
|
||||
|
||||
cmd = "make install"
|
||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
@@ -1,106 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer for Nix flakes.
|
||||
|
||||
If a repository contains flake.nix and the 'nix' command is available, this
|
||||
installer will try to install profile outputs from the flake.
|
||||
|
||||
Behavior:
|
||||
- If flake.nix is present and `nix` exists on PATH:
|
||||
* First remove any existing `package-manager` profile entry (best-effort).
|
||||
* Then install the flake outputs (`pkgmgr`, `default`) via `nix profile install`.
|
||||
- Failure installing `pkgmgr` is treated as fatal.
|
||||
- Failure installing `default` is logged as an error/warning but does not abort.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.repository.install import InstallContext
|
||||
|
||||
|
||||
class NixFlakeInstaller(BaseInstaller):
|
||||
"""Install Nix flake profiles for repositories that define flake.nix."""
|
||||
|
||||
# Logical layer name, used by capability matchers.
|
||||
layer = "nix"
|
||||
|
||||
FLAKE_FILE = "flake.nix"
|
||||
PROFILE_NAME = "package-manager"
|
||||
|
||||
def supports(self, ctx: "RepoContext") -> bool:
|
||||
"""
|
||||
Only support repositories that:
|
||||
- Have a flake.nix
|
||||
- And have the `nix` command available.
|
||||
"""
|
||||
if shutil.which("nix") is None:
|
||||
return False
|
||||
flake_path = os.path.join(ctx.repo_dir, self.FLAKE_FILE)
|
||||
return os.path.exists(flake_path)
|
||||
|
||||
def _ensure_old_profile_removed(self, ctx: "RepoContext") -> None:
|
||||
"""
|
||||
Best-effort removal of an existing profile entry.
|
||||
|
||||
This handles the "already provides the following file" conflict by
|
||||
removing previous `package-manager` installations before we install
|
||||
the new one.
|
||||
|
||||
Any error in `nix profile remove` is intentionally ignored, because
|
||||
a missing profile entry is not a fatal condition.
|
||||
"""
|
||||
if shutil.which("nix") is None:
|
||||
return
|
||||
|
||||
cmd = f"nix profile remove {self.PROFILE_NAME} || true"
|
||||
try:
|
||||
# NOTE: no allow_failure here → matches the existing unit tests
|
||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
except SystemExit:
|
||||
# Unit tests explicitly assert this is swallowed
|
||||
pass
|
||||
|
||||
def run(self, ctx: "InstallContext") -> None:
|
||||
"""
|
||||
Install Nix flake profile outputs (pkgmgr, default).
|
||||
|
||||
Any failure installing `pkgmgr` is treated as fatal (SystemExit).
|
||||
A failure installing `default` is logged but does not abort.
|
||||
"""
|
||||
# Reuse supports() to keep logic in one place
|
||||
if not self.supports(ctx): # type: ignore[arg-type]
|
||||
return
|
||||
|
||||
print("Nix flake detected, attempting to install profile outputs...")
|
||||
|
||||
# Handle the "already installed" case up-front:
|
||||
self._ensure_old_profile_removed(ctx) # type: ignore[arg-type]
|
||||
|
||||
for output in ("pkgmgr", "default"):
|
||||
cmd = f"nix profile install {ctx.repo_dir}#{output}"
|
||||
|
||||
try:
|
||||
# For 'default' we don't want the process to exit on error
|
||||
allow_failure = output == "default"
|
||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview, allow_failure=allow_failure)
|
||||
print(f"Nix flake output '{output}' successfully installed.")
|
||||
except SystemExit as e:
|
||||
print(f"[Error] Failed to install Nix flake output '{output}': {e}")
|
||||
if output == "pkgmgr":
|
||||
# Broken main CLI install → fatal
|
||||
raise
|
||||
# For 'default' we log and continue
|
||||
print(
|
||||
"[Warning] Continuing despite failure to install 'default' "
|
||||
"because 'pkgmgr' is already installed."
|
||||
)
|
||||
break
|
||||
@@ -1,160 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer for RPM-based packages defined in *.spec files.
|
||||
|
||||
This installer:
|
||||
|
||||
1. Installs build dependencies via dnf/yum builddep (where available)
|
||||
2. Uses rpmbuild to build RPMs from the provided .spec file
|
||||
3. Installs the resulting RPMs via `rpm -i`
|
||||
|
||||
It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.).
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
|
||||
class RpmSpecInstaller(BaseInstaller):
|
||||
"""
|
||||
Build and install RPM-based packages from *.spec files.
|
||||
|
||||
This installer is responsible for the full build + install of the
|
||||
application on RPM-like systems.
|
||||
"""
|
||||
|
||||
# Logical layer name, used by capability matchers.
|
||||
layer = "os-packages"
|
||||
|
||||
def _is_rpm_like(self) -> bool:
|
||||
"""
|
||||
Basic RPM-like detection:
|
||||
|
||||
- rpmbuild must be available
|
||||
- at least one of dnf / yum / yum-builddep must be present
|
||||
"""
|
||||
if shutil.which("rpmbuild") is None:
|
||||
return False
|
||||
|
||||
has_dnf = shutil.which("dnf") is not None
|
||||
has_yum = shutil.which("yum") is not None
|
||||
has_yum_builddep = shutil.which("yum-builddep") is not None
|
||||
|
||||
return has_dnf or has_yum or has_yum_builddep
|
||||
|
||||
def _spec_path(self, ctx: RepoContext) -> Optional[str]:
|
||||
"""Return the first *.spec file in the repository root, if any."""
|
||||
pattern = os.path.join(ctx.repo_dir, "*.spec")
|
||||
matches = sorted(glob.glob(pattern))
|
||||
if not matches:
|
||||
return None
|
||||
return matches[0]
|
||||
|
||||
def supports(self, ctx: RepoContext) -> bool:
|
||||
"""
|
||||
This installer is supported if:
|
||||
- we are on an RPM-based system (rpmbuild + dnf/yum/yum-builddep available), and
|
||||
- a *.spec file exists in the repository root.
|
||||
"""
|
||||
if not self._is_rpm_like():
|
||||
return False
|
||||
|
||||
return self._spec_path(ctx) is not None
|
||||
|
||||
def _find_built_rpms(self) -> List[str]:
|
||||
"""
|
||||
Find RPMs built by rpmbuild.
|
||||
|
||||
By default, rpmbuild outputs RPMs into:
|
||||
~/rpmbuild/RPMS/*/*.rpm
|
||||
"""
|
||||
home = os.path.expanduser("~")
|
||||
pattern = os.path.join(home, "rpmbuild", "RPMS", "**", "*.rpm")
|
||||
return sorted(glob.glob(pattern, recursive=True))
|
||||
|
||||
def _install_build_dependencies(self, ctx: RepoContext, spec_path: str) -> None:
|
||||
"""
|
||||
Install build dependencies for the given .spec file.
|
||||
|
||||
Strategy (best-effort):
|
||||
|
||||
1. If dnf is available:
|
||||
sudo dnf builddep -y <spec>
|
||||
2. Else if yum-builddep is available:
|
||||
sudo yum-builddep -y <spec>
|
||||
3. Else if yum is available:
|
||||
sudo yum-builddep -y <spec> # Some systems provide it via yum plugin
|
||||
4. Otherwise: print a warning and skip automatic builddep install.
|
||||
|
||||
Any failure in builddep installation is treated as fatal (SystemExit),
|
||||
consistent with other installer steps.
|
||||
"""
|
||||
spec_basename = os.path.basename(spec_path)
|
||||
|
||||
if shutil.which("dnf") is not None:
|
||||
cmd = f"sudo dnf builddep -y {spec_basename}"
|
||||
elif shutil.which("yum-builddep") is not None:
|
||||
cmd = f"sudo yum-builddep -y {spec_basename}"
|
||||
elif shutil.which("yum") is not None:
|
||||
# Some distributions ship yum-builddep as a plugin.
|
||||
cmd = f"sudo yum-builddep -y {spec_basename}"
|
||||
else:
|
||||
print(
|
||||
"[Warning] No suitable RPM builddep tool (dnf/yum-builddep/yum) found. "
|
||||
"Skipping automatic build dependency installation for RPM."
|
||||
)
|
||||
return
|
||||
|
||||
# Run builddep in the repository directory so relative spec paths work.
|
||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
|
||||
def run(self, ctx: RepoContext) -> None:
|
||||
"""
|
||||
Build and install RPM-based packages.
|
||||
|
||||
Steps:
|
||||
1. dnf/yum builddep <spec> (automatic build dependency installation)
|
||||
2. rpmbuild -ba path/to/spec
|
||||
3. sudo rpm -i ~/rpmbuild/RPMS/*/*.rpm
|
||||
"""
|
||||
spec_path = self._spec_path(ctx)
|
||||
if not spec_path:
|
||||
return
|
||||
|
||||
# 1) Install build dependencies
|
||||
self._install_build_dependencies(ctx, spec_path)
|
||||
|
||||
# 2) Build RPMs
|
||||
# Use the full spec path, but run in the repo directory.
|
||||
spec_basename = os.path.basename(spec_path)
|
||||
build_cmd = f"rpmbuild -ba {spec_basename}"
|
||||
run_command(build_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
|
||||
# 3) Find built RPMs
|
||||
rpms = self._find_built_rpms()
|
||||
if not rpms:
|
||||
print(
|
||||
"[Warning] No RPM files found after rpmbuild. "
|
||||
"Skipping RPM package installation."
|
||||
)
|
||||
return
|
||||
|
||||
# 4) Install RPMs
|
||||
if shutil.which("rpm") is None:
|
||||
print(
|
||||
"[Warning] rpm binary not found on PATH. "
|
||||
"Cannot install built RPMs."
|
||||
)
|
||||
return
|
||||
|
||||
install_cmd = "sudo rpm -i " + " ".join(rpms)
|
||||
run_command(install_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Installer for Python projects based on pyproject.toml.
|
||||
|
||||
Strategy:
|
||||
- Determine a pip command in this order:
|
||||
1. $PKGMGR_PIP (explicit override, e.g. ~/.venvs/pkgmgr/bin/pip)
|
||||
2. sys.executable -m pip (current interpreter)
|
||||
3. "pip" from PATH as last resort
|
||||
- If pyproject.toml exists: pip install .
|
||||
|
||||
All installation failures are treated as fatal errors (SystemExit).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
|
||||
from pkgmgr.core.command.run import run_command
|
||||
|
||||
|
||||
class PythonInstaller(BaseInstaller):
|
||||
"""Install Python projects and dependencies via pip."""
|
||||
|
||||
# Logical layer name, used by capability matchers.
|
||||
layer = "python"
|
||||
|
||||
def supports(self, ctx) -> bool:
|
||||
"""
|
||||
Return True if this installer should handle the given repository.
|
||||
|
||||
Only pyproject.toml is supported as the single source of truth
|
||||
for Python dependencies and packaging metadata.
|
||||
"""
|
||||
repo_dir = ctx.repo_dir
|
||||
return os.path.exists(os.path.join(repo_dir, "pyproject.toml"))
|
||||
|
||||
def _pip_cmd(self) -> str:
|
||||
"""
|
||||
Resolve the pip command to use.
|
||||
"""
|
||||
explicit = os.environ.get("PKGMGR_PIP", "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
if sys.executable:
|
||||
return f"{sys.executable} -m pip"
|
||||
|
||||
return "pip"
|
||||
|
||||
def run(self, ctx) -> None:
|
||||
"""
|
||||
Install Python project defined via pyproject.toml.
|
||||
|
||||
Any pip failure is propagated as SystemExit.
|
||||
"""
|
||||
pip_cmd = self._pip_cmd()
|
||||
|
||||
pyproject = os.path.join(ctx.repo_dir, "pyproject.toml")
|
||||
if os.path.exists(pyproject):
|
||||
print(
|
||||
f"pyproject.toml found in {ctx.identifier}, "
|
||||
f"installing Python project..."
|
||||
)
|
||||
cmd = f"{pip_cmd} install ."
|
||||
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
|
||||
@@ -1,35 +1,57 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.verify import verify_repository
|
||||
|
||||
|
||||
def pull_with_verification(
|
||||
selected_repos,
|
||||
repositories_base_dir,
|
||||
all_repos,
|
||||
extra_args,
|
||||
no_verification,
|
||||
preview:bool):
|
||||
preview: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Executes "git pull" for each repository with verification.
|
||||
|
||||
Uses the verify_repository function in "pull" mode.
|
||||
If verification fails (and verification info is set) and --no-verification is not enabled,
|
||||
the user is prompted to confirm the pull.
|
||||
Execute `git pull` for each repository with verification.
|
||||
|
||||
- Uses verify_repository() in "pull" mode.
|
||||
- If verification fails (and verification info is set) and
|
||||
--no-verification is not enabled, the user is prompted to confirm
|
||||
the pull.
|
||||
- In preview mode, no interactive prompts are performed and no
|
||||
Git commands are executed; only the would-be command is printed.
|
||||
"""
|
||||
for repo in selected_repos:
|
||||
repo_identifier = get_repo_identifier(repo, all_repos)
|
||||
repo_dir = get_repo_dir(repositories_base_dir, repo)
|
||||
|
||||
if not os.path.exists(repo_dir):
|
||||
print(f"Repository directory '{repo_dir}' not found for {repo_identifier}.")
|
||||
continue
|
||||
|
||||
verified_info = repo.get("verified")
|
||||
verified_ok, errors, commit_hash, signing_key = verify_repository(repo, repo_dir, mode="pull", no_verification=no_verification)
|
||||
verified_ok, errors, commit_hash, signing_key = verify_repository(
|
||||
repo,
|
||||
repo_dir,
|
||||
mode="pull",
|
||||
no_verification=no_verification,
|
||||
)
|
||||
|
||||
if not no_verification and verified_info and not verified_ok:
|
||||
# Only prompt the user if:
|
||||
# - we are NOT in preview mode
|
||||
# - verification is enabled
|
||||
# - the repo has verification info configured
|
||||
# - verification failed
|
||||
if (
|
||||
not preview
|
||||
and not no_verification
|
||||
and verified_info
|
||||
and not verified_ok
|
||||
):
|
||||
print(f"Warning: Verification failed for {repo_identifier}:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
@@ -37,12 +59,19 @@ def pull_with_verification(
|
||||
if choice != "y":
|
||||
continue
|
||||
|
||||
full_cmd = f"git pull {' '.join(extra_args)}"
|
||||
# Build the git pull command (include extra args if present)
|
||||
args_part = " ".join(extra_args) if extra_args else ""
|
||||
full_cmd = f"git pull{(' ' + args_part) if args_part else ''}"
|
||||
|
||||
if preview:
|
||||
# Preview mode: only show the command, do not execute or prompt.
|
||||
print(f"[Preview] In '{repo_dir}': {full_cmd}")
|
||||
else:
|
||||
print(f"Running in '{repo_dir}': {full_cmd}")
|
||||
result = subprocess.run(full_cmd, cwd=repo_dir, shell=True)
|
||||
if result.returncode != 0:
|
||||
print(f"'git pull' for {repo_identifier} failed with exit code {result.returncode}.")
|
||||
print(
|
||||
f"'git pull' for {repo_identifier} failed "
|
||||
f"with exit code {result.returncode}."
|
||||
)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
@@ -2,7 +2,7 @@ import sys
|
||||
import shutil
|
||||
|
||||
from pkgmgr.actions.repository.pull import pull_with_verification
|
||||
from pkgmgr.actions.repository.install import install_repos
|
||||
from pkgmgr.actions.install import install_repos
|
||||
|
||||
|
||||
def update_repos(
|
||||
|
||||
@@ -34,7 +34,6 @@ dependency formats, including:
|
||||
• \033[1;33mNix:\033[0m flake.nix
|
||||
• \033[1;33mArch Linux:\033[0m PKGBUILD
|
||||
• \033[1;33mAnsible:\033[0m requirements.yml
|
||||
• \033[1;33mpkgmgr-native:\033[0m pkgmgr.yml
|
||||
|
||||
This allows pkgmgr to perform installation, updates, verification, dependency
|
||||
resolution, and synchronization across complex multi-repo environments — with a
|
||||
|
||||
@@ -7,7 +7,7 @@ import sys
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.actions.repository.install import install_repos
|
||||
from pkgmgr.actions.install import install_repos
|
||||
from pkgmgr.actions.repository.deinstall import deinstall_repos
|
||||
from pkgmgr.actions.repository.delete import delete_repos
|
||||
from pkgmgr.actions.repository.update import update_repos
|
||||
@@ -16,10 +16,36 @@ from pkgmgr.actions.repository.list import list_repositories
|
||||
from pkgmgr.core.command.run import run_command
|
||||
from pkgmgr.actions.repository.create import create_repo
|
||||
from pkgmgr.core.repository.selected import get_selected_repos
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
|
||||
|
||||
def _resolve_repository_directory(repository: Repository, ctx: CLIContext) -> str:
|
||||
"""
|
||||
Resolve the local filesystem directory for a repository.
|
||||
|
||||
Priority:
|
||||
1. Use repository["directory"] if present.
|
||||
2. Fallback to get_repo_dir(...) using the repositories base directory
|
||||
from the CLI context.
|
||||
"""
|
||||
repo_dir = repository.get("directory")
|
||||
if repo_dir:
|
||||
return repo_dir
|
||||
|
||||
base_dir = (
|
||||
getattr(ctx, "repositories_base_dir", None)
|
||||
or getattr(ctx, "repositories_dir", None)
|
||||
)
|
||||
if not base_dir:
|
||||
raise RuntimeError(
|
||||
"Cannot resolve repositories base directory from context; "
|
||||
"expected ctx.repositories_base_dir or ctx.repositories_dir."
|
||||
)
|
||||
return get_repo_dir(base_dir, repository)
|
||||
|
||||
|
||||
def handle_repos_command(
|
||||
args,
|
||||
ctx: CLIContext,
|
||||
@@ -108,8 +134,25 @@ def handle_repos_command(
|
||||
# path
|
||||
# ------------------------------------------------------------
|
||||
if args.command == "path":
|
||||
if not selected:
|
||||
print("[pkgmgr] No repositories selected for path.")
|
||||
return
|
||||
|
||||
for repository in selected:
|
||||
print(repository["directory"])
|
||||
try:
|
||||
repo_dir = _resolve_repository_directory(repository, ctx)
|
||||
except Exception as exc:
|
||||
ident = (
|
||||
f"{repository.get('provider', '?')}/"
|
||||
f"{repository.get('account', '?')}/"
|
||||
f"{repository.get('repository', '?')}"
|
||||
)
|
||||
print(
|
||||
f"[WARN] Could not resolve directory for {ident}: {exc}"
|
||||
)
|
||||
continue
|
||||
|
||||
print(repo_dir)
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------
|
||||
@@ -119,14 +162,14 @@ def handle_repos_command(
|
||||
if not args.shell_command:
|
||||
print("[ERROR] 'shell' requires a command via -c/--command.")
|
||||
sys.exit(2)
|
||||
|
||||
command_to_run = " ".join(args.shell_command)
|
||||
for repository in selected:
|
||||
print(
|
||||
f"Executing in '{repository['directory']}': {command_to_run}"
|
||||
)
|
||||
repo_dir = _resolve_repository_directory(repository, ctx)
|
||||
print(f"Executing in '{repo_dir}': {command_to_run}")
|
||||
run_command(
|
||||
command_to_run,
|
||||
cwd=repository["directory"],
|
||||
cwd=repo_dir,
|
||||
preview=args.preview,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -1,57 +1,84 @@
|
||||
from __future__ import annotations
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import json
|
||||
import os
|
||||
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.core.command.run import run_command
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr .cli .context import CLIContext
|
||||
from pkgmgr .core .command .run import run_command
|
||||
from pkgmgr .core .repository .identifier import get_repo_identifier
|
||||
from pkgmgr .core .repository .dir import get_repo_dir
|
||||
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
|
||||
|
||||
def _resolve_repository_path(repository: Repository, ctx: CLIContext) -> str:
|
||||
"""
|
||||
Resolve the filesystem path for a repository.
|
||||
|
||||
Priority:
|
||||
1. Use explicit keys if present (directory / path / workspace / workspace_dir).
|
||||
2. Fallback to get_repo_dir(...) using the repositories base directory
|
||||
from the CLI context.
|
||||
"""
|
||||
|
||||
# 1) Explicit path-like keys on the repository object
|
||||
for key in ("directory", "path", "workspace", "workspace_dir"):
|
||||
value = repository.get(key)
|
||||
if value:
|
||||
return value
|
||||
|
||||
# 2) Fallback: compute from base dir + repository metadata
|
||||
base_dir = (
|
||||
getattr(ctx, "repositories_base_dir", None)
|
||||
or getattr(ctx, "repositories_dir", None)
|
||||
)
|
||||
if not base_dir:
|
||||
raise RuntimeError(
|
||||
"Cannot resolve repositories base directory from context; "
|
||||
"expected ctx.repositories_base_dir or ctx.repositories_dir."
|
||||
)
|
||||
|
||||
return get_repo_dir(base_dir, repository)
|
||||
|
||||
|
||||
def handle_tools_command(
|
||||
args,
|
||||
ctx: CLIContext,
|
||||
selected: List[Repository],
|
||||
) -> None:
|
||||
"""
|
||||
Handle integration commands:
|
||||
- explore (file manager)
|
||||
- terminal (GNOME Terminal)
|
||||
- code (VS Code workspace)
|
||||
"""
|
||||
|
||||
# --------------------------------------------------------
|
||||
# explore
|
||||
# --------------------------------------------------------
|
||||
# ------------------------------------------------------------------
|
||||
# nautilus "explore" command
|
||||
# ------------------------------------------------------------------
|
||||
if args.command == "explore":
|
||||
for repository in selected:
|
||||
repo_path = _resolve_repository_path(repository, ctx)
|
||||
run_command(
|
||||
f"nautilus {repository['directory']} & disown"
|
||||
f'nautilus "{repo_path}" & disown'
|
||||
)
|
||||
return
|
||||
return
|
||||
|
||||
# --------------------------------------------------------
|
||||
# terminal
|
||||
# --------------------------------------------------------
|
||||
# ------------------------------------------------------------------
|
||||
# GNOME terminal command
|
||||
# ------------------------------------------------------------------
|
||||
if args.command == "terminal":
|
||||
for repository in selected:
|
||||
repo_path = _resolve_repository_path(repository, ctx)
|
||||
run_command(
|
||||
f'gnome-terminal --tab --working-directory="{repository["directory"]}"'
|
||||
f'gnome-terminal --tab --working-directory="{repo_path}"'
|
||||
)
|
||||
return
|
||||
return
|
||||
|
||||
# --------------------------------------------------------
|
||||
# code
|
||||
# --------------------------------------------------------
|
||||
# ------------------------------------------------------------------
|
||||
# VS Code workspace command
|
||||
# ------------------------------------------------------------------
|
||||
if args.command == "code":
|
||||
if not selected:
|
||||
print("No repositories selected.")
|
||||
return
|
||||
return
|
||||
|
||||
identifiers = [
|
||||
get_repo_identifier(repo, ctx.all_repositories)
|
||||
@@ -60,20 +87,25 @@ def handle_tools_command(
|
||||
sorted_identifiers = sorted(identifiers)
|
||||
workspace_name = "_".join(sorted_identifiers) + ".code-workspace"
|
||||
|
||||
directories_cfg = ctx.config_merged.get("directories") or {}
|
||||
workspaces_dir = os.path.expanduser(
|
||||
ctx.config_merged.get("directories").get("workspaces")
|
||||
directories_cfg.get("workspaces", "~/Workspaces")
|
||||
)
|
||||
os.makedirs(workspaces_dir, exist_ok=True)
|
||||
workspace_file = os.path.join(workspaces_dir, workspace_name)
|
||||
|
||||
folders = [{"path": repository["directory"]} for repository in selected]
|
||||
folders = [
|
||||
{"path": _resolve_repository_path(repository, ctx)}
|
||||
for repository in selected
|
||||
]
|
||||
|
||||
workspace_data = {
|
||||
"folders": folders,
|
||||
"settings": {},
|
||||
}
|
||||
|
||||
if not os.path.exists(workspace_file):
|
||||
with open(workspace_file, "w") as f:
|
||||
with open(workspace_file, "w", encoding="utf-8") as f:
|
||||
json.dump(workspace_data, f, indent=4)
|
||||
print(f"Created workspace file: {workspace_file}")
|
||||
else:
|
||||
|
||||
@@ -6,8 +6,14 @@ from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
|
||||
|
||||
def create_ink(repo, repositories_base_dir, bin_dir, all_repos,
|
||||
quiet=False, preview=False):
|
||||
def create_ink(
|
||||
repo,
|
||||
repositories_base_dir,
|
||||
bin_dir,
|
||||
all_repos,
|
||||
quiet: bool = False,
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Create a symlink for the repository's command.
|
||||
|
||||
@@ -18,6 +24,11 @@ def create_ink(repo, repositories_base_dir, bin_dir, all_repos,
|
||||
Behavior:
|
||||
- If repo["command"] is defined → create a symlink to it.
|
||||
- If repo["command"] is missing or None → do NOT create a link.
|
||||
|
||||
Safety:
|
||||
- If the resolved command path is identical to the final link target,
|
||||
we skip symlink creation to avoid self-referential symlinks that
|
||||
would break shell resolution ("too many levels of symbolic links").
|
||||
"""
|
||||
|
||||
repo_identifier = get_repo_identifier(repo, all_repos)
|
||||
@@ -31,6 +42,27 @@ def create_ink(repo, repositories_base_dir, bin_dir, all_repos,
|
||||
|
||||
link_path = os.path.join(bin_dir, repo_identifier)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Safety guard: avoid self-referential symlinks
|
||||
#
|
||||
# Example of a broken situation we must avoid:
|
||||
# - command = ~/.local/bin/package-manager
|
||||
# - link_path = ~/.local/bin/package-manager
|
||||
# - create_ink() removes the real binary and creates a symlink
|
||||
# pointing to itself → zsh: too many levels of symbolic links
|
||||
#
|
||||
# If the resolved command already lives exactly at the target path,
|
||||
# we treat it as "already installed" and skip any modification.
|
||||
# ------------------------------------------------------------------
|
||||
if os.path.abspath(command) == os.path.abspath(link_path):
|
||||
if not quiet:
|
||||
print(
|
||||
f"[pkgmgr] Command for '{repo_identifier}' already lives at "
|
||||
f"'{link_path}'. Skipping symlink creation to avoid a "
|
||||
"self-referential link."
|
||||
)
|
||||
return
|
||||
|
||||
if preview:
|
||||
print(f"[Preview] Would link {link_path} → {command}")
|
||||
return
|
||||
@@ -65,7 +97,10 @@ def create_ink(repo, repositories_base_dir, bin_dir, all_repos,
|
||||
|
||||
if alias_name == repo_identifier:
|
||||
if not quiet:
|
||||
print(f"Alias '{alias_name}' equals identifier. Skipping alias creation.")
|
||||
print(
|
||||
f"Alias '{alias_name}' equals identifier. "
|
||||
"Skipping alias creation."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,113 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Command resolver for repositories.
|
||||
|
||||
This module determines the correct command to expose via symlink.
|
||||
It implements the following priority:
|
||||
|
||||
1. Explicit command in repo config → command
|
||||
2. System package manager binary (/usr/...) → NO LINK (respect OS)
|
||||
3. Nix profile binary (~/.nix-profile/bin/<id>) → command
|
||||
4. Python / non-system console script on PATH → command
|
||||
5. Fallback: repository's main.sh or main.py → command
|
||||
6. If nothing is available → raise error
|
||||
|
||||
The actual symlink creation is handled by create_ink(). This resolver
|
||||
only decides *what* should be used as the entrypoint, or whether no
|
||||
link should be created at all.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
|
||||
def resolve_command_for_repo(repo, repo_identifier: str, repo_dir: str) -> Optional[str]:
|
||||
Repository = Dict[str, Any]
|
||||
|
||||
|
||||
def _is_executable(path: str) -> bool:
|
||||
return os.path.exists(path) and os.access(path, os.X_OK)
|
||||
|
||||
|
||||
def _find_python_package_root(repo_dir: str) -> Optional[str]:
|
||||
"""
|
||||
Determine the command for this repository.
|
||||
Detect a Python src-layout package:
|
||||
|
||||
Returns:
|
||||
str → path to the command (a symlink should be created)
|
||||
None → do NOT create a link (e.g. system package already provides it)
|
||||
repo_dir/src/<package>/__main__.py
|
||||
|
||||
On total failure (no suitable command found at any layer), this function
|
||||
raises SystemExit with a descriptive error message.
|
||||
Returns the directory containing __main__.py (e.g. ".../src/arc")
|
||||
or None if no such structure exists.
|
||||
"""
|
||||
# ------------------------------------------------------------
|
||||
# 1. Explicit command defined by repository config
|
||||
# ------------------------------------------------------------
|
||||
explicit = repo.get("command")
|
||||
if explicit:
|
||||
return explicit
|
||||
src_dir = os.path.join(repo_dir, "src")
|
||||
if not os.path.isdir(src_dir):
|
||||
return None
|
||||
|
||||
for root, _dirs, files in os.walk(src_dir):
|
||||
if "__main__.py" in files:
|
||||
return root
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _nix_binary_candidates(home: str, names: List[str]) -> List[str]:
|
||||
"""
|
||||
Build possible Nix profile binary paths for a list of candidate names.
|
||||
"""
|
||||
return [
|
||||
os.path.join(home, ".nix-profile", "bin", name)
|
||||
for name in names
|
||||
if name
|
||||
]
|
||||
|
||||
|
||||
def _path_binary_candidates(names: List[str]) -> List[str]:
|
||||
"""
|
||||
Resolve candidate names via PATH using shutil.which.
|
||||
Returns only existing, executable paths.
|
||||
"""
|
||||
binaries: List[str] = []
|
||||
for name in names:
|
||||
if not name:
|
||||
continue
|
||||
candidate = shutil.which(name)
|
||||
if candidate and _is_executable(candidate):
|
||||
binaries.append(candidate)
|
||||
return binaries
|
||||
|
||||
|
||||
def resolve_command_for_repo(
|
||||
repo: Repository,
|
||||
repo_identifier: str,
|
||||
repo_dir: str,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Resolve the executable command for a repository.
|
||||
|
||||
Semantics:
|
||||
----------
|
||||
- If the repository explicitly defines the key "command" (even if None),
|
||||
that is treated as authoritative and returned immediately.
|
||||
This allows e.g.:
|
||||
|
||||
command: null
|
||||
|
||||
for pure library repositories with no CLI.
|
||||
|
||||
- If "command" is not defined, we try to discover a suitable CLI command:
|
||||
1. Prefer already installed binaries (PATH, Nix profile).
|
||||
2. For Python src-layout packages (src/*/__main__.py), try to infer
|
||||
a sensible command name (alias, repo identifier, repository name,
|
||||
package directory name) and resolve those via PATH / Nix.
|
||||
3. For script-style repos, fall back to main.sh / main.py.
|
||||
4. If nothing matches, return None (no CLI) instead of raising.
|
||||
|
||||
The caller can interpret:
|
||||
- str → path to the command (symlink target)
|
||||
- None → no CLI command for this repository
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1) Explicit command declaration (including explicit "no command")
|
||||
# ------------------------------------------------------------------
|
||||
if "command" in repo:
|
||||
# May be a string path or None. None means: this repo intentionally
|
||||
# has no CLI command and should not be resolved.
|
||||
return repo.get("command")
|
||||
|
||||
home = os.path.expanduser("~")
|
||||
|
||||
def is_executable(path: str) -> bool:
|
||||
return os.path.exists(path) and os.access(path, os.X_OK)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 2. System package manager binary via PATH
|
||||
# ------------------------------------------------------------------
|
||||
# 2) Collect candidate names for CLI binaries
|
||||
#
|
||||
# If the binary lives under /usr/, we treat it as a system-managed
|
||||
# package (e.g. installed via pacman/apt/yum). In that case, pkgmgr
|
||||
# does NOT create a link at all and defers entirely to the OS.
|
||||
# ------------------------------------------------------------
|
||||
path_candidate = shutil.which(repo_identifier)
|
||||
# Order of preference:
|
||||
# - repo_identifier (usually alias or configured id)
|
||||
# - alias (if defined)
|
||||
# - repository name (e.g. "analysis-ready-code")
|
||||
# - python package name (e.g. "arc" from src/arc/__main__.py)
|
||||
# ------------------------------------------------------------------
|
||||
alias = repo.get("alias")
|
||||
repository_name = repo.get("repository")
|
||||
|
||||
python_package_root = _find_python_package_root(repo_dir)
|
||||
if python_package_root:
|
||||
python_package_name = os.path.basename(python_package_root)
|
||||
else:
|
||||
python_package_name = None
|
||||
|
||||
candidate_names: List[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for name in (
|
||||
repo_identifier,
|
||||
alias,
|
||||
repository_name,
|
||||
python_package_name,
|
||||
):
|
||||
if name and name not in seen:
|
||||
seen.add(name)
|
||||
candidate_names.append(name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3) Try resolve via PATH (non-system and system) and Nix profile
|
||||
# ------------------------------------------------------------------
|
||||
# a) PATH binaries
|
||||
path_binaries = _path_binary_candidates(candidate_names)
|
||||
|
||||
# b) Classify system (/usr/...) vs non-system
|
||||
system_binary: Optional[str] = None
|
||||
non_system_binary: Optional[str] = None
|
||||
|
||||
if path_candidate:
|
||||
if path_candidate.startswith("/usr/"):
|
||||
system_binary = path_candidate
|
||||
for bin_path in path_binaries:
|
||||
if bin_path.startswith("/usr"):
|
||||
# Last system binary wins, but usually there is only one anyway
|
||||
system_binary = bin_path
|
||||
else:
|
||||
non_system_binary = path_candidate
|
||||
non_system_binary = bin_path
|
||||
break # prefer the first non-system binary
|
||||
|
||||
# c) Nix profile binaries
|
||||
nix_binaries = [
|
||||
path for path in _nix_binary_candidates(home, candidate_names)
|
||||
if _is_executable(path)
|
||||
]
|
||||
nix_binary = nix_binaries[0] if nix_binaries else None
|
||||
|
||||
# Decide priority:
|
||||
# 1) non-system PATH binary (user/venv)
|
||||
# 2) Nix profile binary
|
||||
# 3) system binary (/usr/...) → only if we want to expose it
|
||||
if non_system_binary:
|
||||
return non_system_binary
|
||||
|
||||
if nix_binary:
|
||||
return nix_binary
|
||||
|
||||
if system_binary:
|
||||
# Respect system package manager: do not create a link.
|
||||
if repo.get("debug", False):
|
||||
# Respect system packages. Depending on your policy you can decide
|
||||
# to return None (no symlink, OS owns the command) or to expose it.
|
||||
# Here we choose: no symlink for pure system binaries.
|
||||
if repo.get("ignore_system_binary", False):
|
||||
print(
|
||||
f"[pkgmgr] System binary for '{repo_identifier}' found at "
|
||||
f"{system_binary}; no symlink will be created."
|
||||
)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 3. Nix profile binary (~/.nix-profile/bin/<identifier>)
|
||||
# ------------------------------------------------------------
|
||||
nix_candidate = os.path.join(home, ".nix-profile", "bin", repo_identifier)
|
||||
if is_executable(nix_candidate):
|
||||
return nix_candidate
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 4. Python / non-system console script on PATH
|
||||
#
|
||||
# Here we reuse the non-system PATH candidate (e.g. from a venv or
|
||||
# a user-local install like ~/.local/bin). This is treated as a
|
||||
# valid command target.
|
||||
# ------------------------------------------------------------
|
||||
if non_system_binary and is_executable(non_system_binary):
|
||||
return non_system_binary
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 5. Fallback: main.sh / main.py inside the repository
|
||||
# ------------------------------------------------------------
|
||||
# ------------------------------------------------------------------
|
||||
# 4) Script-style repository: fallback to main.sh / main.py
|
||||
# ------------------------------------------------------------------
|
||||
main_sh = os.path.join(repo_dir, "main.sh")
|
||||
main_py = os.path.join(repo_dir, "main.py")
|
||||
|
||||
if is_executable(main_sh):
|
||||
if _is_executable(main_sh):
|
||||
return main_sh
|
||||
|
||||
if is_executable(main_py) or os.path.exists(main_py):
|
||||
if os.path.exists(main_py):
|
||||
return main_py
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 6. Nothing found → treat as a hard error
|
||||
# ------------------------------------------------------------
|
||||
raise SystemExit(
|
||||
f"No executable command could be resolved for repository '{repo_identifier}'. "
|
||||
"No explicit 'command' configured, no system-managed binary under /usr/, "
|
||||
"no Nix profile binary, no non-system console script on PATH, and no "
|
||||
"main.sh/main.py found in the repository."
|
||||
)
|
||||
# ------------------------------------------------------------------
|
||||
# 5) No CLI discovered
|
||||
#
|
||||
# At this point we may still have a Python package structure, but
|
||||
# without any installed CLI entry point and without main.sh/main.py.
|
||||
#
|
||||
# This is perfectly valid for library-only repositories, so we do
|
||||
# NOT treat this as an error. The caller can then decide to simply
|
||||
# skip symlink creation.
|
||||
# ------------------------------------------------------------------
|
||||
if python_package_root:
|
||||
print(
|
||||
f"[INFO] Repository '{repo_identifier}' appears to be a Python "
|
||||
f"package at '{python_package_root}' but no CLI entry point was "
|
||||
f"found (PATH, Nix, main.sh/main.py). Treating it as a "
|
||||
f"library-only repository with no command."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@@ -8,6 +8,7 @@ import re
|
||||
from typing import Any, Dict, List, Sequence
|
||||
|
||||
from pkgmgr.core.repository.resolve import resolve_repos
|
||||
from pkgmgr.core.repository.ignored import filter_ignored
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
|
||||
@@ -88,7 +89,7 @@ def _apply_filters(
|
||||
if not _match_pattern(ident_str, string_pattern):
|
||||
continue
|
||||
|
||||
# Category filter: nur echte Kategorien, KEINE Tags
|
||||
# Category filter: only real categories, NOT tags
|
||||
if category_patterns:
|
||||
cats: List[str] = []
|
||||
cats.extend(map(str, repo.get("category_files", [])))
|
||||
@@ -106,7 +107,7 @@ def _apply_filters(
|
||||
if not ok:
|
||||
continue
|
||||
|
||||
# Tag filter: ausschließlich YAML-Tags
|
||||
# Tag filter: YAML tags only
|
||||
if tag_patterns:
|
||||
tags: List[str] = list(map(str, repo.get("tags", [])))
|
||||
if not tags:
|
||||
@@ -124,16 +125,38 @@ def _apply_filters(
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def _maybe_filter_ignored(args, repos: List[Repository]) -> List[Repository]:
|
||||
"""
|
||||
Apply ignore filtering unless the caller explicitly opted to include ignored
|
||||
repositories (via args.include_ignored).
|
||||
|
||||
Note: this helper is used only for *implicit* selections (all / filters /
|
||||
by-directory). For *explicit* identifiers we do NOT filter ignored repos,
|
||||
so the user can still target them directly if desired.
|
||||
"""
|
||||
include_ignored: bool = bool(getattr(args, "include_ignored", False))
|
||||
if include_ignored:
|
||||
return repos
|
||||
return filter_ignored(repos)
|
||||
|
||||
|
||||
def get_selected_repos(args, all_repositories: List[Repository]) -> List[Repository]:
|
||||
"""
|
||||
Compute the list of repositories selected by CLI arguments.
|
||||
|
||||
Modes:
|
||||
- If identifiers are given: select via resolve_repos() from all_repositories.
|
||||
- Else if any of --category/--string/--tag is used: start from all_repositories
|
||||
and apply filters.
|
||||
- Else if --all is set: select all_repositories.
|
||||
- Else: try to select the repository of the current working directory.
|
||||
Ignored repositories are *not* filtered here, so explicit identifiers
|
||||
always win.
|
||||
- Else if any of --category/--string/--tag is used: start from
|
||||
all_repositories, apply filters and then drop ignored repos.
|
||||
- Else if --all is set: select all_repositories and then drop ignored repos.
|
||||
- Else: try to select the repository of the current working directory
|
||||
and then drop it if it is ignored.
|
||||
|
||||
The ignore filter can be bypassed by setting args.include_ignored = True
|
||||
(e.g. via a CLI flag --include-ignored).
|
||||
"""
|
||||
identifiers: List[str] = getattr(args, "identifiers", []) or []
|
||||
use_all: bool = bool(getattr(args, "all", False))
|
||||
@@ -143,18 +166,25 @@ def get_selected_repos(args, all_repositories: List[Repository]) -> List[Reposit
|
||||
|
||||
has_filters = bool(category_patterns or string_pattern or tag_patterns)
|
||||
|
||||
# 1) Explicit identifiers win
|
||||
# 1) Explicit identifiers win and bypass ignore filtering
|
||||
if identifiers:
|
||||
base = resolve_repos(identifiers, all_repositories)
|
||||
return _apply_filters(base, string_pattern, category_patterns, tag_patterns)
|
||||
|
||||
# 2) Filter-only mode: start from all repositories
|
||||
if has_filters:
|
||||
return _apply_filters(list(all_repositories), string_pattern, category_patterns, tag_patterns)
|
||||
base = _apply_filters(
|
||||
list(all_repositories),
|
||||
string_pattern,
|
||||
category_patterns,
|
||||
tag_patterns,
|
||||
)
|
||||
return _maybe_filter_ignored(args, base)
|
||||
|
||||
# 3) --all (no filters): all repos
|
||||
if use_all:
|
||||
return list(all_repositories)
|
||||
base = list(all_repositories)
|
||||
return _maybe_filter_ignored(args, base)
|
||||
|
||||
# 4) Fallback: try to select repository of current working directory
|
||||
cwd = os.path.abspath(os.getcwd())
|
||||
@@ -164,7 +194,7 @@ def get_selected_repos(args, all_repositories: List[Repository]) -> List[Reposit
|
||||
if os.path.abspath(str(repo.get("directory", ""))) == cwd
|
||||
]
|
||||
if by_dir:
|
||||
return by_dir
|
||||
return _maybe_filter_ignored(args, by_dir)
|
||||
|
||||
# No specific match -> empty list
|
||||
return []
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "package-manager"
|
||||
version = "0.7.1"
|
||||
version = "0.9.1"
|
||||
description = "Kevin's package-manager tool (pkgmgr)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -2,28 +2,59 @@
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ensure Nix has access to a valid CA bundle (TLS trust store)
|
||||
# Detect and export a valid CA bundle so Nix, Git, curl and Python tooling
|
||||
# can successfully perform HTTPS requests on all distros (Debian, Ubuntu,
|
||||
# Fedora, RHEL, CentOS, etc.)
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ -z "${NIX_SSL_CERT_FILE:-}" ]]; then
|
||||
if [[ -f /etc/ssl/certs/ca-certificates.crt ]]; then
|
||||
# Debian/Ubuntu-style path
|
||||
export NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
echo "[docker] Using CA bundle: ${NIX_SSL_CERT_FILE}"
|
||||
elif [[ -f /etc/pki/tls/certs/ca-bundle.crt ]]; then
|
||||
# Fedora/RHEL/CentOS-style path
|
||||
export NIX_SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt
|
||||
echo "[docker] Using CA bundle: ${NIX_SSL_CERT_FILE}"
|
||||
else
|
||||
echo "[docker] WARNING: No CA bundle found for Nix (NIX_SSL_CERT_FILE not set)."
|
||||
echo "[docker] HTTPS access for Nix flakes may fail."
|
||||
fi
|
||||
detect_ca_bundle() {
|
||||
# Common CA bundle locations across major Linux distributions
|
||||
local candidates=(
|
||||
/etc/ssl/certs/ca-certificates.crt # Debian/Ubuntu
|
||||
/etc/ssl/cert.pem # Some distros
|
||||
/etc/pki/tls/certs/ca-bundle.crt # Fedora/RHEL/CentOS
|
||||
/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem # CentOS/RHEL extracted bundle
|
||||
/etc/ssl/ca-bundle.pem # Generic fallback
|
||||
)
|
||||
|
||||
for path in "${candidates[@]}"; do
|
||||
if [[ -f "$path" ]]; then
|
||||
echo "$path"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Use existing NIX_SSL_CERT_FILE if provided, otherwise auto-detect
|
||||
CA_BUNDLE="${NIX_SSL_CERT_FILE:-}"
|
||||
|
||||
if [[ -z "${CA_BUNDLE}" ]]; then
|
||||
CA_BUNDLE="$(detect_ca_bundle || true)"
|
||||
fi
|
||||
|
||||
if [[ -n "${CA_BUNDLE}" ]]; then
|
||||
# Export for Nix (critical)
|
||||
export NIX_SSL_CERT_FILE="${CA_BUNDLE}"
|
||||
|
||||
# Export for Git, Python requests, curl, etc.
|
||||
export SSL_CERT_FILE="${CA_BUNDLE}"
|
||||
export REQUESTS_CA_BUNDLE="${CA_BUNDLE}"
|
||||
export GIT_SSL_CAINFO="${CA_BUNDLE}"
|
||||
|
||||
echo "[docker] Using CA bundle: ${CA_BUNDLE}"
|
||||
else
|
||||
echo "[docker] WARNING: No CA certificate bundle found."
|
||||
echo "[docker] HTTPS access for Nix flakes and other tools may fail."
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo "[docker] Starting package-manager container"
|
||||
|
||||
# Distro info for logging
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log distribution info
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
@@ -34,9 +65,9 @@ fi
|
||||
echo "[docker] Using /src as working directory"
|
||||
cd /src
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# DEV mode: build/install package-manager from current /src
|
||||
# ------------------------------------------------------------
|
||||
# ---------------------------------------------------------------------------
|
||||
# DEV mode: rebuild package-manager from the mounted /src tree
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "${PKGMGR_DEV:-0}" == "1" ]]; then
|
||||
echo "[docker] DEV mode enabled (PKGMGR_DEV=1)"
|
||||
echo "[docker] Rebuilding package-manager from /src via scripts/installation/run-package.sh..."
|
||||
@@ -49,9 +80,9 @@ if [[ "${PKGMGR_DEV:-0}" == "1" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Hand-off to pkgmgr / arbitrary command
|
||||
# ------------------------------------------------------------
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hand off to pkgmgr or arbitrary command
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ $# -eq 0 ]]; then
|
||||
echo "[docker] No arguments provided. Showing pkgmgr help..."
|
||||
exec pkgmgr --help
|
||||
|
||||
@@ -94,14 +94,43 @@ if [[ "${IN_CONTAINER}" -eq 1 && "${EUID:-0}" -eq 0 ]]; then
|
||||
# Ensure "nix" user (home at /home/nix)
|
||||
if ! id nix >/dev/null 2>&1; then
|
||||
echo "[init-nix] Creating user 'nix'..."
|
||||
useradd -m -r -g nixbld -s /usr/bin/bash nix
|
||||
# Resolve a valid shell path across distros:
|
||||
# - Debian/Ubuntu: /bin/bash
|
||||
# - Arch: /usr/bin/bash (often symlinked)
|
||||
# Fall back to /bin/sh on ultra-minimal systems.
|
||||
BASH_SHELL="$(command -v bash || true)"
|
||||
if [[ -z "${BASH_SHELL}" ]]; then
|
||||
BASH_SHELL="/bin/sh"
|
||||
fi
|
||||
useradd -m -r -g nixbld -s "${BASH_SHELL}" nix
|
||||
fi
|
||||
|
||||
# Create /nix directory and hand it to nix user (prevents installer sudo prompt)
|
||||
# Ensure /nix exists and is writable by the "nix" user.
|
||||
#
|
||||
# In some base images (or previous runs), /nix may already exist and be
|
||||
# owned by root. In that case the Nix single-user installer will abort with:
|
||||
#
|
||||
# "directory /nix exists, but is not writable by you"
|
||||
#
|
||||
# To keep container runs idempotent and robust, we always enforce
|
||||
# ownership nix:nixbld here.
|
||||
if [[ ! -d /nix ]]; then
|
||||
echo "[init-nix] Creating /nix with owner nix:nixbld..."
|
||||
mkdir -m 0755 /nix
|
||||
chown nix:nixbld /nix
|
||||
else
|
||||
current_owner="$(stat -c '%U' /nix 2>/dev/null || echo '?')"
|
||||
current_group="$(stat -c '%G' /nix 2>/dev/null || echo '?')"
|
||||
if [[ "${current_owner}" != "nix" || "${current_group}" != "nixbld" ]]; then
|
||||
echo "[init-nix] /nix already exists with owner ${current_owner}:${current_group} – fixing to nix:nixbld..."
|
||||
chown -R nix:nixbld /nix
|
||||
else
|
||||
echo "[init-nix] /nix already exists with correct owner nix:nixbld."
|
||||
fi
|
||||
|
||||
if [[ ! -w /nix ]]; then
|
||||
echo "[init-nix] WARNING: /nix is still not writable after chown; Nix installer may fail."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run Nix single-user installer as "nix"
|
||||
|
||||
@@ -13,6 +13,7 @@ dnf -y install \
|
||||
bash \
|
||||
curl-minimal \
|
||||
ca-certificates \
|
||||
sudo \
|
||||
xz
|
||||
|
||||
dnf clean all
|
||||
|
||||
@@ -4,20 +4,22 @@ set -euo pipefail
|
||||
# ------------------------------------------------------------
|
||||
# main.sh
|
||||
#
|
||||
# Developer setup entrypoint.
|
||||
# Developer / system setup entrypoint.
|
||||
#
|
||||
# Responsibilities:
|
||||
# - If inside a Nix shell (IN_NIX_SHELL=1):
|
||||
# * Skip venv creation and dependency installation
|
||||
# * Run `python3 main.py install`
|
||||
# - Otherwise:
|
||||
# - If running as root (EUID=0):
|
||||
# * Run system-level installer (run-package.sh)
|
||||
# - Otherwise (normal user):
|
||||
# * Create ~/.venvs/pkgmgr virtual environment if missing
|
||||
# * Install Python dependencies into that venv
|
||||
# * Append auto-activation to ~/.bashrc and ~/.zshrc
|
||||
# * Run `main.py install` using the venv Python
|
||||
# ------------------------------------------------------------
|
||||
|
||||
echo "[installation/main] Starting developer setup..."
|
||||
echo "[installation/main] Starting setup..."
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "${PROJECT_ROOT}"
|
||||
@@ -26,20 +28,34 @@ VENV_DIR="${HOME}/.venvs/pkgmgr"
|
||||
RC_LINE='if [ -d "${HOME}/.venvs/pkgmgr" ]; then . "${HOME}/.venvs/pkgmgr/bin/activate"; if [ -n "${PS1:-}" ]; then echo "Global Python virtual environment '\''~/.venvs/pkgmgr'\'' activated."; fi; fi'
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Nix shell mode: do not touch venv, only run main.py install
|
||||
# 1) Nix shell mode: do not touch venv, only run main.py install
|
||||
# ------------------------------------------------------------
|
||||
if [[ -n "${IN_NIX_SHELL:-}" ]]; then
|
||||
echo "[installation/main] Nix shell detected (IN_NIX_SHELL=1)."
|
||||
echo "[installation/main] Skipping virtualenv creation and dependency installation."
|
||||
echo "[installation/main] Running main.py install via system python3..."
|
||||
python3 main.py install
|
||||
echo "[installation/main] Developer setup finished (Nix mode)."
|
||||
echo "[installation/main] Setup finished (Nix mode)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Normal host mode: create/update venv and run main.py install
|
||||
# 2) Root mode: system / distro-level installation
|
||||
# ------------------------------------------------------------
|
||||
if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
|
||||
echo "[installation/main] Running as root (EUID=0)."
|
||||
echo "[installation/main] Skipping user virtualenv and shell RC modifications."
|
||||
echo "[installation/main] Delegating to scripts/installation/run-package.sh..."
|
||||
bash scripts/installation/run-package.sh
|
||||
echo "[installation/main] Root/system setup complete."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 3) Normal user mode: dev setup with venv
|
||||
# ------------------------------------------------------------
|
||||
|
||||
echo "[installation/main] Running in normal user mode (developer setup)."
|
||||
|
||||
echo "[installation/main] Ensuring main.py is executable..."
|
||||
chmod +x main.py || true
|
||||
@@ -47,26 +63,8 @@ chmod +x main.py || true
|
||||
echo "[installation/main] Ensuring global virtualenv root: ${HOME}/.venvs"
|
||||
mkdir -p "${HOME}/.venvs"
|
||||
|
||||
if [[ ! -d "${VENV_DIR}" ]]; then
|
||||
echo "[installation/main] Creating virtual environment at: ${VENV_DIR}"
|
||||
python3 -m venv "${VENV_DIR}"
|
||||
else
|
||||
echo "[installation/main] Virtual environment already exists at: ${VENV_DIR}"
|
||||
fi
|
||||
|
||||
echo "[installation/main] Installing Python tooling into venv..."
|
||||
"${VENV_DIR}/bin/python" -m ensurepip --upgrade
|
||||
"${VENV_DIR}/bin/pip" install --upgrade pip setuptools wheel
|
||||
|
||||
if [[ -f "requirements.txt" ]]; then
|
||||
echo "[installation/main] Installing dependencies from requirements.txt..."
|
||||
"${VENV_DIR}/bin/pip" install -r requirements.txt
|
||||
elif [[ -f "_requirements.txt" ]]; then
|
||||
echo "[installation/main] Installing dependencies from _requirements.txt..."
|
||||
"${VENV_DIR}/bin/pip" install -r _requirements.txt
|
||||
else
|
||||
echo "[installation/main] No requirements.txt or _requirements.txt found. Skipping dependency installation."
|
||||
fi
|
||||
echo "[installation/main] Creating/updating virtualenv via helper..."
|
||||
PKGMGR_VENV_DIR="${VENV_DIR}" bash scripts/installation/venv-create.sh
|
||||
|
||||
echo "[installation/main] Ensuring ~/.bashrc and ~/.zshrc exist..."
|
||||
touch "${HOME}/.bashrc" "${HOME}/.zshrc"
|
||||
|
||||
44
scripts/installation/venv-create.sh
Normal file
44
scripts/installation/venv-create.sh
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# venv-create.sh
|
||||
#
|
||||
# Small helper to create/update a Python virtual environment for pkgmgr.
|
||||
#
|
||||
# Usage:
|
||||
# PKGMGR_VENV_DIR=/home/dev/.venvs/pkgmgr bash scripts/installation/venv-create.sh
|
||||
# or
|
||||
# bash scripts/installation/venv-create.sh /home/dev/.venvs/pkgmgr
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
||||
VENV_DIR="${PKGMGR_VENV_DIR:-${1:-${HOME}/.venvs/pkgmgr}}"
|
||||
|
||||
echo "[venv-create] Using VENV_DIR=${VENV_DIR}"
|
||||
|
||||
echo "[venv-create] Ensuring virtualenv parent directory exists..."
|
||||
mkdir -p "$(dirname "${VENV_DIR}")"
|
||||
|
||||
if [[ ! -d "${VENV_DIR}" ]]; then
|
||||
echo "[venv-create] Creating virtual environment at: ${VENV_DIR}"
|
||||
python3 -m venv "${VENV_DIR}"
|
||||
else
|
||||
echo "[venv-create] Virtual environment already exists at: ${VENV_DIR}"
|
||||
fi
|
||||
|
||||
echo "[venv-create] Installing Python tooling into venv..."
|
||||
"${VENV_DIR}/bin/python" -m ensurepip --upgrade
|
||||
"${VENV_DIR}/bin/pip" install --upgrade pip setuptools wheel
|
||||
|
||||
if [[ -f "requirements.txt" ]]; then
|
||||
echo "[venv-create] Installing dependencies from requirements.txt..."
|
||||
"${VENV_DIR}/bin/pip" install -r requirements.txt
|
||||
elif [[ -f "_requirements.txt" ]]; then
|
||||
echo "[venv-create] Installing dependencies from _requirements.txt..."
|
||||
"${VENV_DIR}/bin/pip" install -r _requirements.txt
|
||||
else
|
||||
echo "[venv-create] No requirements.txt or _requirements.txt found. Skipping dependency installation."
|
||||
fi
|
||||
|
||||
echo "[venv-create] Done."
|
||||
@@ -19,8 +19,9 @@ for distro in $DISTROS; do
|
||||
# Run the command and capture the output
|
||||
if OUTPUT=$(docker run --rm \
|
||||
-e PKGMGR_DEV=1 \
|
||||
-v pkgmgr_nix_store_${distro}:/nix \
|
||||
-v "$(pwd):/src" \
|
||||
-v "pkgmgr_nix_cache:/root/.cache/nix" \
|
||||
-v "pkgmgr_nix_cache_${distro}:/root/.cache/nix" \
|
||||
"$IMAGE" 2>&1); then
|
||||
echo "$OUTPUT"
|
||||
echo
|
||||
|
||||
@@ -8,49 +8,48 @@ for distro in $DISTROS; do
|
||||
echo ">>> Running E2E tests: $distro"
|
||||
echo "============================================================"
|
||||
|
||||
MOUNT_NIX=""
|
||||
if [[ "$distro" == "arch" ]]; then
|
||||
MOUNT_NIX="-v pkgmgr_nix_store:/nix"
|
||||
fi
|
||||
|
||||
docker run --rm \
|
||||
-v "$(pwd):/src" \
|
||||
$MOUNT_NIX \
|
||||
-v "pkgmgr_nix_cache:/root/.cache/nix" \
|
||||
-v pkgmgr_nix_store_${distro}:/nix \
|
||||
-v "pkgmgr_nix_cache_${distro}:/root/.cache/nix" \
|
||||
-e PKGMGR_DEV=1 \
|
||||
-e TEST_PATTERN="${TEST_PATTERN}" \
|
||||
--workdir /src \
|
||||
--entrypoint bash \
|
||||
"package-manager-test-$distro" \
|
||||
-c '
|
||||
set -e;
|
||||
set -e
|
||||
|
||||
# Load distro info
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release;
|
||||
fi;
|
||||
. /etc/os-release
|
||||
fi
|
||||
|
||||
echo "Running tests inside distro: $ID";
|
||||
echo "Running tests inside distro: $ID"
|
||||
|
||||
# Try to load nix environment
|
||||
# Load nix environment if available
|
||||
if [ -f "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh" ]; then
|
||||
. "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh";
|
||||
. "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh"
|
||||
fi
|
||||
|
||||
if [ -f "$HOME/.nix-profile/etc/profile.d/nix.sh" ]; then
|
||||
. "$HOME/.nix-profile/etc/profile.d/nix.sh";
|
||||
. "$HOME/.nix-profile/etc/profile.d/nix.sh"
|
||||
fi
|
||||
|
||||
PATH="/nix/var/nix/profiles/default/bin:$HOME/.nix-profile/bin:$PATH";
|
||||
PATH="/nix/var/nix/profiles/default/bin:$HOME/.nix-profile/bin:$PATH"
|
||||
|
||||
command -v nix >/dev/null || {
|
||||
echo "ERROR: nix not found.";
|
||||
exit 1;
|
||||
echo "ERROR: nix not found."
|
||||
exit 1
|
||||
}
|
||||
|
||||
git config --global --add safe.directory /src || true;
|
||||
# Mark the mounted repository as safe to avoid Git ownership errors
|
||||
git config --global --add safe.directory /src || true
|
||||
|
||||
# Run the E2E tests inside the Nix development shell
|
||||
nix develop .#default --no-write-lock-file -c \
|
||||
python3 -m unittest discover \
|
||||
-s /src/tests/e2e \
|
||||
-p "test_*.py";
|
||||
-p "$TEST_PATTERN"
|
||||
'
|
||||
done
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${distro:=arch}"
|
||||
|
||||
echo "============================================================"
|
||||
echo ">>> Running INTEGRATION tests in Arch container"
|
||||
echo ">>> Running INTEGRATION tests in ${distro} container"
|
||||
echo "============================================================"
|
||||
|
||||
docker run --rm \
|
||||
-v "$(pwd):/src" \
|
||||
-v "pkgmgr_nix_cache:/root/.cache/nix" \
|
||||
-v pkgmgr_nix_store_${distro}:/nix \
|
||||
-v "pkgmgr_nix_cache_${distro}:/root/.cache/nix" \
|
||||
--workdir /src \
|
||||
-e PKGMGR_DEV=1 \
|
||||
-e TEST_PATTERN="${TEST_PATTERN}" \
|
||||
--entrypoint bash \
|
||||
"package-manager-test-arch" \
|
||||
"package-manager-test-${distro}" \
|
||||
-c '
|
||||
set -e;
|
||||
git config --global --add safe.directory /src || true;
|
||||
@@ -19,5 +23,5 @@ docker run --rm \
|
||||
python -m unittest discover \
|
||||
-s tests/integration \
|
||||
-t /src \
|
||||
-p "test_*.py";
|
||||
-p "$TEST_PATTERN";
|
||||
'
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${distro:=arch}"
|
||||
|
||||
echo "============================================================"
|
||||
echo ">>> Running UNIT tests in Arch container"
|
||||
echo ">>> Running UNIT tests in ${distro} container"
|
||||
echo "============================================================"
|
||||
|
||||
docker run --rm \
|
||||
-v "$(pwd):/src" \
|
||||
-v "pkgmgr_nix_cache:/root/.cache/nix" \
|
||||
-v "pkgmgr_nix_cache_${distro}:/root/.cache/nix" \
|
||||
-v pkgmgr_nix_store_${distro}:/nix \
|
||||
--workdir /src \
|
||||
-e PKGMGR_DEV=1 \
|
||||
-e TEST_PATTERN="${TEST_PATTERN}" \
|
||||
--entrypoint bash \
|
||||
"package-manager-test-arch" \
|
||||
"package-manager-test-${distro}" \
|
||||
-c '
|
||||
set -e;
|
||||
git config --global --add safe.directory /src || true;
|
||||
@@ -19,5 +23,5 @@ docker run --rm \
|
||||
python -m unittest discover \
|
||||
-s tests/unit \
|
||||
-t /src \
|
||||
-p "test_*.py";
|
||||
-p "$TEST_PATTERN";
|
||||
'
|
||||
|
||||
115
tests/e2e/test_clone_all.py
Normal file
115
tests/e2e/test_clone_all.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Integration test: clone all configured repositories using
|
||||
--clone-mode https and --no-verification.
|
||||
|
||||
This test is intended to be run inside the Docker container where:
|
||||
- network access is available,
|
||||
- the config/config.yaml is present,
|
||||
- and it is safe to perform real git operations.
|
||||
|
||||
It passes if the command completes without raising an exception.
|
||||
"""
|
||||
|
||||
import runpy
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from test_install_pkgmgr_shallow import (
|
||||
nix_profile_list_debug,
|
||||
remove_pkgmgr_from_nix_profile,
|
||||
pkgmgr_help_debug,
|
||||
)
|
||||
|
||||
|
||||
class TestIntegrationCloneAllHttps(unittest.TestCase):
|
||||
def _run_pkgmgr_clone_all_https(self) -> None:
|
||||
"""
|
||||
Helper that runs the CLI command via main.py and provides
|
||||
extra diagnostics if the command exits with a non-zero code.
|
||||
|
||||
Note:
|
||||
The pkgmgr CLI may exit via SystemExit(0) on success
|
||||
(e.g. when handled by the proxy layer). In that case we
|
||||
treat the test as successful and do not raise.
|
||||
"""
|
||||
cmd_repr = "pkgmgr clone --all --clone-mode https --no-verification"
|
||||
original_argv = sys.argv
|
||||
try:
|
||||
sys.argv = [
|
||||
"pkgmgr",
|
||||
"clone",
|
||||
"--all",
|
||||
"--clone-mode",
|
||||
"https",
|
||||
"--no-verification",
|
||||
]
|
||||
|
||||
try:
|
||||
# Execute main.py as if it was called from CLI.
|
||||
# This will run the full clone pipeline inside the container.
|
||||
runpy.run_module("main", run_name="__main__")
|
||||
except SystemExit as exc:
|
||||
# Determine the exit code (int or string)
|
||||
exit_code = exc.code
|
||||
if isinstance(exit_code, int):
|
||||
numeric_code = exit_code
|
||||
else:
|
||||
try:
|
||||
numeric_code = int(exit_code)
|
||||
except (TypeError, ValueError):
|
||||
numeric_code = None
|
||||
|
||||
# Treat SystemExit(0) as success (expected behavior)
|
||||
if numeric_code == 0:
|
||||
print(
|
||||
"\n[TEST] pkgmgr clone --all finished with SystemExit(0); "
|
||||
"treating as success."
|
||||
)
|
||||
return
|
||||
|
||||
# For non-zero exit codes: convert SystemExit into a more
|
||||
# helpful assertion with debug output.
|
||||
print("\n[TEST] pkgmgr clone --all failed with SystemExit")
|
||||
print(f"[TEST] Command : {cmd_repr}")
|
||||
print(f"[TEST] Exit code: {exit_code!r}")
|
||||
|
||||
# Additional Nix profile debug on failure (may still be useful
|
||||
# if the clone step interacts with Nix-based tooling).
|
||||
nix_profile_list_debug("ON FAILURE (AFTER SystemExit)")
|
||||
|
||||
raise AssertionError(
|
||||
f"{cmd_repr!r} failed with exit code {exit_code!r}. "
|
||||
"Scroll up to see the full pkgmgr/make output inside the container."
|
||||
) from exc
|
||||
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
def test_clone_all_repositories_https(self) -> None:
|
||||
"""
|
||||
Run: pkgmgr clone --all --clone-mode https --no-verification
|
||||
|
||||
This will perform real git clone operations inside the container.
|
||||
The test succeeds if no exception is raised and `pkgmgr --help`
|
||||
works in a fresh interactive bash session afterwards.
|
||||
"""
|
||||
# Debug before cleanup (reusing the same helpers as the install test).
|
||||
nix_profile_list_debug("BEFORE CLEANUP")
|
||||
|
||||
# Cleanup: aggressively try to drop any pkgmgr/profile entries
|
||||
# (harmless for a pure clone test but keeps environments comparable).
|
||||
remove_pkgmgr_from_nix_profile()
|
||||
|
||||
# Debug after cleanup
|
||||
nix_profile_list_debug("AFTER CLEANUP")
|
||||
|
||||
# Run the actual clone with extended diagnostics
|
||||
self._run_pkgmgr_clone_all_https()
|
||||
|
||||
# After successful clone: show `pkgmgr --help`
|
||||
# via interactive bash (same helper as in the install test).
|
||||
pkgmgr_help_debug()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -35,8 +35,8 @@ def remove_pkgmgr_from_nix_profile() -> None:
|
||||
prints a descriptive format without an index column inside the container.
|
||||
|
||||
Instead, we directly try to remove possible names:
|
||||
- 'pkgmgr' (the actual name shown in `nix profile list`)
|
||||
- 'package-manager' (the name mentioned in Nix's own error hints)
|
||||
- 'pkgmgr'
|
||||
- 'package-manager'
|
||||
"""
|
||||
for spec in ("pkgmgr", "package-manager"):
|
||||
subprocess.run(
|
||||
@@ -45,18 +45,34 @@ def remove_pkgmgr_from_nix_profile() -> None:
|
||||
)
|
||||
|
||||
|
||||
def configure_git_safe_directory() -> None:
|
||||
"""
|
||||
Configure Git to treat /src as a safe directory.
|
||||
|
||||
Needed because /src is a bind-mounted repository in CI, often owned by a
|
||||
different UID. Modern Git aborts with:
|
||||
'fatal: detected dubious ownership in repository at /src/.git'
|
||||
|
||||
This fix applies ONLY inside this test container.
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "config", "--global", "--add", "safe.directory", "/src"],
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
print("[WARN] git not found – skipping safe.directory configuration")
|
||||
|
||||
|
||||
def pkgmgr_help_debug() -> None:
|
||||
"""
|
||||
Run `pkgmgr --help` after installation *inside an interactive bash shell*,
|
||||
print its output and return code, but never fail the test.
|
||||
|
||||
Reason:
|
||||
- The installer adds venv/alias setup into shell rc files (~/.bashrc, ~/.zshrc)
|
||||
- Those changes are only applied in a new interactive shell session.
|
||||
This ensures the installer’s shell RC changes are actually loaded.
|
||||
"""
|
||||
print("\n--- PKGMGR HELP (after installation, via bash -i) ---")
|
||||
|
||||
# Simulate a fresh interactive bash, so ~/.bashrc gets sourced
|
||||
proc = subprocess.run(
|
||||
["bash", "-i", "-c", "pkgmgr --help"],
|
||||
capture_output=True,
|
||||
@@ -76,29 +92,43 @@ def pkgmgr_help_debug() -> None:
|
||||
print(f"returncode: {proc.returncode}")
|
||||
print("--- END ---\n")
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise AssertionError(f"'pkgmgr --help' failed with exit code {proc.returncode}")
|
||||
|
||||
# Wichtig: Hier KEIN AssertionError mehr – das ist reine Debug-Ausgabe.
|
||||
# Falls du später hart testen willst, kannst du optional:
|
||||
# if proc.returncode != 0:
|
||||
# self.fail("...")
|
||||
# aber aktuell nur Sichtprüfung.
|
||||
|
||||
|
||||
class TestIntegrationInstalPKGMGRShallow(unittest.TestCase):
|
||||
def test_install_pkgmgr_self_install(self) -> None:
|
||||
# Debug before cleanup
|
||||
nix_profile_list_debug("BEFORE CLEANUP")
|
||||
"""
|
||||
End-to-end test that runs "python main.py install pkgmgr ..." inside
|
||||
the test container.
|
||||
|
||||
# Cleanup: aggressively try to drop any pkgmgr/profile entries
|
||||
remove_pkgmgr_from_nix_profile()
|
||||
|
||||
# Debug after cleanup
|
||||
nix_profile_list_debug("AFTER CLEANUP")
|
||||
HOME is isolated to avoid permission problems with Nix & repositories.
|
||||
"""
|
||||
temp_home = "/tmp/pkgmgr-self-install"
|
||||
os.makedirs(temp_home, exist_ok=True)
|
||||
|
||||
original_argv = sys.argv
|
||||
original_environ = os.environ.copy()
|
||||
|
||||
try:
|
||||
# Isolate HOME so that ~ expands to /tmp/pkgmgr-self-install
|
||||
os.environ["HOME"] = temp_home
|
||||
|
||||
# Optional XDG override for a fully isolated environment
|
||||
os.environ.setdefault("XDG_CONFIG_HOME", os.path.join(temp_home, ".config"))
|
||||
os.environ.setdefault("XDG_CACHE_HOME", os.path.join(temp_home, ".cache"))
|
||||
os.environ.setdefault("XDG_DATA_HOME", os.path.join(temp_home, ".local", "share"))
|
||||
|
||||
# 🔧 IMPORTANT FIX: allow Git to access /src safely
|
||||
configure_git_safe_directory()
|
||||
|
||||
# Debug before cleanup
|
||||
nix_profile_list_debug("BEFORE CLEANUP")
|
||||
|
||||
# Cleanup: drop any pkgmgr entries from nix profile
|
||||
remove_pkgmgr_from_nix_profile()
|
||||
|
||||
# Debug after cleanup
|
||||
nix_profile_list_debug("AFTER CLEANUP")
|
||||
|
||||
# Prepare argv for module execution
|
||||
sys.argv = [
|
||||
"python",
|
||||
"install",
|
||||
@@ -107,13 +137,18 @@ class TestIntegrationInstalPKGMGRShallow(unittest.TestCase):
|
||||
"shallow",
|
||||
"--no-verification",
|
||||
]
|
||||
# Führt die Installation via main.py aus
|
||||
|
||||
# Execute installation via main.py
|
||||
runpy.run_module("main", run_name="__main__")
|
||||
|
||||
# Nach erfolgreicher Installation: pkgmgr --help anzeigen (Debug)
|
||||
# Debug: interactive shell test
|
||||
pkgmgr_help_debug()
|
||||
|
||||
finally:
|
||||
# Restore system state
|
||||
sys.argv = original_argv
|
||||
os.environ.clear()
|
||||
os.environ.update(original_environ)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
117
tests/e2e/test_path_commands.py
Normal file
117
tests/e2e/test_path_commands.py
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
End-to-end tests for the `pkgmgr path` command.
|
||||
|
||||
We verify two usage patterns:
|
||||
|
||||
1) pkgmgr path --all
|
||||
- Should print the paths of all configured repositories.
|
||||
|
||||
2) pkgmgr path pkgmgr
|
||||
- Should print the path for the repository identified as "pkgmgr".
|
||||
|
||||
Both tests are considered successful if the command completes without
|
||||
raising an exception and exits with code 0 (or no explicit exit code).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import runpy
|
||||
import sys
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
|
||||
class TestPathCommandsE2E(unittest.TestCase):
|
||||
def _run_pkgmgr_path(self, argv_tail: list[str]) -> str:
|
||||
"""
|
||||
Helper to run `pkgmgr path ...` via main.py and return stdout.
|
||||
|
||||
Args:
|
||||
argv_tail: List of arguments that follow the "pkgmgr" executable,
|
||||
e.g. ["path", "--all"] or ["path", "pkgmgr"].
|
||||
|
||||
Returns:
|
||||
The captured stdout produced by the command.
|
||||
|
||||
Raises:
|
||||
AssertionError if the command exits with a non-zero exit code.
|
||||
"""
|
||||
original_argv = sys.argv
|
||||
cmd_repr = "pkgmgr " + " ".join(argv_tail)
|
||||
buffer = io.StringIO()
|
||||
|
||||
try:
|
||||
sys.argv = ["pkgmgr"] + argv_tail
|
||||
|
||||
try:
|
||||
# Capture stdout while running the CLI entry point.
|
||||
with redirect_stdout(buffer):
|
||||
runpy.run_module("main", run_name="__main__")
|
||||
except SystemExit as exc:
|
||||
# Determine the exit code (int or string)
|
||||
exit_code = exc.code
|
||||
if isinstance(exit_code, int):
|
||||
numeric_code = exit_code
|
||||
else:
|
||||
try:
|
||||
numeric_code = int(exit_code)
|
||||
except (TypeError, ValueError):
|
||||
numeric_code = None
|
||||
|
||||
# Treat SystemExit(0) as success.
|
||||
if numeric_code == 0 or numeric_code is None:
|
||||
return buffer.getvalue()
|
||||
|
||||
# Non-zero exit code → fail with helpful message.
|
||||
raise AssertionError(
|
||||
f"{cmd_repr!r} failed with exit code {exit_code!r}. "
|
||||
"Scroll up to see the full pkgmgr output inside the container."
|
||||
) from exc
|
||||
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
# No SystemExit raised → also treat as success.
|
||||
return buffer.getvalue()
|
||||
|
||||
def test_path_all_repositories(self) -> None:
|
||||
"""
|
||||
Run: pkgmgr path --all
|
||||
|
||||
The test succeeds if the command exits successfully and prints
|
||||
at least one non-empty line.
|
||||
"""
|
||||
output = self._run_pkgmgr_path(["path", "--all"])
|
||||
lines = [line for line in output.splitlines() if line.strip()]
|
||||
|
||||
# We only assert that something was printed; we do not assume
|
||||
# that repositories are already cloned on disk.
|
||||
self.assertGreater(
|
||||
len(lines),
|
||||
0,
|
||||
msg="Expected `pkgmgr path --all` to print at least one path.",
|
||||
)
|
||||
|
||||
def test_path_single_pkgmgr(self) -> None:
|
||||
"""
|
||||
Run: pkgmgr path pkgmgr
|
||||
|
||||
The test succeeds if the command exits successfully and prints
|
||||
at least one non-empty line (the resolved directory).
|
||||
"""
|
||||
output = self._run_pkgmgr_path(["path", "pkgmgr"])
|
||||
lines = [line for line in output.splitlines() if line.strip()]
|
||||
|
||||
self.assertGreater(
|
||||
len(lines),
|
||||
0,
|
||||
msg="Expected `pkgmgr path pkgmgr` to print at least one path.",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
74
tests/e2e/test_tools_help.py
Normal file
74
tests/e2e/test_tools_help.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
E2E/Integration tests for the tool-related subcommands' --help output.
|
||||
|
||||
We assert that calling:
|
||||
- pkgmgr explore --help
|
||||
- pkgmgr terminal --help
|
||||
- pkgmgr code --help
|
||||
|
||||
completes successfully. For --help, argparse exits with SystemExit(0),
|
||||
which we treat as success and suppress in the helper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import runpy
|
||||
import sys
|
||||
import unittest
|
||||
from typing import List
|
||||
|
||||
|
||||
# Resolve project root (the repo where main.py lives, e.g. /src)
|
||||
PROJECT_ROOT = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..")
|
||||
)
|
||||
MAIN_PATH = os.path.join(PROJECT_ROOT, "main.py")
|
||||
|
||||
|
||||
def _run_main(argv: List[str]) -> None:
|
||||
"""
|
||||
Helper to run main.py with the given argv.
|
||||
|
||||
This mimics a "pkgmgr ..." invocation in the E2E container.
|
||||
|
||||
For --help invocations, argparse will call sys.exit(0), which raises
|
||||
SystemExit(0). We treat this as success and only re-raise non-zero
|
||||
exit codes.
|
||||
"""
|
||||
old_argv = sys.argv
|
||||
try:
|
||||
sys.argv = ["pkgmgr"] + argv
|
||||
try:
|
||||
runpy.run_path(MAIN_PATH, run_name="__main__")
|
||||
except SystemExit as exc: # argparse uses this for --help
|
||||
# SystemExit.code can be int, str or None; for our purposes:
|
||||
code = exc.code
|
||||
if code not in (0, None):
|
||||
# Non-zero exit code -> real error.
|
||||
raise
|
||||
# For 0/None: treat as success and swallow the exception.
|
||||
finally:
|
||||
sys.argv = old_argv
|
||||
|
||||
|
||||
class TestToolsHelp(unittest.TestCase):
|
||||
"""
|
||||
E2E/Integration tests for tool commands' --help screens.
|
||||
"""
|
||||
|
||||
def test_explore_help(self) -> None:
|
||||
"""Ensure `pkgmgr explore --help` runs successfully."""
|
||||
_run_main(["explore", "--help"])
|
||||
|
||||
def test_terminal_help(self) -> None:
|
||||
"""Ensure `pkgmgr terminal --help` runs successfully."""
|
||||
_run_main(["terminal", "--help"])
|
||||
|
||||
def test_code_help(self) -> None:
|
||||
"""Ensure `pkgmgr code --help` runs successfully."""
|
||||
_run_main(["code", "--help"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Integration test: install all configured repositories using
|
||||
--clone-mode shallow (HTTPS shallow clone) and --no-verification.
|
||||
Integration test: update all configured repositories using
|
||||
--clone-mode https and --no-verification.
|
||||
|
||||
This test is intended to be run inside the Docker container where:
|
||||
- network access is available,
|
||||
@@ -21,37 +21,38 @@ from test_install_pkgmgr_shallow import (
|
||||
)
|
||||
|
||||
|
||||
class TestIntegrationInstallAllShallow(unittest.TestCase):
|
||||
def _run_pkgmgr_install_all(self) -> None:
|
||||
class TestIntegrationUpdateAllHttps(unittest.TestCase):
|
||||
def _run_pkgmgr_update_all_https(self) -> None:
|
||||
"""
|
||||
Helper that runs the CLI command via main.py and provides
|
||||
extra diagnostics if the command exits with a non-zero code.
|
||||
"""
|
||||
cmd_repr = "pkgmgr install --all --clone-mode shallow --no-verification"
|
||||
cmd_repr = "pkgmgr update --all --clone-mode https --no-verification"
|
||||
original_argv = sys.argv
|
||||
try:
|
||||
sys.argv = [
|
||||
"pkgmgr",
|
||||
"install",
|
||||
"update",
|
||||
"--all",
|
||||
"--clone-mode",
|
||||
"shallow",
|
||||
"https",
|
||||
"--no-verification",
|
||||
]
|
||||
|
||||
try:
|
||||
# Execute main.py as if it was called from CLI.
|
||||
# This will run the full install pipeline inside the container.
|
||||
# This will run the full update pipeline inside the container.
|
||||
runpy.run_module("main", run_name="__main__")
|
||||
except SystemExit as exc:
|
||||
# Convert SystemExit into a more helpful assertion with debug output.
|
||||
exit_code = exc.code if isinstance(exc.code, int) else str(exc.code)
|
||||
|
||||
print("\n[TEST] pkgmgr install --all failed with SystemExit")
|
||||
print("\n[TEST] pkgmgr update --all failed with SystemExit")
|
||||
print(f"[TEST] Command : {cmd_repr}")
|
||||
print(f"[TEST] Exit code: {exit_code}")
|
||||
|
||||
# Additional Nix profile debug on failure
|
||||
# Additional Nix profile debug on failure (useful if any update
|
||||
# step interacts with Nix-based tooling).
|
||||
nix_profile_list_debug("ON FAILURE (AFTER SystemExit)")
|
||||
|
||||
raise AssertionError(
|
||||
@@ -62,11 +63,11 @@ class TestIntegrationInstallAllShallow(unittest.TestCase):
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
def test_install_all_repositories_shallow(self) -> None:
|
||||
def test_update_all_repositories_https(self) -> None:
|
||||
"""
|
||||
Run: pkgmgr install --all --clone-mode shallow --no-verification
|
||||
Run: pkgmgr update --all --clone-mode https --no-verification
|
||||
|
||||
This will perform real installations/clones inside the container.
|
||||
This will perform real git update operations inside the container.
|
||||
The test succeeds if no exception is raised and `pkgmgr --help`
|
||||
works in a fresh interactive bash session afterwards.
|
||||
"""
|
||||
@@ -74,16 +75,17 @@ class TestIntegrationInstallAllShallow(unittest.TestCase):
|
||||
nix_profile_list_debug("BEFORE CLEANUP")
|
||||
|
||||
# Cleanup: aggressively try to drop any pkgmgr/profile entries
|
||||
# (keeps the environment comparable to other integration tests).
|
||||
remove_pkgmgr_from_nix_profile()
|
||||
|
||||
# Debug after cleanup
|
||||
nix_profile_list_debug("AFTER CLEANUP")
|
||||
|
||||
# Run the actual install with extended diagnostics
|
||||
self._run_pkgmgr_install_all()
|
||||
# Run the actual update with extended diagnostics
|
||||
self._run_pkgmgr_update_all_https()
|
||||
|
||||
# After successful installation: show `pkgmgr --help`
|
||||
# via interactive bash (same as the pkgmgr-only test).
|
||||
# After successful update: show `pkgmgr --help`
|
||||
# via interactive bash (same helper as in the other integration tests).
|
||||
pkgmgr_help_debug()
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import pkgmgr.actions.repository.install as install_module
|
||||
from pkgmgr.actions.repository.install import install_repos
|
||||
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
|
||||
import pkgmgr.actions.install as install_module
|
||||
from pkgmgr.actions.install import install_repos
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||
|
||||
|
||||
class DummyInstaller(BaseInstaller):
|
||||
@@ -16,49 +19,52 @@ class DummyInstaller(BaseInstaller):
|
||||
|
||||
layer = None
|
||||
|
||||
def supports(self, ctx):
|
||||
def supports(self, ctx): # type: ignore[override]
|
||||
return True
|
||||
|
||||
def run(self, ctx):
|
||||
def run(self, ctx): # type: ignore[override]
|
||||
return
|
||||
|
||||
|
||||
class TestInstallReposIntegration(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.repository.install.verify_repository")
|
||||
@patch("pkgmgr.actions.repository.install.clone_repos")
|
||||
@patch("pkgmgr.actions.repository.install.get_repo_dir")
|
||||
@patch("pkgmgr.actions.repository.install.get_repo_identifier")
|
||||
@patch("pkgmgr.actions.install.verify_repository")
|
||||
@patch("pkgmgr.actions.install.clone_repos")
|
||||
@patch("pkgmgr.actions.install.get_repo_dir")
|
||||
@patch("pkgmgr.actions.install.get_repo_identifier")
|
||||
def test_system_binary_vs_nix_binary(
|
||||
self,
|
||||
mock_get_repo_identifier,
|
||||
mock_get_repo_dir,
|
||||
mock_clone_repos,
|
||||
mock_verify_repository,
|
||||
):
|
||||
) -> None:
|
||||
"""
|
||||
Full integration test for high-level command resolution + symlink creation.
|
||||
Integration test:
|
||||
|
||||
We do NOT re-test all low-level file-system details of
|
||||
resolve_command_for_repo here (that is covered by unit tests).
|
||||
Instead, we assert that:
|
||||
We do NOT re-test the low-level implementation details of
|
||||
resolve_command_for_repo() here (that is covered by unit tests).
|
||||
|
||||
- If resolve_command_for_repo(...) returns None:
|
||||
→ install_repos() does NOT create a symlink.
|
||||
Instead, we assert the high-level behavior of install_repos() +
|
||||
InstallationPipeline + create_ink():
|
||||
|
||||
- If resolve_command_for_repo(...) returns a path:
|
||||
→ install_repos() creates exactly one symlink in bin_dir
|
||||
* If resolve_command_for_repo(...) returns None:
|
||||
→ install_repos() must NOT create a symlink for that repo.
|
||||
|
||||
* If resolve_command_for_repo(...) returns a path:
|
||||
→ install_repos() must create exactly one symlink in bin_dir
|
||||
that points to this path.
|
||||
|
||||
Concretely:
|
||||
Concretely in this test:
|
||||
|
||||
- repo-system:
|
||||
resolve_command_for_repo(...) → None
|
||||
* repo-system:
|
||||
fake resolver → returns None
|
||||
→ no symlink in bin_dir for this repo.
|
||||
|
||||
- repo-nix:
|
||||
resolve_command_for_repo(...) → "/nix/profile/bin/repo-nix"
|
||||
* repo-nix:
|
||||
fake resolver → returns "/nix/profile/bin/repo-nix"
|
||||
→ exactly one symlink in bin_dir pointing to that path.
|
||||
"""
|
||||
|
||||
# Repositories must have provider/account/repository so that get_repo_dir()
|
||||
# does not crash when called from create_ink().
|
||||
repo_system = {
|
||||
@@ -77,9 +83,7 @@ class TestInstallReposIntegration(unittest.TestCase):
|
||||
selected_repos = [repo_system, repo_nix]
|
||||
all_repos = selected_repos
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_base, \
|
||||
tempfile.TemporaryDirectory() as tmp_bin:
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_base, tempfile.TemporaryDirectory() as tmp_bin:
|
||||
# Fake repo directories (what get_repo_dir will return)
|
||||
repo_system_dir = os.path.join(tmp_base, "repo-system")
|
||||
repo_nix_dir = os.path.join(tmp_base, "repo-nix")
|
||||
@@ -97,11 +101,15 @@ class TestInstallReposIntegration(unittest.TestCase):
|
||||
# Pretend this is the "Nix binary" path for repo-nix
|
||||
nix_tool_path = "/nix/profile/bin/repo-nix"
|
||||
|
||||
# Patch resolve_command_for_repo at the install_repos module level
|
||||
with patch("pkgmgr.actions.repository.install.resolve_command_for_repo") as mock_resolve, \
|
||||
patch("pkgmgr.actions.repository.install.os.path.exists") as mock_exists_install:
|
||||
# Patch resolve_command_for_repo at the *pipeline* module level,
|
||||
# because InstallationPipeline imports it there.
|
||||
with patch(
|
||||
"pkgmgr.actions.install.pipeline.resolve_command_for_repo"
|
||||
) as mock_resolve, patch(
|
||||
"pkgmgr.actions.install.os.path.exists"
|
||||
) as mock_exists_install:
|
||||
|
||||
def fake_resolve_command(repo, repo_identifier: str, repo_dir: str):
|
||||
def fake_resolve(repo, repo_identifier: str, repo_dir: str):
|
||||
"""
|
||||
High-level behavior stub:
|
||||
|
||||
@@ -111,9 +119,10 @@ class TestInstallReposIntegration(unittest.TestCase):
|
||||
- For repo-nix: act as if a Nix profile binary is the entrypoint
|
||||
→ return nix_tool_path (symlink should be created).
|
||||
"""
|
||||
if repo_identifier == "repo-system":
|
||||
name = repo.get("name")
|
||||
if name == "repo-system":
|
||||
return None
|
||||
if repo_identifier == "repo-nix":
|
||||
if name == "repo-nix":
|
||||
return nix_tool_path
|
||||
return None
|
||||
|
||||
@@ -126,7 +135,7 @@ class TestInstallReposIntegration(unittest.TestCase):
|
||||
return True
|
||||
return False
|
||||
|
||||
mock_resolve.side_effect = fake_resolve_command
|
||||
mock_resolve.side_effect = fake_resolve
|
||||
mock_exists_install.side_effect = fake_exists_install
|
||||
|
||||
# Use only DummyInstaller so we focus on link creation, not installer behavior
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
# Capability Resolution & Installer Shadowing
|
||||
|
||||
## Layer Hierarchy
|
||||
This document explains how `pkgmgr` decides **which installer should run** when multiple installation mechanisms are available in a repository.
|
||||
It reflects the logic shown in the setup-controller diagram:
|
||||
|
||||
➡️ **Full graphical schema:** [https://s.veen.world/pkgmgrmp](https://s.veen.world/pkgmgrmp)
|
||||
|
||||
---
|
||||
|
||||
## Layer Hierarchy (Strength Order)
|
||||
|
||||
Installers are evaluated from **strongest to weakest**.
|
||||
A stronger layer shadows all layers below it.
|
||||
|
||||
```
|
||||
┌───────────────────────────┐ Highest layer
|
||||
@@ -22,7 +32,24 @@
|
||||
|
||||
---
|
||||
|
||||
## Scenario Matrix
|
||||
## Capability Matrix
|
||||
|
||||
Each layer provides a set of **capabilities**.
|
||||
Layers that provide *all* capabilities of a lower layer **shadow** that layer.
|
||||
|
||||
| Capability | Makefile | Python | Nix | OS-Pkgs |
|
||||
| -------------------- | -------- | ------------ | --- | ------- |
|
||||
| `make-install` | ✔ | (optional) ✔ | ✔ | ✔ |
|
||||
| `python-runtime` | – | ✔ | ✔ | ✔ |
|
||||
| `binary/cli` | – | – | ✔ | ✔ |
|
||||
| `system-integration` | – | – | – | ✔ |
|
||||
|
||||
✔ = capability available
|
||||
– = not provided by this layer
|
||||
|
||||
---
|
||||
|
||||
## Scenario Matrix (Expected Installer Execution)
|
||||
|
||||
| Scenario | Makefile | Python | Nix | OS-Pkgs | Test Name |
|
||||
| -------------------------- | -------- | ------ | --- | ------- | ----------------------------- |
|
||||
@@ -34,40 +61,41 @@
|
||||
|
||||
Legend:
|
||||
✔ = installer runs
|
||||
✗ = installer skipped (shadowed by upper layer)
|
||||
– = no such layer present
|
||||
✗ = installer is skipped (shadowed)
|
||||
– = layer not present in this scenario
|
||||
|
||||
---
|
||||
|
||||
## What the Integration Test Confirms
|
||||
|
||||
**Goal:** Validate that the capability-shadowing mechanism correctly determines *which installers actually run* for a given repository layout.
|
||||
The integration tests ensure that the **actual execution** matches the theoretical capability model.
|
||||
|
||||
### 1) Only Makefile
|
||||
|
||||
* Makefile provides `make-install`.
|
||||
* No higher layers → MakefileInstaller runs.
|
||||
* Only `Makefile` present
|
||||
→ MakefileInstaller runs.
|
||||
|
||||
### 2) Python + Makefile
|
||||
|
||||
* Python provides `python-runtime`.
|
||||
* Makefile additionally provides `make-install`.
|
||||
* No capability overlap → both installers run.
|
||||
* Python provides `python-runtime`
|
||||
* Makefile provides `make-install`
|
||||
→ Both run (capabilities are disjoint).
|
||||
|
||||
### 3) Python shadows Makefile
|
||||
|
||||
* Python also provides `make-install`.
|
||||
* Makefile’s capability is fully covered → MakefileInstaller is skipped.
|
||||
* Python additionally advertises `make-install`
|
||||
→ MakefileInstaller is skipped.
|
||||
|
||||
### 4) Nix shadows Python & Makefile
|
||||
|
||||
* Nix provides all capabilities below it.
|
||||
* Only NixInstaller runs.
|
||||
* Nix provides: `python-runtime` + `make-install`
|
||||
→ PythonInstaller and MakefileInstaller are skipped.
|
||||
→ Only NixInstaller runs.
|
||||
|
||||
### 5) OS-Packages shadow all
|
||||
### 5) OS-Pkg layer shadows all
|
||||
|
||||
* PKGBUILD/debian/rpm provide all capabilities.
|
||||
* Only the corresponding OS package installer runs.
|
||||
* OS packages provide all capabilities
|
||||
→ Only OS installer runs.
|
||||
|
||||
---
|
||||
|
||||
@@ -111,6 +139,14 @@ Legend:
|
||||
|
||||
---
|
||||
|
||||
## Core Principle (one sentence)
|
||||
## Core Principle
|
||||
|
||||
**A layer only executes if it provides at least one capability not already guaranteed by any higher layer.**
|
||||
**A layer is executed only if it contributes at least one capability that no stronger layer has already provided.**
|
||||
|
||||
---
|
||||
|
||||
## Link to the Setup Controller Diagram
|
||||
|
||||
The full visual schema is available here:
|
||||
|
||||
➡️ **[https://s.veen.world/pkgmgrmp](https://s.veen.world/pkgmgrmp)**
|
||||
|
||||
@@ -2,140 +2,99 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Integration tests for the recursive / layered capability handling in pkgmgr.
|
||||
Integration tests for recursive capability resolution and installer shadowing.
|
||||
|
||||
We focus on the interaction between:
|
||||
These tests verify that, given different repository layouts (Makefile, pyproject,
|
||||
flake.nix, PKGBUILD), only the expected installers are executed based on the
|
||||
capabilities provided by higher layers.
|
||||
|
||||
- MakefileInstaller (layer: "makefile")
|
||||
- PythonInstaller (layer: "python")
|
||||
- NixFlakeInstaller (layer: "nix")
|
||||
- ArchPkgbuildInstaller (layer: "os-packages")
|
||||
Layer order (strongest → weakest):
|
||||
|
||||
The core idea:
|
||||
|
||||
- Each installer declares logical capabilities for its layer via
|
||||
discover_capabilities() and the global CAPABILITY_MATCHERS.
|
||||
- install_repos() tracks which capabilities have already been provided
|
||||
by earlier installers (in INSTALLERS order).
|
||||
- If an installer only provides capabilities that are already covered
|
||||
by previous installers, it is skipped.
|
||||
|
||||
These tests use *real* capability detection (based on repo files like
|
||||
flake.nix, pyproject.toml, Makefile, PKGBUILD), but patch the installers'
|
||||
run() methods so that no real external commands are executed.
|
||||
|
||||
Scenarios:
|
||||
|
||||
1. Only Makefile with install target
|
||||
→ MakefileInstaller runs, all good.
|
||||
|
||||
2. Python + Makefile (no "make install" in pyproject.toml)
|
||||
→ PythonInstaller provides only python-runtime
|
||||
→ MakefileInstaller provides make-install
|
||||
→ Both run, since their capabilities are disjoint.
|
||||
|
||||
3. Python + Makefile (pyproject.toml mentions "make install")
|
||||
→ PythonInstaller provides {python-runtime, make-install}
|
||||
→ MakefileInstaller provides {make-install}
|
||||
→ MakefileInstaller is skipped (capabilities already covered).
|
||||
|
||||
4. Nix + Python + Makefile
|
||||
- flake.nix hints:
|
||||
* buildPythonApplication (python-runtime)
|
||||
* make install (make-install)
|
||||
→ NixFlakeInstaller provides {python-runtime, make-install, nix-flake}
|
||||
→ PythonInstaller and MakefileInstaller are skipped.
|
||||
|
||||
5. OS packages + Nix + Python + Makefile
|
||||
- PKGBUILD contains:
|
||||
* "pip install ." (python-runtime via os-packages)
|
||||
* "make install" (make-install via os-packages)
|
||||
* "nix profile" (nix-flake via os-packages)
|
||||
→ ArchPkgbuildInstaller provides all capabilities
|
||||
→ All lower layers are skipped.
|
||||
OS-PACKAGES > NIX > PYTHON > MAKEFILE
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from typing import List, Sequence, Tuple
|
||||
from unittest.mock import patch
|
||||
|
||||
import pkgmgr.actions.repository.install as install_mod
|
||||
from pkgmgr.actions.repository.install import install_repos
|
||||
from pkgmgr.actions.repository.install.installers.nix_flake import NixFlakeInstaller
|
||||
from pkgmgr.actions.repository.install.installers.python import PythonInstaller
|
||||
from pkgmgr.actions.repository.install.installers.makefile import MakefileInstaller
|
||||
from pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller
|
||||
import pkgmgr.actions.install as install_mod
|
||||
from pkgmgr.actions.install import install_repos
|
||||
from pkgmgr.actions.install.installers.makefile import MakefileInstaller
|
||||
from pkgmgr.actions.install.installers.nix_flake import NixFlakeInstaller
|
||||
from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import (
|
||||
ArchPkgbuildInstaller,
|
||||
)
|
||||
from pkgmgr.actions.install.installers.python import PythonInstaller
|
||||
|
||||
|
||||
InstallerSpec = Tuple[str, object]
|
||||
|
||||
|
||||
class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
# Temporary base directory for this test class
|
||||
self.tmp_root = tempfile.mkdtemp(prefix="pkgmgr-integration-")
|
||||
self.tmp_root = tempfile.mkdtemp(prefix="pkgmgr-recursive-caps-")
|
||||
self.bin_dir = os.path.join(self.tmp_root, "bin")
|
||||
os.makedirs(self.bin_dir, exist_ok=True)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
shutil.rmtree(self.tmp_root)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helper: create a new repo directory for a scenario
|
||||
# ------------------------------------------------------------------
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
def _new_repo(self) -> str:
|
||||
repo_dir = tempfile.mkdtemp(prefix="repo-", dir=self.tmp_root)
|
||||
return repo_dir
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helper: run install_repos() with a custom installer list
|
||||
# and record which installers actually ran.
|
||||
# ------------------------------------------------------------------
|
||||
def _run_with_installers(self, repo_dir: str, installers, selected_repos=None):
|
||||
"""
|
||||
Run install_repos() with a given INSTALLERS list and a single
|
||||
dummy repo; return the list of installer labels that actually ran.
|
||||
Create a fresh temporary repo directory under self.tmp_root.
|
||||
"""
|
||||
return tempfile.mkdtemp(prefix="repo-", dir=self.tmp_root)
|
||||
|
||||
The installers' supports() are forced to True so that only the
|
||||
capability-shadowing logic decides whether they are skipped.
|
||||
The installers' run() methods are patched to avoid real commands.
|
||||
def _run_with_installers(
|
||||
self,
|
||||
repo_dir: str,
|
||||
installers: Sequence[InstallerSpec],
|
||||
selected_repos=None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Run install_repos() with a custom INSTALLERS list and capture which
|
||||
installer labels actually run.
|
||||
|
||||
NOTE:
|
||||
We patch resolve_command_for_repo() to always return a dummy
|
||||
command path so that command resolution does not interfere with
|
||||
capability-layering tests.
|
||||
We override each installer's supports() to always return True and
|
||||
override run() to append its label to called_installers.
|
||||
"""
|
||||
if selected_repos is None:
|
||||
repo = {}
|
||||
repo = {"repository": "dummy"}
|
||||
selected_repos = [repo]
|
||||
all_repos = [repo]
|
||||
else:
|
||||
all_repos = selected_repos
|
||||
|
||||
called_installers: list[str] = []
|
||||
called_installers: List[str] = []
|
||||
|
||||
# Prepare patched instances with recording run() and always-supports.
|
||||
patched_installers = []
|
||||
for label, inst in installers:
|
||||
def always_supports(self, ctx):
|
||||
return True
|
||||
|
||||
def make_run(label_name):
|
||||
def make_run(label_name: str):
|
||||
def _run(self, ctx):
|
||||
called_installers.append(label_name)
|
||||
return _run
|
||||
|
||||
inst.supports = always_supports.__get__(inst, inst.__class__)
|
||||
inst.run = make_run(label).__get__(inst, inst.__class__)
|
||||
inst.supports = always_supports.__get__(inst, inst.__class__) # type: ignore[assignment]
|
||||
inst.run = make_run(label).__get__(inst, inst.__class__) # type: ignore[assignment]
|
||||
patched_installers.append(inst)
|
||||
|
||||
with patch.object(install_mod, "INSTALLERS", patched_installers), \
|
||||
patch.object(install_mod, "get_repo_identifier", return_value="dummy-repo"), \
|
||||
patch.object(install_mod, "get_repo_dir", return_value=repo_dir), \
|
||||
patch.object(install_mod, "verify_repository", return_value=(True, [], None, None)), \
|
||||
patch.object(install_mod, "create_ink"), \
|
||||
patch.object(install_mod, "clone_repos"), \
|
||||
patch.object(install_mod, "resolve_command_for_repo", return_value="/bin/dummy"):
|
||||
|
||||
with patch.object(install_mod, "INSTALLERS", patched_installers), patch.object(
|
||||
install_mod, "get_repo_identifier", return_value="dummy-repo"
|
||||
), patch.object(
|
||||
install_mod, "get_repo_dir", return_value=repo_dir
|
||||
), patch.object(
|
||||
install_mod, "verify_repository", return_value=(True, [], None, None)
|
||||
), patch.object(
|
||||
install_mod, "clone_repos"
|
||||
):
|
||||
install_repos(
|
||||
selected_repos=selected_repos,
|
||||
repositories_base_dir=self.tmp_root,
|
||||
@@ -144,25 +103,25 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
||||
no_verification=True,
|
||||
preview=False,
|
||||
quiet=False,
|
||||
clone_mode="shallow",
|
||||
clone_mode="ssh",
|
||||
update_dependencies=False,
|
||||
)
|
||||
|
||||
return called_installers
|
||||
|
||||
# ----------------------------------------------------------------- scenarios
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Scenario 1: Only Makefile with install target
|
||||
# ------------------------------------------------------------------
|
||||
def test_only_makefile_installer_runs(self) -> None:
|
||||
"""
|
||||
With only a Makefile present, only the MakefileInstaller should run.
|
||||
"""
|
||||
repo_dir = self._new_repo()
|
||||
|
||||
# Makefile: detect a real 'install' target for makefile layer.
|
||||
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
|
||||
f.write("install:\n\t@echo 'installing from Makefile'\n")
|
||||
f.write("install:\n\t@echo 'make install'\n")
|
||||
|
||||
mk_inst = MakefileInstaller()
|
||||
installers = [("makefile", mk_inst)]
|
||||
installers: Sequence[InstallerSpec] = [("makefile", mk_inst)]
|
||||
|
||||
called = self._run_with_installers(repo_dir, installers)
|
||||
|
||||
@@ -172,110 +131,85 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
||||
"With only a Makefile, the MakefileInstaller should run exactly once.",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Scenario 2: Python + Makefile, but pyproject.toml does NOT mention 'make install'
|
||||
# → capabilities are disjoint, both installers should run.
|
||||
# ------------------------------------------------------------------
|
||||
def test_python_and_makefile_both_run_when_caps_disjoint(self) -> None:
|
||||
"""
|
||||
If Python and Makefile have disjoint capabilities, both installers run.
|
||||
"""
|
||||
repo_dir = self._new_repo()
|
||||
|
||||
# pyproject.toml: basic Python project, no 'make install' string.
|
||||
# pyproject.toml without any explicit "make install" hint
|
||||
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
"[project]\n"
|
||||
"name = 'dummy'\n"
|
||||
)
|
||||
f.write("name = 'dummy'\n")
|
||||
|
||||
# Makefile: install target for makefile layer.
|
||||
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
|
||||
f.write("install:\n\t@echo 'installing from Makefile'\n")
|
||||
f.write("install:\n\t@echo 'make install'\n")
|
||||
|
||||
py_inst = PythonInstaller()
|
||||
mk_inst = MakefileInstaller()
|
||||
|
||||
# Order: Python first, then Makefile
|
||||
installers = [
|
||||
installers: Sequence[InstallerSpec] = [
|
||||
("python", py_inst),
|
||||
("makefile", mk_inst),
|
||||
]
|
||||
|
||||
called = self._run_with_installers(repo_dir, installers)
|
||||
|
||||
# Both should have run because:
|
||||
# - Python provides {python-runtime}
|
||||
# - Makefile provides {make-install}
|
||||
self.assertEqual(
|
||||
called,
|
||||
["python", "makefile"],
|
||||
"PythonInstaller and MakefileInstaller should both run when their capabilities are disjoint.",
|
||||
"PythonInstaller and MakefileInstaller should both run when their "
|
||||
"capabilities are disjoint.",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Scenario 3: Python + Makefile, pyproject.toml mentions 'make install'
|
||||
# → PythonInstaller provides {python-runtime, make-install}
|
||||
# MakefileInstaller only {make-install}
|
||||
# → MakefileInstaller must be skipped.
|
||||
# ------------------------------------------------------------------
|
||||
def test_python_shadows_makefile_when_pyproject_mentions_make_install(self) -> None:
|
||||
"""
|
||||
If the Python layer advertises a 'make-install' capability (pyproject
|
||||
explicitly hints at 'make install'), the Makefile layer must be skipped.
|
||||
"""
|
||||
repo_dir = self._new_repo()
|
||||
|
||||
# pyproject.toml: Python project with 'make install' hint.
|
||||
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
"[project]\n"
|
||||
"name = 'dummy'\n"
|
||||
"\n"
|
||||
"# Hint for MakeInstallCapability on layer 'python'\n"
|
||||
"make install\n"
|
||||
)
|
||||
|
||||
# Makefile: install target, but should be shadowed by Python.
|
||||
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
|
||||
f.write("install:\n\t@echo 'installing from Makefile'\n")
|
||||
f.write("install:\n\t@echo 'make install'\n")
|
||||
|
||||
py_inst = PythonInstaller()
|
||||
mk_inst = MakefileInstaller()
|
||||
|
||||
installers = [
|
||||
installers: Sequence[InstallerSpec] = [
|
||||
("python", py_inst),
|
||||
("makefile", mk_inst),
|
||||
]
|
||||
|
||||
called = self._run_with_installers(repo_dir, installers)
|
||||
|
||||
# Python should run, Makefile should be skipped because its only
|
||||
# capability (make-install) is already provided by Python.
|
||||
self.assertIn("python", called, "PythonInstaller should have run.")
|
||||
self.assertNotIn(
|
||||
"makefile",
|
||||
called,
|
||||
"MakefileInstaller should be skipped because its 'make-install' capability "
|
||||
"is already provided by Python.",
|
||||
"MakefileInstaller should be skipped because its 'make-install' "
|
||||
"capability is already provided by Python.",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Scenario 4: Nix + Python + Makefile
|
||||
# flake.nix provides python-runtime + make-install + nix-flake
|
||||
# → Nix shadows both Python and Makefile.
|
||||
# ------------------------------------------------------------------
|
||||
def test_nix_shadows_python_and_makefile(self) -> None:
|
||||
"""
|
||||
If a Nix flake advertises both python-runtime and make-install
|
||||
capabilities, Python and Makefile installers must be skipped.
|
||||
"""
|
||||
repo_dir = self._new_repo()
|
||||
|
||||
# pyproject.toml: generic Python project
|
||||
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
"[project]\n"
|
||||
"name = 'dummy'\n"
|
||||
)
|
||||
f.write("name = 'dummy'\n")
|
||||
|
||||
# Makefile: install target
|
||||
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
|
||||
f.write("install:\n\t@echo 'installing from Makefile'\n")
|
||||
f.write("install:\n\t@echo 'make install'\n")
|
||||
|
||||
# flake.nix: hints for both python-runtime and make-install on layer 'nix'
|
||||
with open(os.path.join(repo_dir, "flake.nix"), "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
"{\n"
|
||||
' description = "integration test flake";\n'
|
||||
"}\n"
|
||||
"\n"
|
||||
@@ -289,8 +223,7 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
||||
nix_inst = NixFlakeInstaller()
|
||||
py_inst = PythonInstaller()
|
||||
mk_inst = MakefileInstaller()
|
||||
|
||||
installers = [
|
||||
installers: Sequence[InstallerSpec] = [
|
||||
("nix", nix_inst),
|
||||
("python", py_inst),
|
||||
("makefile", mk_inst),
|
||||
@@ -298,47 +231,35 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
||||
|
||||
called = self._run_with_installers(repo_dir, installers)
|
||||
|
||||
# Nix must run, Python and Makefile must be skipped:
|
||||
# - Nix provides {python-runtime, make-install, nix-flake}
|
||||
# - Python provides {python-runtime}
|
||||
# - Makefile provides {make-install}
|
||||
self.assertIn("nix", called, "NixFlakeInstaller should have run.")
|
||||
self.assertNotIn(
|
||||
"python",
|
||||
called,
|
||||
"PythonInstaller should be skipped because its python-runtime capability "
|
||||
"is already provided by Nix.",
|
||||
"PythonInstaller should be skipped because its python-runtime "
|
||||
"capability is already provided by Nix.",
|
||||
)
|
||||
self.assertNotIn(
|
||||
"makefile",
|
||||
called,
|
||||
"MakefileInstaller should be skipped because its make-install capability "
|
||||
"is already provided by Nix.",
|
||||
"MakefileInstaller should be skipped because its make-install "
|
||||
"capability is already provided by Nix.",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Scenario 5: OS packages + Nix + Python + Makefile
|
||||
# PKGBUILD provides python-runtime + make-install + nix-flake
|
||||
# → ArchPkgbuildInstaller shadows everything below.
|
||||
# ------------------------------------------------------------------
|
||||
def test_os_packages_shadow_nix_python_and_makefile(self) -> None:
|
||||
"""
|
||||
If an OS package layer (PKGBUILD) advertises all capabilities,
|
||||
all lower layers (Nix, Python, Makefile) must be skipped.
|
||||
"""
|
||||
repo_dir = self._new_repo()
|
||||
|
||||
# pyproject.toml: enough to signal a Python project
|
||||
with open(os.path.join(repo_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
"[project]\n"
|
||||
"name = 'dummy'\n"
|
||||
)
|
||||
f.write("name = 'dummy'\n")
|
||||
|
||||
# Makefile: install target
|
||||
with open(os.path.join(repo_dir, "Makefile"), "w", encoding="utf-8") as f:
|
||||
f.write("install:\n\t@echo 'installing from Makefile'\n")
|
||||
f.write("install:\n\t@echo 'make install'\n")
|
||||
|
||||
# flake.nix: as before
|
||||
with open(os.path.join(repo_dir, "flake.nix"), "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
"{\n"
|
||||
' description = "integration test flake";\n'
|
||||
"}\n"
|
||||
"\n"
|
||||
@@ -346,13 +267,8 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
||||
"make install\n"
|
||||
)
|
||||
|
||||
# PKGBUILD: contains patterns for all three capabilities on layer 'os-packages':
|
||||
# - "pip install ." → python-runtime
|
||||
# - "make install" → make-install
|
||||
# - "nix profile" → nix-flake
|
||||
with open(os.path.join(repo_dir, "PKGBUILD"), "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
"pkgname=dummy\n"
|
||||
"pkgver=0.1\n"
|
||||
"pkgrel=1\n"
|
||||
"pkgdesc='dummy pkg for integration test'\n"
|
||||
@@ -376,8 +292,7 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
||||
nix_inst = NixFlakeInstaller()
|
||||
py_inst = PythonInstaller()
|
||||
mk_inst = MakefileInstaller()
|
||||
|
||||
installers = [
|
||||
installers: Sequence[InstallerSpec] = [
|
||||
("os-packages", os_inst),
|
||||
("nix", nix_inst),
|
||||
("python", py_inst),
|
||||
@@ -386,11 +301,6 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
||||
|
||||
called = self._run_with_installers(repo_dir, installers)
|
||||
|
||||
# ArchPkgbuildInstaller must run, and everything below must be skipped:
|
||||
# - os-packages provides {python-runtime, make-install, nix-flake}
|
||||
# - nix provides {python-runtime, make-install, nix-flake}
|
||||
# - python provides {python-runtime}
|
||||
# - makefile provides {make-install}
|
||||
self.assertIn("os-packages", called, "ArchPkgbuildInstaller should have run.")
|
||||
self.assertNotIn(
|
||||
"nix",
|
||||
|
||||
@@ -4,8 +4,8 @@ import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller
|
||||
|
||||
|
||||
class TestArchPkgbuildInstaller(unittest.TestCase):
|
||||
@@ -26,7 +26,7 @@ class TestArchPkgbuildInstaller(unittest.TestCase):
|
||||
)
|
||||
self.installer = ArchPkgbuildInstaller()
|
||||
|
||||
@patch("pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000)
|
||||
@patch("pkgmgr.actions.install.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("shutil.which")
|
||||
def test_supports_true_when_tools_and_pkgbuild_exist(
|
||||
@@ -46,7 +46,7 @@ class TestArchPkgbuildInstaller(unittest.TestCase):
|
||||
self.assertIn("makepkg", calls)
|
||||
mock_exists.assert_called_with(os.path.join(self.ctx.repo_dir, "PKGBUILD"))
|
||||
|
||||
@patch("pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=0)
|
||||
@patch("pkgmgr.actions.install.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=0)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("shutil.which")
|
||||
def test_supports_false_when_running_as_root(
|
||||
@@ -55,7 +55,7 @@ class TestArchPkgbuildInstaller(unittest.TestCase):
|
||||
mock_which.return_value = "/usr/bin/pacman"
|
||||
self.assertFalse(self.installer.supports(self.ctx))
|
||||
|
||||
@patch("pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000)
|
||||
@patch("pkgmgr.actions.install.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000)
|
||||
@patch("os.path.exists", return_value=False)
|
||||
@patch("shutil.which")
|
||||
def test_supports_false_when_pkgbuild_missing(
|
||||
@@ -64,8 +64,8 @@ class TestArchPkgbuildInstaller(unittest.TestCase):
|
||||
mock_which.return_value = "/usr/bin/pacman"
|
||||
self.assertFalse(self.installer.supports(self.ctx))
|
||||
|
||||
@patch("pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild.run_command")
|
||||
@patch("pkgmgr.actions.repository.install.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000)
|
||||
@patch("pkgmgr.actions.install.installers.os_packages.arch_pkgbuild.run_command")
|
||||
@patch("pkgmgr.actions.install.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("shutil.which")
|
||||
def test_run_builds_and_installs_with_makepkg(
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
# tests/unit/pkgmgr/installers/os_packages/test_debian_control.py
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.repository.install.installers.os_packages.debian_control import DebianControlInstaller
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.os_packages.debian_control import (
|
||||
DebianControlInstaller,
|
||||
)
|
||||
|
||||
|
||||
class TestDebianControlInstaller(unittest.TestCase):
|
||||
@@ -29,14 +28,24 @@ class TestDebianControlInstaller(unittest.TestCase):
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("shutil.which", return_value="/usr/bin/dpkg-buildpackage")
|
||||
def test_supports_true(self, mock_which, mock_exists):
|
||||
"""
|
||||
supports() should return True when dpkg-buildpackage is available
|
||||
and a debian/control file exists in the repository.
|
||||
"""
|
||||
self.assertTrue(self.installer.supports(self.ctx))
|
||||
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("shutil.which", return_value=None)
|
||||
def test_supports_false_without_dpkg_buildpackage(self, mock_which, mock_exists):
|
||||
"""
|
||||
supports() should return False when dpkg-buildpackage is not available,
|
||||
even if a debian/control file exists.
|
||||
"""
|
||||
self.assertFalse(self.installer.supports(self.ctx))
|
||||
|
||||
@patch("pkgmgr.actions.repository.install.installers.os_packages.debian_control.run_command")
|
||||
@patch(
|
||||
"pkgmgr.actions.install.installers.os_packages.debian_control.run_command"
|
||||
)
|
||||
@patch("glob.glob", return_value=["/tmp/package-manager_0.1.1_all.deb"])
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("shutil.which")
|
||||
@@ -47,7 +56,19 @@ class TestDebianControlInstaller(unittest.TestCase):
|
||||
mock_glob,
|
||||
mock_run_command,
|
||||
):
|
||||
# dpkg-buildpackage + apt-get vorhanden
|
||||
"""
|
||||
run() should:
|
||||
|
||||
1. Install build dependencies (apt-get build-dep).
|
||||
2. Build the package using dpkg-buildpackage -b -us -uc.
|
||||
3. Discover built .deb files via glob.
|
||||
4. Install the resulting .deb packages using a suitable tool:
|
||||
- dpkg -i
|
||||
- sudo dpkg -i
|
||||
- or sudo apt-get install -y
|
||||
"""
|
||||
|
||||
# Simulate dpkg-buildpackage and apt-get being available.
|
||||
def which_side_effect(name):
|
||||
if name == "dpkg-buildpackage":
|
||||
return "/usr/bin/dpkg-buildpackage"
|
||||
@@ -64,16 +85,35 @@ class TestDebianControlInstaller(unittest.TestCase):
|
||||
# 1) apt-get update
|
||||
self.assertTrue(any("apt-get update" in cmd for cmd in cmds))
|
||||
|
||||
# 2) apt-get build-dep ./
|
||||
self.assertTrue(any("apt-get build-dep -y ./ " in cmd or
|
||||
"apt-get build-dep -y ./"
|
||||
in cmd for cmd in cmds))
|
||||
# 2) apt-get build-dep -y ./ (with or without trailing space)
|
||||
self.assertTrue(
|
||||
any(
|
||||
"apt-get build-dep -y ./ " in cmd
|
||||
or "apt-get build-dep -y ./"
|
||||
in cmd
|
||||
for cmd in cmds
|
||||
)
|
||||
)
|
||||
|
||||
# 3) dpkg-buildpackage -b -us -uc
|
||||
self.assertTrue(any("dpkg-buildpackage -b -us -uc" in cmd for cmd in cmds))
|
||||
|
||||
# 4) dpkg -i ../*.deb
|
||||
self.assertTrue(any(cmd.startswith("sudo dpkg -i ") for cmd in cmds))
|
||||
# 4) final installation of .deb packages:
|
||||
# accept dpkg -i, sudo dpkg -i, or sudo apt-get install -y
|
||||
has_plain_dpkg_install = any(cmd.startswith("dpkg -i ") for cmd in cmds)
|
||||
has_sudo_dpkg_install = any(cmd.startswith("sudo dpkg -i ") for cmd in cmds)
|
||||
has_apt_install = any(
|
||||
cmd.startswith("sudo apt-get install -y ") for cmd in cmds
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
has_plain_dpkg_install or has_sudo_dpkg_install or has_apt_install,
|
||||
msg=(
|
||||
"Expected one of 'dpkg -i', 'sudo dpkg -i' or "
|
||||
"'sudo apt-get install -y', but got commands: "
|
||||
f"{cmds}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# tests/unit/pkgmgr/installers/os_packages/test_rpm_spec.py
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.repository.install.installers.os_packages.rpm_spec import RpmSpecInstaller
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.os_packages.rpm_spec import (
|
||||
RpmSpecInstaller,
|
||||
)
|
||||
|
||||
|
||||
class TestRpmSpecInstaller(unittest.TestCase):
|
||||
@@ -28,6 +28,13 @@ class TestRpmSpecInstaller(unittest.TestCase):
|
||||
@patch("glob.glob", return_value=["/tmp/repo/test.spec"])
|
||||
@patch("shutil.which")
|
||||
def test_supports_true(self, mock_which, mock_glob):
|
||||
"""
|
||||
supports() should return True when:
|
||||
- rpmbuild is available, and
|
||||
- at least one of dnf/yum/yum-builddep is available, and
|
||||
- a *.spec file is present in the repo.
|
||||
"""
|
||||
|
||||
def which_side_effect(name):
|
||||
if name == "rpmbuild":
|
||||
return "/usr/bin/rpmbuild"
|
||||
@@ -42,10 +49,15 @@ class TestRpmSpecInstaller(unittest.TestCase):
|
||||
@patch("glob.glob", return_value=[])
|
||||
@patch("shutil.which")
|
||||
def test_supports_false_missing_spec(self, mock_which, mock_glob):
|
||||
"""
|
||||
supports() should return False if no *.spec file is found,
|
||||
even if rpmbuild is present.
|
||||
"""
|
||||
mock_which.return_value = "/usr/bin/rpmbuild"
|
||||
self.assertFalse(self.installer.supports(self.ctx))
|
||||
|
||||
@patch("pkgmgr.actions.repository.install.installers.os_packages.rpm_spec.run_command")
|
||||
@patch.object(RpmSpecInstaller, "_prepare_source_tarball")
|
||||
@patch("pkgmgr.actions.install.installers.os_packages.rpm_spec.run_command")
|
||||
@patch("glob.glob")
|
||||
@patch("shutil.which")
|
||||
def test_run_builds_and_installs_rpms(
|
||||
@@ -53,8 +65,20 @@ class TestRpmSpecInstaller(unittest.TestCase):
|
||||
mock_which,
|
||||
mock_glob,
|
||||
mock_run_command,
|
||||
mock_prepare_source_tarball,
|
||||
):
|
||||
# glob.glob wird zweimal benutzt: einmal für *.spec, einmal für gebaute RPMs
|
||||
"""
|
||||
run() should:
|
||||
|
||||
1. Determine the .spec file in the repo.
|
||||
2. Call _prepare_source_tarball() once with ctx and spec path.
|
||||
3. Install build dependencies via dnf/yum-builddep/yum.
|
||||
4. Call rpmbuild -ba <spec>.
|
||||
5. Find built RPMs via glob.
|
||||
6. Install built RPMs via dnf/yum/rpm (here: dnf).
|
||||
"""
|
||||
|
||||
# glob.glob is used twice: once for *.spec, once for built RPMs.
|
||||
def glob_side_effect(pattern, recursive=False):
|
||||
if pattern.endswith("*.spec"):
|
||||
return ["/tmp/repo/package-manager.spec"]
|
||||
@@ -77,16 +101,23 @@ class TestRpmSpecInstaller(unittest.TestCase):
|
||||
|
||||
self.installer.run(self.ctx)
|
||||
|
||||
# _prepare_source_tarball must have been called with the resolved spec path.
|
||||
mock_prepare_source_tarball.assert_called_once_with(
|
||||
self.ctx,
|
||||
"/tmp/repo/package-manager.spec",
|
||||
)
|
||||
|
||||
# Collect all command strings passed to run_command.
|
||||
cmds = [c[0][0] for c in mock_run_command.call_args_list]
|
||||
|
||||
# 1) builddep
|
||||
# 1) build dependencies (dnf builddep)
|
||||
self.assertTrue(any("builddep -y" in cmd for cmd in cmds))
|
||||
|
||||
# 2) rpmbuild -ba
|
||||
# 2) rpmbuild -ba <spec>
|
||||
self.assertTrue(any(cmd.startswith("rpmbuild -ba ") for cmd in cmds))
|
||||
|
||||
# 3) rpm -i …
|
||||
self.assertTrue(any(cmd.startswith("sudo rpm -i ") for cmd in cmds))
|
||||
# 3) installation via dnf: "sudo dnf install -y <rpms>"
|
||||
self.assertTrue(any(cmd.startswith("sudo dnf install -y ") for cmd in cmds))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# tests/unit/pkgmgr/installers/test_base.py
|
||||
|
||||
import unittest
|
||||
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
|
||||
|
||||
class DummyInstaller(BaseInstaller):
|
||||
|
||||
@@ -4,8 +4,8 @@ import os
|
||||
import unittest
|
||||
from unittest.mock import patch, mock_open
|
||||
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.repository.install.installers.makefile import MakefileInstaller
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.makefile import MakefileInstaller
|
||||
|
||||
|
||||
class TestMakefileInstaller(unittest.TestCase):
|
||||
@@ -26,16 +26,16 @@ class TestMakefileInstaller(unittest.TestCase):
|
||||
)
|
||||
self.installer = MakefileInstaller()
|
||||
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_supports_true_when_makefile_exists(self, mock_exists):
|
||||
self.assertTrue(self.installer.supports(self.ctx))
|
||||
mock_exists.assert_called_with(os.path.join(self.ctx.repo_dir, "Makefile"))
|
||||
# @patch("os.path.exists", return_value=True)
|
||||
# def test_supports_true_when_makefile_exists(self, mock_exists):
|
||||
# self.assertTrue(self.installer.supports(self.ctx))
|
||||
# mock_exists.assert_called_with(os.path.join(self.ctx.repo_dir, "Makefile"))
|
||||
|
||||
@patch("os.path.exists", return_value=False)
|
||||
def test_supports_false_when_makefile_missing(self, mock_exists):
|
||||
self.assertFalse(self.installer.supports(self.ctx))
|
||||
|
||||
@patch("pkgmgr.actions.repository.install.installers.makefile.run_command")
|
||||
@patch("pkgmgr.actions.install.installers.makefile.run_command")
|
||||
@patch(
|
||||
"builtins.open",
|
||||
new_callable=mock_open,
|
||||
@@ -62,7 +62,7 @@ class TestMakefileInstaller(unittest.TestCase):
|
||||
self.ctx.repo_dir,
|
||||
)
|
||||
|
||||
@patch("pkgmgr.actions.repository.install.installers.makefile.run_command")
|
||||
@patch("pkgmgr.actions.install.installers.makefile.run_command")
|
||||
@patch(
|
||||
"builtins.open",
|
||||
new_callable=mock_open,
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.repository.install.installers.nix_flake import NixFlakeInstaller
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.nix_flake import NixFlakeInstaller
|
||||
|
||||
|
||||
class TestNixFlakeInstaller(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repo = {"name": "test-repo"}
|
||||
def setUp(self) -> None:
|
||||
self.repo = {"repository": "package-manager"}
|
||||
# Important: identifier "pkgmgr" triggers both "pkgmgr" and "default"
|
||||
self.ctx = RepoContext(
|
||||
repo=self.repo,
|
||||
identifier="test-id",
|
||||
identifier="pkgmgr",
|
||||
repo_dir="/tmp/repo",
|
||||
repositories_base_dir="/tmp",
|
||||
bin_dir="/bin",
|
||||
@@ -25,72 +29,103 @@ class TestNixFlakeInstaller(unittest.TestCase):
|
||||
)
|
||||
self.installer = NixFlakeInstaller()
|
||||
|
||||
@patch("shutil.which", return_value="/usr/bin/nix")
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_supports_true_when_nix_and_flake_exist(self, mock_exists, mock_which):
|
||||
self.assertTrue(self.installer.supports(self.ctx))
|
||||
mock_which.assert_called_with("nix")
|
||||
mock_exists.assert_called_with(os.path.join(self.ctx.repo_dir, "flake.nix"))
|
||||
@patch("pkgmgr.actions.install.installers.nix_flake.os.path.exists")
|
||||
@patch("pkgmgr.actions.install.installers.nix_flake.shutil.which")
|
||||
def test_supports_true_when_nix_and_flake_exist(
|
||||
self,
|
||||
mock_which: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
) -> None:
|
||||
mock_which.return_value = "/usr/bin/nix"
|
||||
mock_exists.return_value = True
|
||||
|
||||
@patch("shutil.which", return_value=None)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_supports_false_when_nix_missing(self, mock_exists, mock_which):
|
||||
self.assertFalse(self.installer.supports(self.ctx))
|
||||
with patch.dict(os.environ, {"PKGMGR_DISABLE_NIX_FLAKE_INSTALLER": ""}, clear=False):
|
||||
self.assertTrue(self.installer.supports(self.ctx))
|
||||
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("shutil.which", return_value="/usr/bin/nix")
|
||||
@mock.patch("pkgmgr.actions.repository.install.installers.nix_flake.run_command")
|
||||
mock_which.assert_called_once_with("nix")
|
||||
mock_exists.assert_called_once_with(
|
||||
os.path.join(self.ctx.repo_dir, self.installer.FLAKE_FILE)
|
||||
)
|
||||
|
||||
@patch("pkgmgr.actions.install.installers.nix_flake.os.path.exists")
|
||||
@patch("pkgmgr.actions.install.installers.nix_flake.shutil.which")
|
||||
def test_supports_false_when_nix_missing(
|
||||
self,
|
||||
mock_which: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
) -> None:
|
||||
mock_which.return_value = None
|
||||
mock_exists.return_value = True # flake exists but nix is missing
|
||||
|
||||
with patch.dict(os.environ, {"PKGMGR_DISABLE_NIX_FLAKE_INSTALLER": ""}, clear=False):
|
||||
self.assertFalse(self.installer.supports(self.ctx))
|
||||
|
||||
@patch("pkgmgr.actions.install.installers.nix_flake.os.path.exists")
|
||||
@patch("pkgmgr.actions.install.installers.nix_flake.shutil.which")
|
||||
def test_supports_false_when_disabled_via_env(
|
||||
self,
|
||||
mock_which: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
) -> None:
|
||||
mock_which.return_value = "/usr/bin/nix"
|
||||
mock_exists.return_value = True
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"PKGMGR_DISABLE_NIX_FLAKE_INSTALLER": "1"},
|
||||
clear=False,
|
||||
):
|
||||
self.assertFalse(self.installer.supports(self.ctx))
|
||||
|
||||
@patch("pkgmgr.actions.install.installers.nix_flake.NixFlakeInstaller.supports")
|
||||
@patch("pkgmgr.actions.install.installers.nix_flake.run_command")
|
||||
def test_run_removes_old_profile_and_installs_outputs(
|
||||
self,
|
||||
mock_run_command,
|
||||
mock_which,
|
||||
mock_exists,
|
||||
):
|
||||
mock_run_command: MagicMock,
|
||||
mock_supports: MagicMock,
|
||||
) -> None:
|
||||
"""
|
||||
Ensure that run():
|
||||
- first tries to remove the old 'package-manager' profile entry
|
||||
- then installs both 'pkgmgr' and 'default' outputs.
|
||||
run() should:
|
||||
- remove the old profile
|
||||
- install both 'pkgmgr' and 'default' outputs for identifier 'pkgmgr'
|
||||
- call commands in the correct order
|
||||
"""
|
||||
cmds = []
|
||||
mock_supports.return_value = True
|
||||
|
||||
def side_effect(cmd, cwd=None, preview=False, *args, **kwargs):
|
||||
cmds.append(cmd)
|
||||
return None
|
||||
commands: list[str] = []
|
||||
|
||||
def side_effect(cmd: str, cwd: str | None = None, preview: bool = False, **_: object) -> None:
|
||||
commands.append(cmd)
|
||||
|
||||
mock_run_command.side_effect = side_effect
|
||||
|
||||
self.installer.run(self.ctx)
|
||||
with patch.dict(os.environ, {"PKGMGR_DISABLE_NIX_FLAKE_INSTALLER": ""}, clear=False):
|
||||
self.installer.run(self.ctx)
|
||||
|
||||
remove_cmd = f"nix profile remove {self.installer.PROFILE_NAME} || true"
|
||||
install_pkgmgr_cmd = f"nix profile install {self.ctx.repo_dir}#pkgmgr"
|
||||
install_default_cmd = f"nix profile install {self.ctx.repo_dir}#default"
|
||||
|
||||
# Mindestens diese drei Kommandos müssen aufgerufen worden sein
|
||||
self.assertIn(remove_cmd, cmds)
|
||||
self.assertIn(install_pkgmgr_cmd, cmds)
|
||||
self.assertIn(install_default_cmd, cmds)
|
||||
self.assertIn(remove_cmd, commands)
|
||||
self.assertIn(install_pkgmgr_cmd, commands)
|
||||
self.assertIn(install_default_cmd, commands)
|
||||
|
||||
# Optional: sicherstellen, dass der remove-Aufruf zuerst kam
|
||||
self.assertEqual(cmds[0], remove_cmd)
|
||||
self.assertEqual(commands[0], remove_cmd)
|
||||
|
||||
@patch("shutil.which", return_value="/usr/bin/nix")
|
||||
@mock.patch("pkgmgr.actions.repository.install.installers.nix_flake.run_command")
|
||||
@patch("pkgmgr.actions.install.installers.nix_flake.shutil.which")
|
||||
@patch("pkgmgr.actions.install.installers.nix_flake.run_command")
|
||||
def test_ensure_old_profile_removed_ignores_systemexit(
|
||||
self,
|
||||
mock_run_command,
|
||||
mock_which,
|
||||
):
|
||||
"""
|
||||
_ensure_old_profile_removed() must not propagate SystemExit, even if
|
||||
'nix profile remove' fails (e.g. profile entry does not exist).
|
||||
"""
|
||||
mock_run_command: MagicMock,
|
||||
mock_which: MagicMock,
|
||||
) -> None:
|
||||
mock_which.return_value = "/usr/bin/nix"
|
||||
|
||||
def side_effect(cmd, cwd=None, preview=False, *args, **kwargs):
|
||||
def side_effect(cmd: str, cwd: str | None = None, preview: bool = False, **_: object) -> None:
|
||||
raise SystemExit(1)
|
||||
|
||||
mock_run_command.side_effect = side_effect
|
||||
|
||||
# Should not raise, SystemExit is swallowed internally.
|
||||
self.installer._ensure_old_profile_removed(self.ctx)
|
||||
|
||||
remove_cmd = f"nix profile remove {self.installer.PROFILE_NAME} || true"
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
# tests/unit/pkgmgr/installers/test_python_installer.py
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.repository.install.installers.python import PythonInstaller
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.python import PythonInstaller
|
||||
|
||||
|
||||
class TestPythonInstaller(unittest.TestCase):
|
||||
@@ -28,18 +26,40 @@ class TestPythonInstaller(unittest.TestCase):
|
||||
|
||||
@patch("os.path.exists", side_effect=lambda path: path.endswith("pyproject.toml"))
|
||||
def test_supports_true_when_pyproject_exists(self, mock_exists):
|
||||
self.assertTrue(self.installer.supports(self.ctx))
|
||||
"""
|
||||
supports() should return True when a pyproject.toml exists in the repo
|
||||
and we are not inside a Nix dev shell.
|
||||
"""
|
||||
with patch.dict(os.environ, {"IN_NIX_SHELL": ""}, clear=False):
|
||||
self.assertTrue(self.installer.supports(self.ctx))
|
||||
|
||||
@patch("os.path.exists", return_value=False)
|
||||
def test_supports_false_when_no_pyproject(self, mock_exists):
|
||||
self.assertFalse(self.installer.supports(self.ctx))
|
||||
"""
|
||||
supports() should return False when no pyproject.toml exists.
|
||||
"""
|
||||
with patch.dict(os.environ, {"IN_NIX_SHELL": ""}, clear=False):
|
||||
self.assertFalse(self.installer.supports(self.ctx))
|
||||
|
||||
@patch("pkgmgr.actions.repository.install.installers.python.run_command")
|
||||
@patch("pkgmgr.actions.install.installers.python.run_command")
|
||||
@patch("os.path.exists", side_effect=lambda path: path.endswith("pyproject.toml"))
|
||||
def test_run_installs_project_from_pyproject(self, mock_exists, mock_run_command):
|
||||
self.installer.run(self.ctx)
|
||||
"""
|
||||
run() should invoke pip to install the project from pyproject.toml
|
||||
when we are not inside a Nix dev shell.
|
||||
"""
|
||||
# Simulate a normal environment (not inside nix develop).
|
||||
with patch.dict(os.environ, {"IN_NIX_SHELL": ""}, clear=False):
|
||||
self.installer.run(self.ctx)
|
||||
|
||||
# Ensure run_command was actually called.
|
||||
mock_run_command.assert_called()
|
||||
|
||||
# Extract the command string.
|
||||
cmd = mock_run_command.call_args[0][0]
|
||||
self.assertIn("pip install .", cmd)
|
||||
|
||||
# Ensure the working directory is the repo dir.
|
||||
self.assertEqual(
|
||||
mock_run_command.call_args[1].get("cwd"),
|
||||
self.ctx.repo_dir,
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
import unittest
|
||||
from unittest.mock import patch, mock_open
|
||||
|
||||
from pkgmgr.actions.repository.install.capabilities import (
|
||||
from pkgmgr.actions.install.capabilities import (
|
||||
PythonRuntimeCapability,
|
||||
MakeInstallCapability,
|
||||
NixFlakeCapability,
|
||||
@@ -31,7 +31,7 @@ class TestCapabilitiesDetectors(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.ctx = DummyCtx("/tmp/repo")
|
||||
|
||||
@patch("pkgmgr.actions.repository.install.capabilities.os.path.exists")
|
||||
@patch("pkgmgr.actions.install.capabilities.os.path.exists")
|
||||
def test_python_runtime_python_layer_pyproject(self, mock_exists):
|
||||
"""PythonRuntimeCapability: python layer is provided if pyproject.toml exists."""
|
||||
cap = PythonRuntimeCapability()
|
||||
@@ -47,8 +47,8 @@ class TestCapabilitiesDetectors(unittest.TestCase):
|
||||
self.assertFalse(cap.is_provided(self.ctx, "nix"))
|
||||
self.assertFalse(cap.is_provided(self.ctx, "os-packages"))
|
||||
|
||||
@patch("pkgmgr.actions.repository.install.capabilities._read_text_if_exists")
|
||||
@patch("pkgmgr.actions.repository.install.capabilities.os.path.exists")
|
||||
@patch("pkgmgr.actions.install.capabilities._read_text_if_exists")
|
||||
@patch("pkgmgr.actions.install.capabilities.os.path.exists")
|
||||
def test_python_runtime_nix_layer_flake(self, mock_exists, mock_read):
|
||||
"""
|
||||
PythonRuntimeCapability: nix layer is provided if flake.nix contains
|
||||
@@ -65,7 +65,7 @@ class TestCapabilitiesDetectors(unittest.TestCase):
|
||||
self.assertTrue(cap.applies_to_layer("nix"))
|
||||
self.assertTrue(cap.is_provided(self.ctx, "nix"))
|
||||
|
||||
@patch("pkgmgr.actions.repository.install.capabilities.os.path.exists", return_value=True)
|
||||
@patch("pkgmgr.actions.install.capabilities.os.path.exists", return_value=True)
|
||||
@patch(
|
||||
"builtins.open",
|
||||
new_callable=mock_open,
|
||||
@@ -78,7 +78,7 @@ class TestCapabilitiesDetectors(unittest.TestCase):
|
||||
self.assertTrue(cap.applies_to_layer("makefile"))
|
||||
self.assertTrue(cap.is_provided(self.ctx, "makefile"))
|
||||
|
||||
@patch("pkgmgr.actions.repository.install.capabilities.os.path.exists")
|
||||
@patch("pkgmgr.actions.install.capabilities.os.path.exists")
|
||||
def test_nix_flake_capability_on_nix_layer(self, mock_exists):
|
||||
"""NixFlakeCapability: nix layer is provided if flake.nix exists."""
|
||||
cap = NixFlakeCapability()
|
||||
@@ -153,7 +153,7 @@ class TestDetectCapabilities(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
with patch("pkgmgr.actions.repository.install.capabilities.CAPABILITY_MATCHERS", [dummy1, dummy2]):
|
||||
with patch("pkgmgr.actions.install.capabilities.CAPABILITY_MATCHERS", [dummy1, dummy2]):
|
||||
caps = detect_capabilities(self.ctx, layers)
|
||||
|
||||
self.assertEqual(
|
||||
@@ -221,7 +221,7 @@ class TestResolveEffectiveCapabilities(unittest.TestCase):
|
||||
)
|
||||
|
||||
with patch(
|
||||
"pkgmgr.actions.repository.install.capabilities.CAPABILITY_MATCHERS",
|
||||
"pkgmgr.actions.install.capabilities.CAPABILITY_MATCHERS",
|
||||
[cap_make_install, cap_python_runtime, cap_nix_flake],
|
||||
):
|
||||
effective = resolve_effective_capabilities(self.ctx, layers)
|
||||
@@ -258,7 +258,7 @@ class TestResolveEffectiveCapabilities(unittest.TestCase):
|
||||
)
|
||||
|
||||
with patch(
|
||||
"pkgmgr.actions.repository.install.capabilities.CAPABILITY_MATCHERS",
|
||||
"pkgmgr.actions.install.capabilities.CAPABILITY_MATCHERS",
|
||||
[cap_python_runtime],
|
||||
):
|
||||
effective = resolve_effective_capabilities(self.ctx, layers)
|
||||
@@ -283,7 +283,7 @@ class TestResolveEffectiveCapabilities(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
with patch("pkgmgr.actions.repository.install.capabilities.CAPABILITY_MATCHERS", [cap_only_make]):
|
||||
with patch("pkgmgr.actions.install.capabilities.CAPABILITY_MATCHERS", [cap_only_make]):
|
||||
effective = resolve_effective_capabilities(self.ctx, layers)
|
||||
|
||||
self.assertEqual(effective["makefile"], {"make-install"})
|
||||
@@ -306,7 +306,7 @@ class TestResolveEffectiveCapabilities(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
with patch("pkgmgr.actions.repository.install.capabilities.CAPABILITY_MATCHERS", [cap_only_nix]):
|
||||
with patch("pkgmgr.actions.install.capabilities.CAPABILITY_MATCHERS", [cap_only_nix]):
|
||||
effective = resolve_effective_capabilities(self.ctx, layers)
|
||||
|
||||
self.assertEqual(effective["makefile"], set())
|
||||
@@ -337,7 +337,7 @@ class TestResolveEffectiveCapabilities(unittest.TestCase):
|
||||
)
|
||||
|
||||
with patch(
|
||||
"pkgmgr.actions.repository.install.capabilities.CAPABILITY_MATCHERS",
|
||||
"pkgmgr.actions.install.capabilities.CAPABILITY_MATCHERS",
|
||||
[cap_python_runtime],
|
||||
):
|
||||
effective = resolve_effective_capabilities(self.ctx, layers)
|
||||
@@ -359,7 +359,7 @@ class TestResolveEffectiveCapabilities(unittest.TestCase):
|
||||
)
|
||||
|
||||
with patch(
|
||||
"pkgmgr.actions.repository.install.capabilities.CAPABILITY_MATCHERS",
|
||||
"pkgmgr.actions.install.capabilities.CAPABILITY_MATCHERS",
|
||||
[cap_dummy],
|
||||
):
|
||||
effective = resolve_effective_capabilities(self.ctx)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import unittest
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
|
||||
|
||||
class TestRepoContext(unittest.TestCase):
|
||||
|
||||
@@ -1,134 +1,129 @@
|
||||
# tests/unit/pkgmgr/test_install_repos.py
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pkgmgr.actions.repository.install.context import RepoContext
|
||||
import pkgmgr.actions.repository.install as install_module
|
||||
from pkgmgr.actions.repository.install.installers.base import BaseInstaller
|
||||
from pkgmgr.actions.install import install_repos
|
||||
|
||||
|
||||
class DummyInstaller(BaseInstaller):
|
||||
"""Simple installer for testing orchestration."""
|
||||
|
||||
layer = None # no specific capabilities
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def supports(self, ctx: RepoContext) -> bool:
|
||||
# Always support to verify that the pipeline runs
|
||||
return True
|
||||
|
||||
def run(self, ctx: RepoContext) -> None:
|
||||
self.calls.append(ctx.identifier)
|
||||
Repository = Dict[str, Any]
|
||||
|
||||
|
||||
class TestInstallReposOrchestration(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.repository.install.create_ink")
|
||||
@patch("pkgmgr.actions.repository.install.resolve_command_for_repo")
|
||||
@patch("pkgmgr.actions.repository.install.verify_repository")
|
||||
@patch("pkgmgr.actions.repository.install.get_repo_dir")
|
||||
@patch("pkgmgr.actions.repository.install.get_repo_identifier")
|
||||
@patch("pkgmgr.actions.repository.install.clone_repos")
|
||||
def setUp(self) -> None:
|
||||
self.base_dir = "/fake/base"
|
||||
self.bin_dir = "/fake/bin"
|
||||
|
||||
self.repo1: Repository = {
|
||||
"account": "kevinveenbirkenbach",
|
||||
"repository": "repo-one",
|
||||
"alias": "repo-one",
|
||||
"verified": {"gpg_keys": ["FAKEKEY"]},
|
||||
}
|
||||
self.repo2: Repository = {
|
||||
"account": "kevinveenbirkenbach",
|
||||
"repository": "repo-two",
|
||||
"alias": "repo-two",
|
||||
"verified": {"gpg_keys": ["FAKEKEY"]},
|
||||
}
|
||||
self.all_repos: List[Repository] = [self.repo1, self.repo2]
|
||||
|
||||
@patch("pkgmgr.actions.install.InstallationPipeline")
|
||||
@patch("pkgmgr.actions.install.clone_repos")
|
||||
@patch("pkgmgr.actions.install.get_repo_dir")
|
||||
@patch("pkgmgr.actions.install.os.path.exists", return_value=True)
|
||||
@patch(
|
||||
"pkgmgr.actions.install.verify_repository",
|
||||
return_value=(True, [], "hash", "key"),
|
||||
)
|
||||
def test_install_repos_runs_pipeline_for_each_repo(
|
||||
self,
|
||||
mock_clone_repos,
|
||||
mock_get_repo_identifier,
|
||||
mock_get_repo_dir,
|
||||
mock_verify_repository,
|
||||
mock_resolve_command_for_repo,
|
||||
mock_create_ink,
|
||||
):
|
||||
repo1 = {"name": "repo1"}
|
||||
repo2 = {"name": "repo2"}
|
||||
selected_repos = [repo1, repo2]
|
||||
all_repos = selected_repos
|
||||
_mock_verify_repository: MagicMock,
|
||||
_mock_exists: MagicMock,
|
||||
mock_get_repo_dir: MagicMock,
|
||||
mock_clone_repos: MagicMock,
|
||||
mock_pipeline_cls: MagicMock,
|
||||
) -> None:
|
||||
"""
|
||||
install_repos() should construct a RepoContext for each repository and
|
||||
run the InstallationPipeline exactly once per selected repo when the
|
||||
repo directory exists and verification passes.
|
||||
"""
|
||||
mock_get_repo_dir.side_effect = [
|
||||
os.path.join(self.base_dir, "repo-one"),
|
||||
os.path.join(self.base_dir, "repo-two"),
|
||||
]
|
||||
|
||||
# Return identifiers and directories
|
||||
mock_get_repo_identifier.side_effect = ["id1", "id2"]
|
||||
mock_get_repo_dir.side_effect = ["/tmp/repo1", "/tmp/repo2"]
|
||||
selected = [self.repo1, self.repo2]
|
||||
|
||||
# Simulate verification success: (ok, errors, commit, key)
|
||||
mock_verify_repository.return_value = (True, [], "commit", "key")
|
||||
install_repos(
|
||||
selected_repos=selected,
|
||||
repositories_base_dir=self.base_dir,
|
||||
bin_dir=self.bin_dir,
|
||||
all_repos=self.all_repos,
|
||||
no_verification=False,
|
||||
preview=False,
|
||||
quiet=False,
|
||||
clone_mode="ssh",
|
||||
update_dependencies=False,
|
||||
)
|
||||
|
||||
# Resolve commands for both repos so create_ink will be called
|
||||
mock_resolve_command_for_repo.side_effect = ["/bin/cmd1", "/bin/cmd2"]
|
||||
# clone_repos must not be called because directories "exist"
|
||||
mock_clone_repos.assert_not_called()
|
||||
|
||||
# Ensure directories exist (no cloning)
|
||||
with patch("os.path.exists", return_value=True):
|
||||
dummy_installer = DummyInstaller()
|
||||
# Monkeypatch INSTALLERS for this test
|
||||
old_installers = install_module.INSTALLERS
|
||||
install_module.INSTALLERS = [dummy_installer]
|
||||
try:
|
||||
install_module.install_repos(
|
||||
selected_repos=selected_repos,
|
||||
repositories_base_dir="/tmp",
|
||||
bin_dir="/bin",
|
||||
all_repos=all_repos,
|
||||
no_verification=False,
|
||||
preview=False,
|
||||
quiet=False,
|
||||
clone_mode="ssh",
|
||||
update_dependencies=False,
|
||||
)
|
||||
finally:
|
||||
install_module.INSTALLERS = old_installers
|
||||
# A pipeline is constructed once, then run() is invoked once per repo
|
||||
self.assertEqual(mock_pipeline_cls.call_count, 1)
|
||||
pipeline_instance = mock_pipeline_cls.return_value
|
||||
self.assertEqual(pipeline_instance.run.call_count, len(selected))
|
||||
|
||||
# Check that installers ran with both identifiers
|
||||
self.assertEqual(dummy_installer.calls, ["id1", "id2"])
|
||||
self.assertEqual(mock_create_ink.call_count, 2)
|
||||
self.assertEqual(mock_verify_repository.call_count, 2)
|
||||
self.assertEqual(mock_resolve_command_for_repo.call_count, 2)
|
||||
|
||||
@patch("pkgmgr.actions.repository.install.verify_repository")
|
||||
@patch("pkgmgr.actions.repository.install.get_repo_dir")
|
||||
@patch("pkgmgr.actions.repository.install.get_repo_identifier")
|
||||
@patch("pkgmgr.actions.repository.install.clone_repos")
|
||||
@patch("pkgmgr.actions.install.InstallationPipeline")
|
||||
@patch("pkgmgr.actions.install.clone_repos")
|
||||
@patch("pkgmgr.actions.install.get_repo_dir")
|
||||
@patch("pkgmgr.actions.install.os.path.exists", return_value=True)
|
||||
@patch(
|
||||
"pkgmgr.actions.install.verify_repository",
|
||||
return_value=(False, ["invalid signature"], None, None),
|
||||
)
|
||||
@patch("builtins.input", return_value="n")
|
||||
def test_install_repos_skips_on_failed_verification(
|
||||
self,
|
||||
mock_clone_repos,
|
||||
mock_get_repo_identifier,
|
||||
mock_get_repo_dir,
|
||||
mock_verify_repository,
|
||||
):
|
||||
repo = {"name": "repo1", "verified": True}
|
||||
selected_repos = [repo]
|
||||
all_repos = selected_repos
|
||||
_mock_input: MagicMock,
|
||||
_mock_verify_repository: MagicMock,
|
||||
_mock_exists: MagicMock,
|
||||
mock_get_repo_dir: MagicMock,
|
||||
mock_clone_repos: MagicMock,
|
||||
mock_pipeline_cls: MagicMock,
|
||||
) -> None:
|
||||
"""
|
||||
When verification fails and the user does NOT confirm installation,
|
||||
the InstallationPipeline must not be run for that repository.
|
||||
"""
|
||||
mock_get_repo_dir.return_value = os.path.join(self.base_dir, "repo-one")
|
||||
|
||||
mock_get_repo_identifier.return_value = "id1"
|
||||
mock_get_repo_dir.return_value = "/tmp/repo1"
|
||||
selected = [self.repo1]
|
||||
|
||||
# Verification fails: ok=False, with error list
|
||||
mock_verify_repository.return_value = (False, ["sig error"], None, None)
|
||||
install_repos(
|
||||
selected_repos=selected,
|
||||
repositories_base_dir=self.base_dir,
|
||||
bin_dir=self.bin_dir,
|
||||
all_repos=self.all_repos,
|
||||
no_verification=False,
|
||||
preview=False,
|
||||
quiet=False,
|
||||
clone_mode="ssh",
|
||||
update_dependencies=False,
|
||||
)
|
||||
|
||||
dummy_installer = DummyInstaller()
|
||||
with patch("pkgmgr.actions.repository.install.create_ink") as mock_create_ink, \
|
||||
patch("pkgmgr.actions.repository.install.resolve_command_for_repo") as mock_resolve_cmd, \
|
||||
patch("os.path.exists", return_value=True), \
|
||||
patch("builtins.input", return_value="n"):
|
||||
old_installers = install_module.INSTALLERS
|
||||
install_module.INSTALLERS = [dummy_installer]
|
||||
try:
|
||||
install_module.install_repos(
|
||||
selected_repos=selected_repos,
|
||||
repositories_base_dir="/tmp",
|
||||
bin_dir="/bin",
|
||||
all_repos=all_repos,
|
||||
no_verification=False,
|
||||
preview=False,
|
||||
quiet=False,
|
||||
clone_mode="ssh",
|
||||
update_dependencies=False,
|
||||
)
|
||||
finally:
|
||||
install_module.INSTALLERS = old_installers
|
||||
# clone_repos must not be called because directory "exists"
|
||||
mock_clone_repos.assert_not_called()
|
||||
|
||||
# No installer run and no create_ink when user declines
|
||||
self.assertEqual(dummy_installer.calls, [])
|
||||
mock_create_ink.assert_not_called()
|
||||
mock_resolve_cmd.assert_not_called()
|
||||
# Pipeline is constructed, but run() must not be called
|
||||
mock_pipeline_cls.assert_called_once()
|
||||
pipeline_instance = mock_pipeline_cls.return_value
|
||||
pipeline_instance.run.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
94
tests/unit/pkgmgr/actions/install/test_layers.py
Normal file
94
tests/unit/pkgmgr/actions/install/test_layers.py
Normal file
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from pkgmgr.actions.install.layers import (
|
||||
CliLayer,
|
||||
CLI_LAYERS,
|
||||
classify_command_layer,
|
||||
layer_priority,
|
||||
)
|
||||
|
||||
|
||||
class TestCliLayerAndPriority(unittest.TestCase):
|
||||
def test_layer_priority_for_known_layers_is_monotonic(self) -> None:
|
||||
"""
|
||||
layer_priority() must reflect the ordering in CLI_LAYERS.
|
||||
We mainly check that the order is stable and that each later item
|
||||
has a higher (or equal) priority index than the previous one.
|
||||
"""
|
||||
priorities = [layer_priority(layer) for layer in CLI_LAYERS]
|
||||
|
||||
# Ensure no negative priorities and strictly increasing or stable order
|
||||
for idx, value in enumerate(priorities):
|
||||
self.assertGreaterEqual(
|
||||
value, 0, f"Priority for {CLI_LAYERS[idx]} must be >= 0"
|
||||
)
|
||||
if idx > 0:
|
||||
self.assertGreaterEqual(
|
||||
value,
|
||||
priorities[idx - 1],
|
||||
"Priorities must be non-decreasing in CLI_LAYERS order",
|
||||
)
|
||||
|
||||
def test_layer_priority_for_none_and_unknown(self) -> None:
|
||||
"""
|
||||
None and unknown layers should both receive the 'max' priority
|
||||
(i.e., len(CLI_LAYERS)).
|
||||
"""
|
||||
none_priority = layer_priority(None)
|
||||
self.assertEqual(none_priority, len(CLI_LAYERS))
|
||||
|
||||
class FakeLayer:
|
||||
# Not part of CliLayer
|
||||
pass
|
||||
|
||||
unknown_priority = layer_priority(FakeLayer()) # type: ignore[arg-type]
|
||||
self.assertEqual(unknown_priority, len(CLI_LAYERS))
|
||||
|
||||
|
||||
class TestClassifyCommandLayer(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.home = os.path.expanduser("~")
|
||||
self.repo_dir = "/tmp/pkgmgr-test-repo"
|
||||
|
||||
def test_classify_system_binaries_os_packages(self) -> None:
|
||||
for cmd in ("/usr/bin/pkgmgr", "/bin/pkgmgr"):
|
||||
with self.subTest(cmd=cmd):
|
||||
layer = classify_command_layer(cmd, self.repo_dir)
|
||||
self.assertEqual(layer, CliLayer.OS_PACKAGES)
|
||||
|
||||
def test_classify_nix_binaries(self) -> None:
|
||||
nix_cmds = [
|
||||
"/nix/store/abcd1234-bin-pkgmgr/bin/pkgmgr",
|
||||
os.path.join(self.home, ".nix-profile", "bin", "pkgmgr"),
|
||||
]
|
||||
for cmd in nix_cmds:
|
||||
with self.subTest(cmd=cmd):
|
||||
layer = classify_command_layer(cmd, self.repo_dir)
|
||||
self.assertEqual(layer, CliLayer.NIX)
|
||||
|
||||
def test_classify_python_binaries(self) -> None:
|
||||
# Default Python/virtualenv-style location in home
|
||||
cmd = os.path.join(self.home, ".local", "bin", "pkgmgr")
|
||||
layer = classify_command_layer(cmd, self.repo_dir)
|
||||
self.assertEqual(layer, CliLayer.PYTHON)
|
||||
|
||||
def test_classify_repo_local_binary_makefile_layer(self) -> None:
|
||||
cmd = os.path.join(self.repo_dir, "bin", "pkgmgr")
|
||||
layer = classify_command_layer(cmd, self.repo_dir)
|
||||
self.assertEqual(layer, CliLayer.MAKEFILE)
|
||||
|
||||
def test_fallback_to_python_layer(self) -> None:
|
||||
"""
|
||||
Non-system, non-nix, non-repo binaries should fall back to PYTHON.
|
||||
"""
|
||||
cmd = "/opt/pkgmgr/bin/pkgmgr"
|
||||
layer = classify_command_layer(cmd, self.repo_dir)
|
||||
self.assertEqual(layer, CliLayer.PYTHON)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
157
tests/unit/pkgmgr/actions/install/test_pipeline.py
Normal file
157
tests/unit/pkgmgr/actions/install/test_pipeline.py
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pkgmgr.actions.install.context import RepoContext
|
||||
from pkgmgr.actions.install.installers.base import BaseInstaller
|
||||
from pkgmgr.actions.install.layers import CliLayer
|
||||
from pkgmgr.actions.install.pipeline import InstallationPipeline
|
||||
|
||||
|
||||
class DummyInstaller(BaseInstaller):
|
||||
"""
|
||||
Small fake installer with configurable layer, supports() result,
|
||||
and advertised capabilities.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
layer: str | None = None,
|
||||
supports_result: bool = True,
|
||||
capabilities: set[str] | None = None,
|
||||
) -> None:
|
||||
self._name = name
|
||||
self.layer = layer # type: ignore[assignment]
|
||||
self._supports_result = supports_result
|
||||
self._capabilities = capabilities or set()
|
||||
self.ran = False
|
||||
|
||||
def supports(self, ctx: RepoContext) -> bool: # type: ignore[override]
|
||||
return self._supports_result
|
||||
|
||||
def run(self, ctx: RepoContext) -> None: # type: ignore[override]
|
||||
self.ran = True
|
||||
|
||||
def discover_capabilities(self, ctx: RepoContext) -> set[str]: # type: ignore[override]
|
||||
return set(self._capabilities)
|
||||
|
||||
|
||||
def _minimal_context() -> RepoContext:
|
||||
repo = {
|
||||
"account": "kevinveenbirkenbach",
|
||||
"repository": "test-repo",
|
||||
"alias": "test-repo",
|
||||
}
|
||||
return RepoContext(
|
||||
repo=repo,
|
||||
identifier="test-repo",
|
||||
repo_dir="/tmp/test-repo",
|
||||
repositories_base_dir="/tmp",
|
||||
bin_dir="/usr/local/bin",
|
||||
all_repos=[repo],
|
||||
no_verification=False,
|
||||
preview=False,
|
||||
quiet=False,
|
||||
clone_mode="ssh",
|
||||
update_dependencies=False,
|
||||
)
|
||||
|
||||
|
||||
class TestInstallationPipeline(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.install.pipeline.create_ink")
|
||||
@patch("pkgmgr.actions.install.pipeline.resolve_command_for_repo")
|
||||
def test_create_ink_called_when_command_resolved(
|
||||
self,
|
||||
mock_resolve_command_for_repo: MagicMock,
|
||||
mock_create_ink: MagicMock,
|
||||
) -> None:
|
||||
"""
|
||||
If resolve_command_for_repo returns a command, InstallationPipeline
|
||||
must attach it to the repo and call create_ink().
|
||||
"""
|
||||
mock_resolve_command_for_repo.return_value = "/usr/local/bin/test-repo"
|
||||
|
||||
ctx = _minimal_context()
|
||||
installer = DummyInstaller("noop-installer", supports_result=False)
|
||||
pipeline = InstallationPipeline([installer])
|
||||
|
||||
pipeline.run(ctx)
|
||||
|
||||
self.assertTrue(mock_create_ink.called)
|
||||
self.assertEqual(
|
||||
ctx.repo.get("command"),
|
||||
"/usr/local/bin/test-repo",
|
||||
)
|
||||
|
||||
@patch("pkgmgr.actions.install.pipeline.create_ink")
|
||||
@patch("pkgmgr.actions.install.pipeline.resolve_command_for_repo")
|
||||
def test_lower_priority_installers_are_skipped_if_cli_exists(
|
||||
self,
|
||||
mock_resolve_command_for_repo: MagicMock,
|
||||
mock_create_ink: MagicMock,
|
||||
) -> None:
|
||||
"""
|
||||
If the resolved command is provided by a higher-priority layer
|
||||
(e.g. OS_PACKAGES), a lower-priority installer (e.g. PYTHON)
|
||||
must be skipped.
|
||||
"""
|
||||
mock_resolve_command_for_repo.return_value = "/usr/bin/test-repo"
|
||||
|
||||
ctx = _minimal_context()
|
||||
python_installer = DummyInstaller(
|
||||
"python-installer",
|
||||
layer=CliLayer.PYTHON.value,
|
||||
supports_result=True,
|
||||
)
|
||||
pipeline = InstallationPipeline([python_installer])
|
||||
|
||||
pipeline.run(ctx)
|
||||
|
||||
self.assertFalse(
|
||||
python_installer.ran,
|
||||
"Python installer must not run when an OS_PACKAGES CLI already exists.",
|
||||
)
|
||||
self.assertEqual(ctx.repo.get("command"), "/usr/bin/test-repo")
|
||||
|
||||
@patch("pkgmgr.actions.install.pipeline.create_ink")
|
||||
@patch("pkgmgr.actions.install.pipeline.resolve_command_for_repo")
|
||||
def test_capabilities_prevent_duplicate_installers(
|
||||
self,
|
||||
mock_resolve_command_for_repo: MagicMock,
|
||||
mock_create_ink: MagicMock,
|
||||
) -> None:
|
||||
"""
|
||||
If one installer has already provided a set of capabilities,
|
||||
a second installer advertising the same capabilities should be skipped.
|
||||
"""
|
||||
mock_resolve_command_for_repo.return_value = None # no CLI initially
|
||||
|
||||
ctx = _minimal_context()
|
||||
first = DummyInstaller(
|
||||
"first-installer",
|
||||
layer=CliLayer.PYTHON.value,
|
||||
supports_result=True,
|
||||
capabilities={"cli"},
|
||||
)
|
||||
second = DummyInstaller(
|
||||
"second-installer",
|
||||
layer=CliLayer.PYTHON.value,
|
||||
supports_result=True,
|
||||
capabilities={"cli"}, # same capability
|
||||
)
|
||||
|
||||
pipeline = InstallationPipeline([first, second])
|
||||
pipeline.run(ctx)
|
||||
|
||||
self.assertTrue(first.ran, "First installer should run.")
|
||||
self.assertFalse(
|
||||
second.ran,
|
||||
"Second installer with identical capabilities must be skipped.",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -13,6 +13,7 @@ from pkgmgr.actions.release.files import (
|
||||
update_spec_version,
|
||||
update_changelog,
|
||||
update_debian_changelog,
|
||||
update_spec_changelog,
|
||||
)
|
||||
|
||||
|
||||
@@ -79,6 +80,21 @@ class TestUpdatePyprojectVersion(unittest.TestCase):
|
||||
|
||||
self.assertNotEqual(cm.exception.code, 0)
|
||||
|
||||
def test_update_pyproject_version_missing_file_is_skipped(self) -> None:
|
||||
"""
|
||||
If pyproject.toml does not exist, the function must not raise
|
||||
and must not create the file. It should simply be skipped.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, "pyproject.toml")
|
||||
self.assertFalse(os.path.exists(path))
|
||||
|
||||
# Must not raise an exception
|
||||
update_pyproject_version(path, "1.2.3", preview=False)
|
||||
|
||||
# Still no file created
|
||||
self.assertFalse(os.path.exists(path))
|
||||
|
||||
|
||||
class TestUpdateFlakeVersion(unittest.TestCase):
|
||||
def test_update_flake_version_normal(self) -> None:
|
||||
@@ -310,5 +326,94 @@ class TestUpdateDebianChangelog(unittest.TestCase):
|
||||
self.assertEqual(content, original)
|
||||
|
||||
|
||||
class TestUpdateSpecChangelog(unittest.TestCase):
|
||||
def test_update_spec_changelog_inserts_stanza_after_changelog_marker(self) -> None:
|
||||
original = textwrap.dedent(
|
||||
"""
|
||||
Name: package-manager
|
||||
Version: 0.1.0
|
||||
Release: 5%{?dist}
|
||||
|
||||
%description
|
||||
Some description.
|
||||
|
||||
%changelog
|
||||
* Mon Jan 01 2024 Old Maintainer <old@example.com> - 0.1.0-1
|
||||
- Old entry
|
||||
"""
|
||||
).strip() + "\n"
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, "package-manager.spec")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(original)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"DEBFULLNAME": "Test Maintainer",
|
||||
"DEBEMAIL": "test@example.com",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
update_spec_changelog(
|
||||
spec_path=path,
|
||||
package_name="package-manager",
|
||||
new_version="1.2.3",
|
||||
message="Fedora changelog entry",
|
||||
preview=False,
|
||||
)
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# New stanza must appear after the %changelog marker
|
||||
self.assertIn("%changelog", content)
|
||||
self.assertIn("Fedora changelog entry", content)
|
||||
self.assertIn("Test Maintainer <test@example.com>", content)
|
||||
# Old entries must still be present
|
||||
self.assertIn("Old Maintainer <old@example.com>", content)
|
||||
|
||||
def test_update_spec_changelog_preview_does_not_write(self) -> None:
|
||||
original = textwrap.dedent(
|
||||
"""
|
||||
Name: package-manager
|
||||
Version: 0.1.0
|
||||
Release: 5%{?dist}
|
||||
|
||||
%changelog
|
||||
* Mon Jan 01 2024 Old Maintainer <old@example.com> - 0.1.0-1
|
||||
- Old entry
|
||||
"""
|
||||
).strip() + "\n"
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, "package-manager.spec")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(original)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"DEBFULLNAME": "Test Maintainer",
|
||||
"DEBEMAIL": "test@example.com",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
update_spec_changelog(
|
||||
spec_path=path,
|
||||
package_name="package-manager",
|
||||
new_version="1.2.3",
|
||||
message="Fedora changelog entry",
|
||||
preview=True,
|
||||
)
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# In preview mode, the spec file must not change
|
||||
self.assertEqual(content, original)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -19,6 +19,7 @@ class TestReleaseOrchestration(unittest.TestCase):
|
||||
patch("pkgmgr.actions.release.update_pkgbuild_version") as mock_update_pkgbuild, \
|
||||
patch("pkgmgr.actions.release.update_spec_version") as mock_update_spec, \
|
||||
patch("pkgmgr.actions.release.update_debian_changelog") as mock_update_debian_changelog, \
|
||||
patch("pkgmgr.actions.release.update_spec_changelog") as mock_update_spec_changelog, \
|
||||
patch("pkgmgr.actions.release.run_git_command") as mock_run_git_command, \
|
||||
patch("pkgmgr.actions.release.sync_branch_with_remote") as mock_sync_branch, \
|
||||
patch("pkgmgr.actions.release.update_latest_tag") as mock_update_latest_tag:
|
||||
@@ -48,7 +49,7 @@ class TestReleaseOrchestration(unittest.TestCase):
|
||||
self.assertEqual(args[1], "1.2.4")
|
||||
self.assertEqual(kwargs.get("preview"), False)
|
||||
|
||||
# changelog update
|
||||
# changelog update (Projekt)
|
||||
mock_update_changelog.assert_called_once()
|
||||
args, kwargs = mock_update_changelog.call_args
|
||||
self.assertEqual(args[0], "CHANGELOG.md")
|
||||
@@ -72,6 +73,13 @@ class TestReleaseOrchestration(unittest.TestCase):
|
||||
False,
|
||||
)
|
||||
|
||||
# Fedora / RPM %changelog helper
|
||||
mock_update_spec_changelog.assert_called_once()
|
||||
self.assertEqual(
|
||||
mock_update_spec_changelog.call_args[1].get("preview"),
|
||||
False,
|
||||
)
|
||||
|
||||
# Git operations
|
||||
mock_get_current_branch.assert_called_once()
|
||||
self.assertEqual(mock_get_current_branch.return_value, "develop")
|
||||
@@ -96,6 +104,7 @@ class TestReleaseOrchestration(unittest.TestCase):
|
||||
patch("pkgmgr.actions.release.update_pkgbuild_version") as mock_update_pkgbuild, \
|
||||
patch("pkgmgr.actions.release.update_spec_version") as mock_update_spec, \
|
||||
patch("pkgmgr.actions.release.update_debian_changelog") as mock_update_debian_changelog, \
|
||||
patch("pkgmgr.actions.release.update_spec_changelog") as mock_update_spec_changelog, \
|
||||
patch("pkgmgr.actions.release.run_git_command") as mock_run_git_command, \
|
||||
patch("pkgmgr.actions.release.sync_branch_with_remote") as mock_sync_branch, \
|
||||
patch("pkgmgr.actions.release.update_latest_tag") as mock_update_latest_tag:
|
||||
@@ -129,6 +138,10 @@ class TestReleaseOrchestration(unittest.TestCase):
|
||||
mock_update_debian_changelog.assert_called_once()
|
||||
self.assertTrue(mock_update_debian_changelog.call_args[1].get("preview"))
|
||||
|
||||
# Fedora / RPM spec changelog helper in preview mode
|
||||
mock_update_spec_changelog.assert_called_once()
|
||||
self.assertTrue(mock_update_spec_changelog.call_args[1].get("preview"))
|
||||
|
||||
# In preview mode no real git commands must be executed
|
||||
mock_run_git_command.assert_not_called()
|
||||
|
||||
|
||||
309
tests/unit/pkgmgr/actions/repos/test_pull_with_verification.py
Normal file
309
tests/unit/pkgmgr/actions/repos/test_pull_with_verification.py
Normal file
@@ -0,0 +1,309 @@
|
||||
import io
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from pkgmgr.actions.repository.pull import pull_with_verification
|
||||
|
||||
|
||||
class TestPullWithVerification(unittest.TestCase):
|
||||
"""
|
||||
Comprehensive unit tests for pull_with_verification().
|
||||
|
||||
These tests verify:
|
||||
- Preview mode behaviour
|
||||
- Verification logic (prompting, bypassing, skipping)
|
||||
- subprocess.run invocation
|
||||
- Repository directory existence checks
|
||||
- Handling of extra git pull arguments
|
||||
"""
|
||||
|
||||
def _setup_mocks(self, mock_exists, mock_get_repo_id, mock_get_repo_dir,
|
||||
mock_verify, exists=True, verified_ok=True,
|
||||
errors=None, verified_info=True):
|
||||
"""Helper to configure repetitive mock behavior."""
|
||||
repo = {
|
||||
"name": "pkgmgr",
|
||||
"verified": {"gpg_keys": ["ABCDEF"]} if verified_info else None,
|
||||
}
|
||||
mock_exists.return_value = exists
|
||||
mock_get_repo_id.return_value = "pkgmgr"
|
||||
mock_get_repo_dir.return_value = "/fake/base/pkgmgr"
|
||||
mock_verify.return_value = (
|
||||
verified_ok,
|
||||
errors or [],
|
||||
"deadbeef", # commit hash
|
||||
"ABCDEF", # signing key
|
||||
)
|
||||
return repo
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
@patch("pkgmgr.actions.repository.pull.subprocess.run")
|
||||
@patch("pkgmgr.actions.repository.pull.verify_repository")
|
||||
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
|
||||
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
|
||||
@patch("pkgmgr.actions.repository.pull.os.path.exists")
|
||||
@patch("builtins.input")
|
||||
def test_preview_mode_non_interactive(
|
||||
self,
|
||||
mock_input,
|
||||
mock_exists,
|
||||
mock_get_repo_id,
|
||||
mock_get_repo_dir,
|
||||
mock_verify,
|
||||
mock_subprocess,
|
||||
):
|
||||
"""
|
||||
Preview mode must NEVER request user input and must NEVER execute git.
|
||||
It must only print the preview command.
|
||||
"""
|
||||
repo = self._setup_mocks(
|
||||
mock_exists,
|
||||
mock_get_repo_id,
|
||||
mock_get_repo_dir,
|
||||
mock_verify,
|
||||
exists=True,
|
||||
verified_ok=False,
|
||||
errors=["bad signature"],
|
||||
verified_info=True,
|
||||
)
|
||||
|
||||
buf = io.StringIO()
|
||||
with patch("sys.stdout", new=buf):
|
||||
pull_with_verification(
|
||||
selected_repos=[repo],
|
||||
repositories_base_dir="/fake/base",
|
||||
all_repos=[repo],
|
||||
extra_args=["--ff-only"],
|
||||
no_verification=False,
|
||||
preview=True,
|
||||
)
|
||||
|
||||
output = buf.getvalue()
|
||||
self.assertIn(
|
||||
"[Preview] In '/fake/base/pkgmgr': git pull --ff-only",
|
||||
output,
|
||||
)
|
||||
|
||||
mock_input.assert_not_called()
|
||||
mock_subprocess.assert_not_called()
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
@patch("pkgmgr.actions.repository.pull.subprocess.run")
|
||||
@patch("pkgmgr.actions.repository.pull.verify_repository")
|
||||
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
|
||||
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
|
||||
@patch("pkgmgr.actions.repository.pull.os.path.exists")
|
||||
@patch("builtins.input")
|
||||
def test_verification_failure_user_declines(
|
||||
self,
|
||||
mock_input,
|
||||
mock_exists,
|
||||
mock_get_repo_id,
|
||||
mock_get_repo_dir,
|
||||
mock_verify,
|
||||
mock_subprocess,
|
||||
):
|
||||
"""
|
||||
If verification fails and preview=False, the user is prompted.
|
||||
If the user declines ('n'), no git command is executed.
|
||||
"""
|
||||
repo = self._setup_mocks(
|
||||
mock_exists,
|
||||
mock_get_repo_id,
|
||||
mock_get_repo_dir,
|
||||
mock_verify,
|
||||
verified_ok=False,
|
||||
errors=["signature invalid"],
|
||||
)
|
||||
|
||||
mock_input.return_value = "n"
|
||||
|
||||
buf = io.StringIO()
|
||||
with patch("sys.stdout", new=buf):
|
||||
pull_with_verification(
|
||||
selected_repos=[repo],
|
||||
repositories_base_dir="/fake/base",
|
||||
all_repos=[repo],
|
||||
extra_args=[],
|
||||
no_verification=False,
|
||||
preview=False,
|
||||
)
|
||||
|
||||
mock_input.assert_called_once()
|
||||
mock_subprocess.assert_not_called()
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
@patch("pkgmgr.actions.repository.pull.subprocess.run")
|
||||
@patch("pkgmgr.actions.repository.pull.verify_repository")
|
||||
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
|
||||
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
|
||||
@patch("pkgmgr.actions.repository.pull.os.path.exists")
|
||||
@patch("builtins.input")
|
||||
def test_verification_failure_user_accepts_runs_git(
|
||||
self,
|
||||
mock_input,
|
||||
mock_exists,
|
||||
mock_get_repo_id,
|
||||
mock_get_repo_dir,
|
||||
mock_verify,
|
||||
mock_subprocess,
|
||||
):
|
||||
"""
|
||||
If verification fails and the user accepts ('y'),
|
||||
then the git pull should be executed.
|
||||
"""
|
||||
repo = self._setup_mocks(
|
||||
mock_exists,
|
||||
mock_get_repo_id,
|
||||
mock_get_repo_dir,
|
||||
mock_verify,
|
||||
verified_ok=False,
|
||||
errors=["invalid"],
|
||||
)
|
||||
|
||||
mock_input.return_value = "y"
|
||||
mock_subprocess.return_value = MagicMock(returncode=0)
|
||||
|
||||
pull_with_verification(
|
||||
selected_repos=[repo],
|
||||
repositories_base_dir="/fake/base",
|
||||
all_repos=[repo],
|
||||
extra_args=[],
|
||||
no_verification=False,
|
||||
preview=False,
|
||||
)
|
||||
|
||||
mock_subprocess.assert_called_once()
|
||||
mock_input.assert_called_once()
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
@patch("pkgmgr.actions.repository.pull.subprocess.run")
|
||||
@patch("pkgmgr.actions.repository.pull.verify_repository")
|
||||
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
|
||||
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
|
||||
@patch("pkgmgr.actions.repository.pull.os.path.exists")
|
||||
@patch("builtins.input")
|
||||
def test_verification_success_no_prompt(
|
||||
self,
|
||||
mock_input,
|
||||
mock_exists,
|
||||
mock_get_repo_id,
|
||||
mock_get_repo_dir,
|
||||
mock_verify,
|
||||
mock_subprocess,
|
||||
):
|
||||
"""
|
||||
If verification is successful, the user should NOT be prompted,
|
||||
and git pull should run immediately.
|
||||
"""
|
||||
repo = self._setup_mocks(
|
||||
mock_exists,
|
||||
mock_get_repo_id,
|
||||
mock_get_repo_dir,
|
||||
mock_verify,
|
||||
verified_ok=True,
|
||||
)
|
||||
|
||||
mock_subprocess.return_value = MagicMock(returncode=0)
|
||||
|
||||
pull_with_verification(
|
||||
selected_repos=[repo],
|
||||
repositories_base_dir="/fake/base",
|
||||
all_repos=[repo],
|
||||
extra_args=["--rebase"],
|
||||
no_verification=False,
|
||||
preview=False,
|
||||
)
|
||||
|
||||
mock_input.assert_not_called()
|
||||
mock_subprocess.assert_called_once()
|
||||
cmd = mock_subprocess.call_args[0][0]
|
||||
self.assertIn("git pull --rebase", cmd)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
@patch("pkgmgr.actions.repository.pull.subprocess.run")
|
||||
@patch("pkgmgr.actions.repository.pull.verify_repository")
|
||||
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
|
||||
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
|
||||
@patch("pkgmgr.actions.repository.pull.os.path.exists")
|
||||
@patch("builtins.input")
|
||||
def test_directory_missing_skips_repo(
|
||||
self,
|
||||
mock_input,
|
||||
mock_exists,
|
||||
mock_get_repo_id,
|
||||
mock_get_repo_dir,
|
||||
mock_verify,
|
||||
mock_subprocess,
|
||||
):
|
||||
"""
|
||||
If the repository directory does not exist, the repo must be skipped
|
||||
silently and no git command executed.
|
||||
"""
|
||||
repo = self._setup_mocks(
|
||||
mock_exists,
|
||||
mock_get_repo_id,
|
||||
mock_get_repo_dir,
|
||||
mock_verify,
|
||||
exists=False,
|
||||
)
|
||||
|
||||
buf = io.StringIO()
|
||||
with patch("sys.stdout", new=buf):
|
||||
pull_with_verification(
|
||||
selected_repos=[repo],
|
||||
repositories_base_dir="/fake/base",
|
||||
all_repos=[repo],
|
||||
extra_args=[],
|
||||
no_verification=False,
|
||||
preview=False,
|
||||
)
|
||||
|
||||
output = buf.getvalue()
|
||||
self.assertIn("not found", output)
|
||||
|
||||
mock_input.assert_not_called()
|
||||
mock_subprocess.assert_not_called()
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
@patch("pkgmgr.actions.repository.pull.subprocess.run")
|
||||
@patch("pkgmgr.actions.repository.pull.verify_repository")
|
||||
@patch("pkgmgr.actions.repository.pull.get_repo_dir")
|
||||
@patch("pkgmgr.actions.repository.pull.get_repo_identifier")
|
||||
@patch("pkgmgr.actions.repository.pull.os.path.exists")
|
||||
@patch("builtins.input")
|
||||
def test_no_verification_flag_skips_prompt(
|
||||
self,
|
||||
mock_input,
|
||||
mock_exists,
|
||||
mock_get_repo_id,
|
||||
mock_get_repo_dir,
|
||||
mock_verify,
|
||||
mock_subprocess,
|
||||
):
|
||||
"""
|
||||
If no_verification=True, verification failures must NOT prompt.
|
||||
Git pull should run directly.
|
||||
"""
|
||||
repo = self._setup_mocks(
|
||||
mock_exists,
|
||||
mock_get_repo_id,
|
||||
mock_get_repo_dir,
|
||||
mock_verify,
|
||||
verified_ok=False,
|
||||
errors=["invalid"],
|
||||
)
|
||||
|
||||
mock_subprocess.return_value = MagicMock(returncode=0)
|
||||
|
||||
pull_with_verification(
|
||||
selected_repos=[repo],
|
||||
repositories_base_dir="/fake/base",
|
||||
all_repos=[repo],
|
||||
extra_args=[],
|
||||
no_verification=True,
|
||||
preview=False,
|
||||
)
|
||||
|
||||
mock_input.assert_not_called()
|
||||
mock_subprocess.assert_called_once()
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.branch import open_branch
|
||||
@@ -13,9 +12,10 @@ class TestOpenBranch(unittest.TestCase):
|
||||
def test_open_branch_with_explicit_name_and_default_base(self, mock_run_git) -> None:
|
||||
"""
|
||||
open_branch(name, base='main') should:
|
||||
- resolve base branch (prefers 'main', falls back to 'master')
|
||||
- fetch origin
|
||||
- checkout base
|
||||
- pull base
|
||||
- checkout resolved base
|
||||
- pull resolved base
|
||||
- create new branch
|
||||
- push with upstream
|
||||
"""
|
||||
@@ -25,6 +25,7 @@ class TestOpenBranch(unittest.TestCase):
|
||||
|
||||
# We expect a specific sequence of Git calls.
|
||||
expected_calls = [
|
||||
(["rev-parse", "--verify", "main"], "/repo"),
|
||||
(["fetch", "origin"], "/repo"),
|
||||
(["checkout", "main"], "/repo"),
|
||||
(["pull", "origin", "main"], "/repo"),
|
||||
@@ -50,7 +51,7 @@ class TestOpenBranch(unittest.TestCase):
|
||||
) -> None:
|
||||
"""
|
||||
If name is None/empty, open_branch should prompt via input()
|
||||
and still perform the full Git sequence.
|
||||
and still perform the full Git sequence on the resolved base.
|
||||
"""
|
||||
mock_run_git.return_value = ""
|
||||
|
||||
@@ -60,6 +61,7 @@ class TestOpenBranch(unittest.TestCase):
|
||||
mock_input.assert_called_once()
|
||||
|
||||
expected_calls = [
|
||||
(["rev-parse", "--verify", "develop"], "/repo"),
|
||||
(["fetch", "origin"], "/repo"),
|
||||
(["checkout", "develop"], "/repo"),
|
||||
(["pull", "origin", "develop"], "/repo"),
|
||||
@@ -76,15 +78,20 @@ class TestOpenBranch(unittest.TestCase):
|
||||
self.assertEqual(kwargs.get("cwd"), cwd_expected)
|
||||
|
||||
@patch("pkgmgr.actions.branch.run_git")
|
||||
def test_open_branch_raises_runtimeerror_on_git_failure(self, mock_run_git) -> None:
|
||||
def test_open_branch_raises_runtimeerror_on_fetch_failure(self, mock_run_git) -> None:
|
||||
"""
|
||||
If a GitError occurs (e.g. fetch fails), open_branch should
|
||||
raise a RuntimeError with a helpful message.
|
||||
If a GitError occurs on fetch, open_branch should raise a RuntimeError
|
||||
with a helpful message.
|
||||
"""
|
||||
|
||||
def side_effect(args, cwd="."):
|
||||
# Simulate a failure on the first call (fetch)
|
||||
raise GitError("simulated fetch failure")
|
||||
# First call: base resolution (rev-parse) should succeed
|
||||
if args == ["rev-parse", "--verify", "main"]:
|
||||
return ""
|
||||
# Second call: fetch should fail
|
||||
if args == ["fetch", "origin"]:
|
||||
raise GitError("simulated fetch failure")
|
||||
return ""
|
||||
|
||||
mock_run_git.side_effect = side_effect
|
||||
|
||||
@@ -95,6 +102,45 @@ class TestOpenBranch(unittest.TestCase):
|
||||
self.assertIn("Failed to fetch from origin", msg)
|
||||
self.assertIn("simulated fetch failure", msg)
|
||||
|
||||
@patch("pkgmgr.actions.branch.run_git")
|
||||
def test_open_branch_uses_fallback_master_if_main_missing(self, mock_run_git) -> None:
|
||||
"""
|
||||
If the preferred base (e.g. 'main') does not exist, open_branch should
|
||||
fall back to the fallback base (default: 'master').
|
||||
"""
|
||||
|
||||
def side_effect(args, cwd="."):
|
||||
# First: rev-parse main -> fails
|
||||
if args == ["rev-parse", "--verify", "main"]:
|
||||
raise GitError("main does not exist")
|
||||
# Second: rev-parse master -> succeeds
|
||||
if args == ["rev-parse", "--verify", "master"]:
|
||||
return ""
|
||||
# Then normal flow on master
|
||||
return ""
|
||||
|
||||
mock_run_git.side_effect = side_effect
|
||||
|
||||
open_branch(name="feature/fallback", base_branch="main", cwd="/repo")
|
||||
|
||||
expected_calls = [
|
||||
(["rev-parse", "--verify", "main"], "/repo"),
|
||||
(["rev-parse", "--verify", "master"], "/repo"),
|
||||
(["fetch", "origin"], "/repo"),
|
||||
(["checkout", "master"], "/repo"),
|
||||
(["pull", "origin", "master"], "/repo"),
|
||||
(["checkout", "-b", "feature/fallback"], "/repo"),
|
||||
(["push", "-u", "origin", "feature/fallback"], "/repo"),
|
||||
]
|
||||
|
||||
self.assertEqual(mock_run_git.call_count, len(expected_calls))
|
||||
for call, (args_expected, cwd_expected) in zip(
|
||||
mock_run_git.call_args_list, expected_calls
|
||||
):
|
||||
args, kwargs = call
|
||||
self.assertEqual(args[0], args_expected)
|
||||
self.assertEqual(kwargs.get("cwd"), cwd_expected)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
216
tests/unit/pkgmgr/cli/commands/test_repos.py
Normal file
216
tests/unit/pkgmgr/cli/commands/test_repos.py
Normal file
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Unit tests for pkgmgr.cli.commands.repos
|
||||
|
||||
We focus on the behaviour of:
|
||||
|
||||
- _resolve_repository_directory(...)
|
||||
- handle_repos_command(...) for the "path" and "shell" commands
|
||||
|
||||
Goals:
|
||||
|
||||
* "path" should:
|
||||
- print repo["directory"] if present
|
||||
- fall back to get_repo_dir(ctx.repositories_base_dir, repo) otherwise
|
||||
- handle "no selected repos" gracefully
|
||||
|
||||
* "shell" should:
|
||||
- resolve the directory via _resolve_repository_directory(...)
|
||||
- call run_command(...) with cwd set to the resolved directory
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import sys
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.cli.commands.repos import handle_repos_command
|
||||
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
|
||||
|
||||
class TestReposCommand(unittest.TestCase):
|
||||
def _make_ctx(self, repositories: List[Repository]) -> CLIContext:
|
||||
"""
|
||||
Helper to build a minimal CLIContext for tests.
|
||||
"""
|
||||
return CLIContext(
|
||||
config_merged={},
|
||||
repositories_base_dir="/base/dir",
|
||||
all_repositories=repositories,
|
||||
binaries_dir="/bin/dir",
|
||||
user_config_path="~/.config/pkgmgr/config.yaml",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# "path" command tests
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_path_uses_explicit_directory_if_present(self) -> None:
|
||||
"""
|
||||
When repository["directory"] is present, handle_repos_command("path")
|
||||
should print this value directly without calling get_repo_dir().
|
||||
"""
|
||||
repos: List[Repository] = [
|
||||
{
|
||||
"provider": "github.com",
|
||||
"account": "kevinveenbirkenbach",
|
||||
"repository": "package-manager",
|
||||
"directory": "/custom/path/pkgmgr",
|
||||
}
|
||||
]
|
||||
ctx = self._make_ctx(repos)
|
||||
|
||||
args = SimpleNamespace(
|
||||
command="path",
|
||||
preview=False,
|
||||
list=False,
|
||||
system=False,
|
||||
extra_args=[],
|
||||
)
|
||||
|
||||
buf = io.StringIO()
|
||||
|
||||
with patch(
|
||||
"pkgmgr.cli.commands.repos.get_repo_dir"
|
||||
) as mock_get_repo_dir, redirect_stdout(buf):
|
||||
handle_repos_command(args, ctx, selected=repos)
|
||||
|
||||
output = buf.getvalue().strip().splitlines()
|
||||
self.assertIn("/custom/path/pkgmgr", output)
|
||||
mock_get_repo_dir.assert_not_called()
|
||||
|
||||
def test_path_falls_back_to_get_repo_dir_if_directory_missing(self) -> None:
|
||||
"""
|
||||
When repository["directory"] is missing, handle_repos_command("path")
|
||||
should call get_repo_dir(ctx.repositories_base_dir, repo) and print
|
||||
the returned value.
|
||||
"""
|
||||
repos: List[Repository] = [
|
||||
{
|
||||
"provider": "github.com",
|
||||
"account": "kevinveenbirkenbach",
|
||||
"repository": "package-manager",
|
||||
}
|
||||
]
|
||||
ctx = self._make_ctx(repos)
|
||||
|
||||
args = SimpleNamespace(
|
||||
command="path",
|
||||
preview=False,
|
||||
list=False,
|
||||
system=False,
|
||||
extra_args=[],
|
||||
)
|
||||
|
||||
buf = io.StringIO()
|
||||
|
||||
with patch(
|
||||
"pkgmgr.cli.commands.repos.get_repo_dir",
|
||||
return_value="/resolved/from/get_repo_dir",
|
||||
) as mock_get_repo_dir, redirect_stdout(buf):
|
||||
handle_repos_command(args, ctx, selected=repos)
|
||||
|
||||
output = buf.getvalue().strip().splitlines()
|
||||
self.assertIn("/resolved/from/get_repo_dir", output)
|
||||
mock_get_repo_dir.assert_called_once_with("/base/dir", repos[0])
|
||||
|
||||
def test_path_with_no_selected_repos_prints_message(self) -> None:
|
||||
"""
|
||||
When 'selected' is empty, the 'path' command should print a friendly
|
||||
message and not raise.
|
||||
"""
|
||||
ctx = self._make_ctx(repositories=[])
|
||||
args = SimpleNamespace(
|
||||
command="path",
|
||||
preview=False,
|
||||
list=False,
|
||||
system=False,
|
||||
extra_args=[],
|
||||
)
|
||||
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
handle_repos_command(args, ctx, selected=[])
|
||||
|
||||
output = buf.getvalue()
|
||||
self.assertIn("No repositories selected for path", output)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# "shell" command tests
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_shell_resolves_directory_and_calls_run_command(self) -> None:
|
||||
"""
|
||||
'shell' should resolve the repository directory and pass it as cwd
|
||||
to run_command(), along with the full shell command string.
|
||||
"""
|
||||
repos: List[Repository] = [
|
||||
{
|
||||
"provider": "github.com",
|
||||
"account": "kevinveenbirkenbach",
|
||||
"repository": "package-manager",
|
||||
}
|
||||
]
|
||||
ctx = self._make_ctx(repos)
|
||||
|
||||
args = SimpleNamespace(
|
||||
command="shell",
|
||||
preview=False,
|
||||
shell_command=["echo", "hello"],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"pkgmgr.cli.commands.repos.get_repo_dir",
|
||||
return_value="/resolved/for/shell",
|
||||
) as mock_get_repo_dir, patch(
|
||||
"pkgmgr.cli.commands.repos.run_command"
|
||||
) as mock_run_command:
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
handle_repos_command(args, ctx, selected=repos)
|
||||
|
||||
# _resolve_repository_directory should have called get_repo_dir
|
||||
mock_get_repo_dir.assert_called_once_with("/base/dir", repos[0])
|
||||
|
||||
# run_command should be invoked with cwd set to the resolved path
|
||||
mock_run_command.assert_called_once()
|
||||
called_args, called_kwargs = mock_run_command.call_args
|
||||
|
||||
self.assertEqual("echo hello", called_args[0]) # command string
|
||||
self.assertEqual("/resolved/for/shell", called_kwargs["cwd"])
|
||||
self.assertFalse(called_kwargs["preview"])
|
||||
|
||||
def test_shell_without_command_exits_with_error(self) -> None:
|
||||
"""
|
||||
'shell' without -c/--command should print an error and exit with code 2.
|
||||
"""
|
||||
repos: List[Repository] = []
|
||||
ctx = self._make_ctx(repos)
|
||||
|
||||
args = SimpleNamespace(
|
||||
command="shell",
|
||||
preview=False,
|
||||
shell_command=[],
|
||||
)
|
||||
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf), self.assertRaises(SystemExit) as cm:
|
||||
handle_repos_command(args, ctx, selected=repos)
|
||||
|
||||
self.assertEqual(cm.exception.code, 2)
|
||||
output = buf.getvalue()
|
||||
self.assertIn("'shell' requires a command via -c/--command", output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
168
tests/unit/pkgmgr/cli/commands/test_tools.py
Normal file
168
tests/unit/pkgmgr/cli/commands/test_tools.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from pkgmgr.cli.commands.tools import handle_tools_command
|
||||
|
||||
Repository = Dict[str, Any]
|
||||
|
||||
|
||||
class _Args:
|
||||
"""
|
||||
Simple helper object to mimic argparse.Namespace for handle_tools_command.
|
||||
"""
|
||||
|
||||
def __init__(self, command: str) -> None:
|
||||
self.command = command
|
||||
|
||||
|
||||
class TestHandleToolsCommand(unittest.TestCase):
|
||||
"""
|
||||
Unit tests for pkgmgr.cli.commands.tools.handle_tools_command.
|
||||
|
||||
We focus on:
|
||||
- Correct path resolution for repositories that have a 'directory' key.
|
||||
- Correct shell commands for 'explore' and 'terminal'.
|
||||
- Proper workspace creation and invocation of 'code' for the 'code' command.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
# Two fake repositories with explicit 'directory' entries so that
|
||||
# _resolve_repository_path() does not need to call get_repo_dir().
|
||||
self.repos: List[Repository] = [
|
||||
{"alias": "repo1", "directory": "/tmp/repo1"},
|
||||
{"alias": "repo2", "directory": "/tmp/repo2"},
|
||||
]
|
||||
|
||||
# Minimal CLI context; only attributes used in tools.py are provided.
|
||||
self.ctx = SimpleNamespace(
|
||||
config_merged={"directories": {"workspaces": "~/Workspaces"}},
|
||||
all_repositories=self.repos,
|
||||
repositories_base_dir="/base/dir",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Helper
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _patch_run_command(self):
|
||||
"""
|
||||
Convenience context manager for patching run_command in tools module.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
return patch("pkgmgr.cli.commands.tools.run_command")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Tests for 'explore'
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_explore_uses_directory_paths(self) -> None:
|
||||
"""
|
||||
The 'explore' command should call Nautilus with the resolved
|
||||
repository paths and use '& disown' as in the implementation.
|
||||
"""
|
||||
from unittest.mock import call
|
||||
|
||||
args = _Args(command="explore")
|
||||
|
||||
with self._patch_run_command() as mock_run_command:
|
||||
handle_tools_command(args, self.ctx, self.repos)
|
||||
|
||||
expected_calls = [
|
||||
call('nautilus "/tmp/repo1" & disown'),
|
||||
call('nautilus "/tmp/repo2" & disown'),
|
||||
]
|
||||
self.assertEqual(mock_run_command.call_args_list, expected_calls)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Tests for 'terminal'
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_terminal_uses_directory_paths(self) -> None:
|
||||
"""
|
||||
The 'terminal' command should open a GNOME Terminal tab with the
|
||||
repository as its working directory.
|
||||
"""
|
||||
from unittest.mock import call
|
||||
|
||||
args = _Args(command="terminal")
|
||||
|
||||
with self._patch_run_command() as mock_run_command:
|
||||
handle_tools_command(args, self.ctx, self.repos)
|
||||
|
||||
expected_calls = [
|
||||
call('gnome-terminal --tab --working-directory="/tmp/repo1"'),
|
||||
call('gnome-terminal --tab --working-directory="/tmp/repo2"'),
|
||||
]
|
||||
self.assertEqual(mock_run_command.call_args_list, expected_calls)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Tests for 'code'
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_code_creates_workspace_and_calls_code(self) -> None:
|
||||
"""
|
||||
The 'code' command should:
|
||||
|
||||
- Build a workspace file name from sorted repository identifiers.
|
||||
- Resolve the repository paths into VS Code 'folders'.
|
||||
- Create the workspace file if it does not exist.
|
||||
- Call 'code "<workspace_file>"' via run_command.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
args = _Args(command="code")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Patch expanduser so that the configured '~/Workspaces'
|
||||
# resolves into our temporary directory.
|
||||
with patch(
|
||||
"pkgmgr.cli.commands.tools.os.path.expanduser"
|
||||
) as mock_expanduser:
|
||||
mock_expanduser.return_value = tmpdir
|
||||
|
||||
# Patch get_repo_identifier so the resulting workspace file
|
||||
# name is deterministic and easy to assert.
|
||||
with patch(
|
||||
"pkgmgr.cli.commands.tools.get_repo_identifier"
|
||||
) as mock_get_identifier:
|
||||
mock_get_identifier.side_effect = ["repo-b", "repo-a"]
|
||||
|
||||
with self._patch_run_command() as mock_run_command:
|
||||
handle_tools_command(args, self.ctx, self.repos)
|
||||
|
||||
# The identifiers are ['repo-b', 'repo-a'], which are
|
||||
# sorted to ['repo-a', 'repo-b'] and joined with '_'.
|
||||
expected_workspace_name = "repo-a_repo-b.code-workspace"
|
||||
expected_workspace_file = os.path.join(
|
||||
tmpdir, expected_workspace_name
|
||||
)
|
||||
|
||||
# Workspace file should have been created.
|
||||
self.assertTrue(
|
||||
os.path.exists(expected_workspace_file),
|
||||
"Workspace file was not created.",
|
||||
)
|
||||
|
||||
# The content of the workspace must be valid JSON with
|
||||
# the expected folder paths.
|
||||
with open(expected_workspace_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
self.assertIn("folders", data)
|
||||
folder_paths = {f["path"] for f in data["folders"]}
|
||||
self.assertEqual(
|
||||
folder_paths,
|
||||
{"/tmp/repo1", "/tmp/repo2"},
|
||||
)
|
||||
|
||||
# And VS Code must have been invoked with that workspace.
|
||||
mock_run_command.assert_called_once_with(
|
||||
f'code "{expected_workspace_file}"'
|
||||
)
|
||||
0
tests/unit/pkgmgr/core/command/__init__.py
Normal file
0
tests/unit/pkgmgr/core/command/__init__.py
Normal file
108
tests/unit/pkgmgr/core/command/test_ink.py
Normal file
108
tests/unit/pkgmgr/core/command/test_ink.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.core.command.ink import create_ink
|
||||
|
||||
|
||||
class TestCreateInk(unittest.TestCase):
|
||||
@patch("pkgmgr.core.command.ink.get_repo_dir")
|
||||
@patch("pkgmgr.core.command.ink.get_repo_identifier")
|
||||
def test_self_referential_command_skips_symlink(
|
||||
self,
|
||||
mock_get_repo_identifier,
|
||||
mock_get_repo_dir,
|
||||
):
|
||||
"""
|
||||
If the resolved command path is identical to the final link target,
|
||||
create_ink() must NOT replace it with a self-referential symlink.
|
||||
|
||||
This simulates the situation where the command already lives at
|
||||
~/.local/bin/<identifier> and we would otherwise create a symlink
|
||||
pointing to itself.
|
||||
"""
|
||||
mock_get_repo_identifier.return_value = "package-manager"
|
||||
mock_get_repo_dir.return_value = "/fake/repo"
|
||||
|
||||
with tempfile.TemporaryDirectory() as bin_dir:
|
||||
# Simulate an existing real binary at the final link location.
|
||||
command_path = os.path.join(bin_dir, "package-manager")
|
||||
with open(command_path, "w", encoding="utf-8") as f:
|
||||
f.write("#!/bin/sh\necho package-manager\n")
|
||||
|
||||
# Sanity check: not a symlink yet.
|
||||
self.assertTrue(os.path.exists(command_path))
|
||||
self.assertFalse(os.path.islink(command_path))
|
||||
|
||||
repo = {"command": command_path}
|
||||
|
||||
# This must NOT turn the file into a self-referential symlink.
|
||||
create_ink(
|
||||
repo=repo,
|
||||
repositories_base_dir="/fake/base",
|
||||
bin_dir=bin_dir,
|
||||
all_repos=[],
|
||||
quiet=True,
|
||||
preview=False,
|
||||
)
|
||||
|
||||
# After create_ink(), the file must still exist and must not be a symlink.
|
||||
self.assertTrue(os.path.exists(command_path))
|
||||
self.assertFalse(
|
||||
os.path.islink(command_path),
|
||||
"create_ink() must not create a self-referential symlink "
|
||||
"when command == link_path",
|
||||
)
|
||||
|
||||
@patch("pkgmgr.core.command.ink.get_repo_dir")
|
||||
@patch("pkgmgr.core.command.ink.get_repo_identifier")
|
||||
def test_create_symlink_for_normal_command(
|
||||
self,
|
||||
mock_get_repo_identifier,
|
||||
mock_get_repo_dir,
|
||||
):
|
||||
"""
|
||||
In the normal case (command path != link target), create_ink()
|
||||
must create a symlink in bin_dir pointing to the given command,
|
||||
and optionally an alias symlink when repo['alias'] is set.
|
||||
"""
|
||||
mock_get_repo_identifier.return_value = "mytool"
|
||||
|
||||
with tempfile.TemporaryDirectory() as repo_dir, tempfile.TemporaryDirectory() as bin_dir:
|
||||
mock_get_repo_dir.return_value = repo_dir
|
||||
|
||||
# Create a fake executable inside the repository.
|
||||
command_path = os.path.join(repo_dir, "main.sh")
|
||||
with open(command_path, "w", encoding="utf-8") as f:
|
||||
f.write("#!/bin/sh\necho mytool\n")
|
||||
os.chmod(command_path, 0o755)
|
||||
|
||||
repo = {
|
||||
"command": command_path,
|
||||
"alias": "mt",
|
||||
}
|
||||
|
||||
create_ink(
|
||||
repo=repo,
|
||||
repositories_base_dir="/fake/base",
|
||||
bin_dir=bin_dir,
|
||||
all_repos=[],
|
||||
quiet=True,
|
||||
preview=False,
|
||||
)
|
||||
|
||||
link_path = os.path.join(bin_dir, "mytool")
|
||||
alias_path = os.path.join(bin_dir, "mt")
|
||||
|
||||
# Main link must exist and point to the command.
|
||||
self.assertTrue(os.path.islink(link_path))
|
||||
self.assertEqual(os.readlink(link_path), command_path)
|
||||
|
||||
# Alias must exist and point to the main link.
|
||||
self.assertTrue(os.path.islink(alias_path))
|
||||
self.assertEqual(os.readlink(alias_path), link_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
212
tests/unit/pkgmgr/core/command/test_resolve.py
Normal file
212
tests/unit/pkgmgr/core/command/test_resolve.py
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.core.command.resolve import (
|
||||
_find_python_package_root,
|
||||
_nix_binary_candidates,
|
||||
_path_binary_candidates,
|
||||
resolve_command_for_repo,
|
||||
)
|
||||
|
||||
|
||||
class TestHelpers(unittest.TestCase):
|
||||
def test_find_python_package_root_none_when_missing_src(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = _find_python_package_root(tmpdir)
|
||||
self.assertIsNone(root)
|
||||
|
||||
def test_find_python_package_root_returns_existing_dir_or_none(self) -> None:
|
||||
"""
|
||||
We only assert that the helper does not return an invalid path.
|
||||
The exact selection heuristic is intentionally left flexible since
|
||||
the implementation may evolve.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
src_dir = os.path.join(tmpdir, "src", "mypkg")
|
||||
os.makedirs(src_dir, exist_ok=True)
|
||||
init_path = os.path.join(src_dir, "__init__.py")
|
||||
with open(init_path, "w", encoding="utf-8") as f:
|
||||
f.write("# package marker\n")
|
||||
|
||||
root = _find_python_package_root(tmpdir)
|
||||
if root is not None:
|
||||
self.assertTrue(os.path.isdir(root))
|
||||
|
||||
def test_nix_binary_candidates_builds_expected_paths(self) -> None:
|
||||
home = "/home/testuser"
|
||||
names = ["pkgmgr", "", None, "other"] # type: ignore[list-item]
|
||||
candidates = _nix_binary_candidates(home, names) # type: ignore[arg-type]
|
||||
|
||||
self.assertIn(
|
||||
os.path.join(home, ".nix-profile", "bin", "pkgmgr"),
|
||||
candidates,
|
||||
)
|
||||
self.assertIn(
|
||||
os.path.join(home, ".nix-profile", "bin", "other"),
|
||||
candidates,
|
||||
)
|
||||
self.assertEqual(len(candidates), 2)
|
||||
|
||||
@patch("pkgmgr.core.command.resolve._is_executable", return_value=True)
|
||||
@patch("pkgmgr.core.command.resolve.shutil.which")
|
||||
def test_path_binary_candidates_uses_which_and_executable(
|
||||
self,
|
||||
mock_which,
|
||||
_mock_is_executable,
|
||||
) -> None:
|
||||
def which_side_effect(name: str) -> str | None:
|
||||
if name == "pkgmgr":
|
||||
return "/usr/local/bin/pkgmgr"
|
||||
if name == "other":
|
||||
return "/usr/bin/other"
|
||||
return None
|
||||
|
||||
mock_which.side_effect = which_side_effect
|
||||
|
||||
candidates = _path_binary_candidates(["pkgmgr", "other", "missing"])
|
||||
self.assertEqual(
|
||||
candidates,
|
||||
["/usr/local/bin/pkgmgr", "/usr/bin/other"],
|
||||
)
|
||||
|
||||
|
||||
class TestResolveCommandForRepo(unittest.TestCase):
|
||||
def test_explicit_command_in_repo_wins(self) -> None:
|
||||
repo = {"command": "/custom/path/pkgmgr"}
|
||||
cmd = resolve_command_for_repo(
|
||||
repo=repo,
|
||||
repo_identifier="pkgmgr",
|
||||
repo_dir="/tmp/pkgmgr",
|
||||
)
|
||||
self.assertEqual(cmd, "/custom/path/pkgmgr")
|
||||
|
||||
@patch("pkgmgr.core.command.resolve._is_executable", return_value=True)
|
||||
@patch("pkgmgr.core.command.resolve._nix_binary_candidates", return_value=[])
|
||||
@patch("pkgmgr.core.command.resolve.shutil.which")
|
||||
def test_prefers_non_system_path_over_system_binary(
|
||||
self,
|
||||
mock_which,
|
||||
_mock_nix_candidates,
|
||||
_mock_is_executable,
|
||||
) -> None:
|
||||
"""
|
||||
If both a system binary (/usr/bin) and a non-system binary (/opt/bin)
|
||||
exist in PATH, the non-system binary must be preferred.
|
||||
"""
|
||||
|
||||
def which_side_effect(name: str) -> str | None:
|
||||
if name == "pkgmgr":
|
||||
return "/usr/bin/pkgmgr" # system binary
|
||||
if name == "alias":
|
||||
return "/opt/bin/pkgmgr" # non-system binary
|
||||
return None
|
||||
|
||||
mock_which.side_effect = which_side_effect
|
||||
|
||||
repo = {
|
||||
"alias": "alias",
|
||||
"repository": "pkgmgr",
|
||||
}
|
||||
cmd = resolve_command_for_repo(
|
||||
repo=repo,
|
||||
repo_identifier="pkgmgr",
|
||||
repo_dir="/tmp/pkgmgr",
|
||||
)
|
||||
self.assertEqual(cmd, "/opt/bin/pkgmgr")
|
||||
|
||||
@patch("pkgmgr.core.command.resolve._is_executable", return_value=True)
|
||||
@patch("pkgmgr.core.command.resolve._nix_binary_candidates")
|
||||
@patch("pkgmgr.core.command.resolve.shutil.which")
|
||||
def test_nix_binary_used_when_no_non_system_bin(
|
||||
self,
|
||||
mock_which,
|
||||
mock_nix_candidates,
|
||||
_mock_is_executable,
|
||||
) -> None:
|
||||
"""
|
||||
When only a system binary exists in PATH but a Nix profile binary is
|
||||
available, the Nix binary should be preferred.
|
||||
"""
|
||||
|
||||
def which_side_effect(name: str) -> str | None:
|
||||
if name == "pkgmgr":
|
||||
return "/usr/bin/pkgmgr"
|
||||
return None
|
||||
|
||||
mock_which.side_effect = which_side_effect
|
||||
mock_nix_candidates.return_value = ["/home/test/.nix-profile/bin/pkgmgr"]
|
||||
|
||||
repo = {"repository": "pkgmgr"}
|
||||
cmd = resolve_command_for_repo(
|
||||
repo=repo,
|
||||
repo_identifier="pkgmgr",
|
||||
repo_dir="/tmp/pkgmgr",
|
||||
)
|
||||
self.assertEqual(cmd, "/home/test/.nix-profile/bin/pkgmgr")
|
||||
|
||||
def test_main_sh_fallback_when_no_binaries(self) -> None:
|
||||
"""
|
||||
If no CLI is found via PATH or Nix, resolve_command_for_repo()
|
||||
should fall back to an executable main.sh in the repo root.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir, patch(
|
||||
"pkgmgr.core.command.resolve.shutil.which", return_value=None
|
||||
), patch(
|
||||
"pkgmgr.core.command.resolve._nix_binary_candidates", return_value=[]
|
||||
), patch(
|
||||
"pkgmgr.core.command.resolve._is_executable"
|
||||
) as mock_is_executable:
|
||||
main_sh = os.path.join(tmpdir, "main.sh")
|
||||
with open(main_sh, "w", encoding="utf-8") as f:
|
||||
f.write("#!/bin/sh\nexit 0\n")
|
||||
os.chmod(main_sh, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
|
||||
|
||||
def is_exec_side_effect(path: str) -> bool:
|
||||
return path == main_sh
|
||||
|
||||
mock_is_executable.side_effect = is_exec_side_effect
|
||||
|
||||
repo = {}
|
||||
cmd = resolve_command_for_repo(
|
||||
repo=repo,
|
||||
repo_identifier="pkgmgr",
|
||||
repo_dir=tmpdir,
|
||||
)
|
||||
self.assertEqual(cmd, main_sh)
|
||||
|
||||
def test_python_package_without_entry_point_returns_none(self) -> None:
|
||||
"""
|
||||
If the repository looks like a Python package (src/package/__init__.py)
|
||||
but there is no CLI entry point or main.sh/main.py, the result
|
||||
should be None.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir, patch(
|
||||
"pkgmgr.core.command.resolve.shutil.which", return_value=None
|
||||
), patch(
|
||||
"pkgmgr.core.command.resolve._nix_binary_candidates", return_value=[]
|
||||
), patch(
|
||||
"pkgmgr.core.command.resolve._is_executable", return_value=False
|
||||
):
|
||||
src_dir = os.path.join(tmpdir, "src", "mypkg")
|
||||
os.makedirs(src_dir, exist_ok=True)
|
||||
init_path = os.path.join(src_dir, "__init__.py")
|
||||
with open(init_path, "w", encoding="utf-8") as f:
|
||||
f.write("# package marker\n")
|
||||
|
||||
repo = {}
|
||||
cmd = resolve_command_for_repo(
|
||||
repo=repo,
|
||||
repo_identifier="mypkg",
|
||||
repo_dir=tmpdir,
|
||||
)
|
||||
self.assertIsNone(cmd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
29
tests/unit/pkgmgr/core/repository/test_ignored.py
Normal file
29
tests/unit/pkgmgr/core/repository/test_ignored.py
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import unittest
|
||||
|
||||
from pkgmgr.core.repository.ignored import filter_ignored
|
||||
|
||||
|
||||
class TestFilterIgnored(unittest.TestCase):
|
||||
def test_filter_ignored_removes_repos_with_ignore_true(self) -> None:
|
||||
repos = [
|
||||
{"provider": "github.com", "account": "user", "repository": "a", "ignore": True},
|
||||
{"provider": "github.com", "account": "user", "repository": "b", "ignore": False},
|
||||
{"provider": "github.com", "account": "user", "repository": "c"},
|
||||
]
|
||||
|
||||
result = filter_ignored(repos)
|
||||
|
||||
identifiers = {(r["provider"], r["account"], r["repository"]) for r in result}
|
||||
self.assertNotIn(("github.com", "user", "a"), identifiers)
|
||||
self.assertIn(("github.com", "user", "b"), identifiers)
|
||||
self.assertIn(("github.com", "user", "c"), identifiers)
|
||||
|
||||
def test_filter_ignored_empty_list_returns_empty_list(self) -> None:
|
||||
self.assertEqual(filter_ignored([]), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
180
tests/unit/pkgmgr/core/repository/test_selected.py
Normal file
180
tests/unit/pkgmgr/core/repository/test_selected.py
Normal file
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.core.repository.selected import get_selected_repos
|
||||
|
||||
|
||||
def _repo(
|
||||
provider: str,
|
||||
account: str,
|
||||
repository: str,
|
||||
ignore: bool | None = None,
|
||||
directory: str | None = None,
|
||||
):
|
||||
repo = {
|
||||
"provider": provider,
|
||||
"account": account,
|
||||
"repository": repository,
|
||||
}
|
||||
if ignore is not None:
|
||||
repo["ignore"] = ignore
|
||||
if directory is not None:
|
||||
repo["directory"] = directory
|
||||
return repo
|
||||
|
||||
|
||||
class TestGetSelectedRepos(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.repo_ignored = _repo(
|
||||
"github.com",
|
||||
"user",
|
||||
"ignored-repo",
|
||||
ignore=True,
|
||||
directory="/repos/github.com/user/ignored-repo",
|
||||
)
|
||||
self.repo_visible = _repo(
|
||||
"github.com",
|
||||
"user",
|
||||
"visible-repo",
|
||||
ignore=False,
|
||||
directory="/repos/github.com/user/visible-repo",
|
||||
)
|
||||
self.all_repos = [self.repo_ignored, self.repo_visible]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1) Explizite Identifier – ignorierte Repos dürfen ausgewählt werden
|
||||
# ------------------------------------------------------------------
|
||||
def test_identifiers_bypass_ignore_filter(self) -> None:
|
||||
args = SimpleNamespace(
|
||||
identifiers=["ignored-repo"], # matches by repository name
|
||||
all=False,
|
||||
category=[],
|
||||
string="",
|
||||
tag=[],
|
||||
include_ignored=False, # should be ignored for explicit identifiers
|
||||
)
|
||||
|
||||
selected = get_selected_repos(args, self.all_repos)
|
||||
|
||||
self.assertEqual(len(selected), 1)
|
||||
self.assertIs(selected[0], self.repo_ignored)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2) Filter-only Modus – ignorierte Repos werden rausgefiltert
|
||||
# ------------------------------------------------------------------
|
||||
def test_filter_mode_excludes_ignored_by_default(self) -> None:
|
||||
# string-Filter, der beide Repos matchen würde
|
||||
args = SimpleNamespace(
|
||||
identifiers=[],
|
||||
all=False,
|
||||
category=[],
|
||||
string="repo", # substring in beiden Namen
|
||||
tag=[],
|
||||
include_ignored=False,
|
||||
)
|
||||
|
||||
selected = get_selected_repos(args, self.all_repos)
|
||||
|
||||
self.assertEqual(len(selected), 1)
|
||||
self.assertIs(selected[0], self.repo_visible)
|
||||
|
||||
def test_filter_mode_can_include_ignored_when_flag_set(self) -> None:
|
||||
args = SimpleNamespace(
|
||||
identifiers=[],
|
||||
all=False,
|
||||
category=[],
|
||||
string="repo",
|
||||
tag=[],
|
||||
include_ignored=True,
|
||||
)
|
||||
|
||||
selected = get_selected_repos(args, self.all_repos)
|
||||
|
||||
# Beide Repos sollten erscheinen, weil include_ignored=True
|
||||
self.assertEqual({r["repository"] for r in selected}, {"ignored-repo", "visible-repo"})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3) --all Modus – ignorierte Repos werden per Default entfernt
|
||||
# ------------------------------------------------------------------
|
||||
def test_all_mode_excludes_ignored_by_default(self) -> None:
|
||||
args = SimpleNamespace(
|
||||
identifiers=[],
|
||||
all=True,
|
||||
category=[],
|
||||
string="",
|
||||
tag=[],
|
||||
include_ignored=False,
|
||||
)
|
||||
|
||||
selected = get_selected_repos(args, self.all_repos)
|
||||
|
||||
self.assertEqual(len(selected), 1)
|
||||
self.assertIs(selected[0], self.repo_visible)
|
||||
|
||||
def test_all_mode_can_include_ignored_when_flag_set(self) -> None:
|
||||
args = SimpleNamespace(
|
||||
identifiers=[],
|
||||
all=True,
|
||||
category=[],
|
||||
string="",
|
||||
tag=[],
|
||||
include_ignored=True,
|
||||
)
|
||||
|
||||
selected = get_selected_repos(args, self.all_repos)
|
||||
|
||||
self.assertEqual(len(selected), 2)
|
||||
self.assertCountEqual(
|
||||
[r["repository"] for r in selected],
|
||||
["ignored-repo", "visible-repo"],
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4) CWD-Modus – Repo anhand des aktuellen Verzeichnisses auswählen
|
||||
# ------------------------------------------------------------------
|
||||
def test_cwd_selection_excludes_ignored_by_default(self) -> None:
|
||||
# Wir lassen CWD auf das Verzeichnis des ignorierten Repos zeigen.
|
||||
cwd = os.path.abspath(self.repo_ignored["directory"])
|
||||
|
||||
args = SimpleNamespace(
|
||||
identifiers=[],
|
||||
all=False,
|
||||
category=[],
|
||||
string="",
|
||||
tag=[],
|
||||
include_ignored=False,
|
||||
)
|
||||
|
||||
with patch("os.getcwd", return_value=cwd):
|
||||
selected = get_selected_repos(args, self.all_repos)
|
||||
|
||||
# Da das einzige Repo für dieses Verzeichnis ignoriert ist,
|
||||
# sollte die Auswahl leer sein.
|
||||
self.assertEqual(selected, [])
|
||||
|
||||
def test_cwd_selection_can_include_ignored_when_flag_set(self) -> None:
|
||||
cwd = os.path.abspath(self.repo_ignored["directory"])
|
||||
|
||||
args = SimpleNamespace(
|
||||
identifiers=[],
|
||||
all=False,
|
||||
category=[],
|
||||
string="",
|
||||
tag=[],
|
||||
include_ignored=True,
|
||||
)
|
||||
|
||||
with patch("os.getcwd", return_value=cwd):
|
||||
selected = get_selected_repos(args, self.all_repos)
|
||||
|
||||
self.assertEqual(len(selected), 1)
|
||||
self.assertIs(selected[0], self.repo_ignored)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user