Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28df54503e | ||
|
|
aa489811e3 | ||
|
|
f66af0157b | ||
|
|
b0b3ccf5aa |
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,3 +1,13 @@
|
||||
## [1.9.2] - 2025-12-21
|
||||
|
||||
* Default configuration files are now packaged and loaded correctly when no user config exists, while fully preserving custom user configurations.
|
||||
|
||||
|
||||
## [1.9.1] - 2025-12-21
|
||||
|
||||
* Fixed installation issues and improved loading of default configuration files.
|
||||
|
||||
|
||||
## [1.9.0] - 2025-12-20
|
||||
|
||||
* * New ***mirror visibility*** command to set remote Git repositories to ***public*** or ***private***.
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
rec {
|
||||
pkgmgr = pyPkgs.buildPythonApplication {
|
||||
pname = "package-manager";
|
||||
version = "1.9.0";
|
||||
version = "1.9.2";
|
||||
|
||||
# Use the git repo as source
|
||||
src = ./.;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
||||
|
||||
pkgname=package-manager
|
||||
pkgver=1.9.0
|
||||
pkgver=1.9.2
|
||||
pkgrel=1
|
||||
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
||||
arch=('any')
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
package-manager (1.9.2-1) unstable; urgency=medium
|
||||
|
||||
* Default configuration files are now packaged and loaded correctly when no user config exists, while fully preserving custom user configurations.
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 21 Dec 2025 15:30:22 +0100
|
||||
|
||||
package-manager (1.9.1-1) unstable; urgency=medium
|
||||
|
||||
* Fixed installation issues and improved loading of default configuration files.
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 21 Dec 2025 13:38:58 +0100
|
||||
|
||||
package-manager (1.9.0-1) unstable; urgency=medium
|
||||
|
||||
* * New ***mirror visibility*** command to set remote Git repositories to ***public*** or ***private***.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: package-manager
|
||||
Version: 1.9.0
|
||||
Version: 1.9.2
|
||||
Release: 1%{?dist}
|
||||
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
||||
|
||||
@@ -74,6 +74,12 @@ echo ">>> package-manager removed. Nix itself was not removed."
|
||||
/usr/lib/package-manager/
|
||||
|
||||
%changelog
|
||||
* Sun Dec 21 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.2-1
|
||||
- Default configuration files are now packaged and loaded correctly when no user config exists, while fully preserving custom user configurations.
|
||||
|
||||
* Sun Dec 21 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.1-1
|
||||
- Fixed installation issues and improved loading of default configuration files.
|
||||
|
||||
* Sat Dec 20 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 1.9.0-1
|
||||
- * New ***mirror visibility*** command to set remote Git repositories to ***public*** or ***private***.
|
||||
* New ***--public*** flag for ***mirror provision*** to create repositories and immediately make them public.
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "kpmx"
|
||||
version = "1.9.0"
|
||||
version = "1.9.2"
|
||||
description = "Kevin's package-manager tool (pkgmgr)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
@@ -43,11 +43,12 @@ pkgmgr = "pkgmgr.cli:main"
|
||||
# -----------------------------
|
||||
# Source layout: all packages live under "src/"
|
||||
[tool.setuptools]
|
||||
package-dir = { "" = "src", "config" = "config" }
|
||||
package-dir = { "" = "src" }
|
||||
include-package-data = true
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src", "."]
|
||||
include = ["pkgmgr*", "config*"]
|
||||
where = ["src"]
|
||||
include = ["pkgmgr*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"config" = ["defaults.yaml"]
|
||||
"pkgmgr.config" = ["*.yml", "*.yaml"]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# src/pkgmgr/cli/commands/config.py
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
@@ -38,29 +39,18 @@ def _load_user_config(user_config_path: str) -> Dict[str, Any]:
|
||||
|
||||
def _find_defaults_source_dir() -> Optional[str]:
|
||||
"""
|
||||
Find the directory inside the installed pkgmgr package OR the
|
||||
project root that contains default config files.
|
||||
Find the directory inside the installed pkgmgr package that contains
|
||||
the default config files.
|
||||
|
||||
Preferred locations (in dieser Reihenfolge):
|
||||
- <pkg_root>/config_defaults
|
||||
Preferred location:
|
||||
- <pkg_root>/config
|
||||
- <project_root>/config_defaults
|
||||
- <project_root>/config
|
||||
"""
|
||||
import pkgmgr # local import to avoid circular deps
|
||||
|
||||
pkg_root = Path(pkgmgr.__file__).resolve().parent
|
||||
project_root = pkg_root.parent
|
||||
|
||||
candidates = [
|
||||
pkg_root / "config_defaults",
|
||||
pkg_root / "config",
|
||||
project_root / "config_defaults",
|
||||
project_root / "config",
|
||||
]
|
||||
for cand in candidates:
|
||||
if cand.is_dir():
|
||||
return str(cand)
|
||||
cand = pkg_root / "config"
|
||||
if cand.is_dir():
|
||||
return str(cand)
|
||||
return None
|
||||
|
||||
|
||||
@@ -73,7 +63,7 @@ def _update_default_configs(user_config_path: str) -> None:
|
||||
source_dir = _find_defaults_source_dir()
|
||||
if not source_dir:
|
||||
print(
|
||||
"[WARN] No config_defaults or config directory found in "
|
||||
"[WARN] No config directory found in "
|
||||
"pkgmgr installation. Nothing to update."
|
||||
)
|
||||
return
|
||||
@@ -88,7 +78,6 @@ def _update_default_configs(user_config_path: str) -> None:
|
||||
if not (lower.endswith(".yml") or lower.endswith(".yaml")):
|
||||
continue
|
||||
if name == "config.yaml":
|
||||
# Never overwrite the user config template / live config
|
||||
continue
|
||||
|
||||
src = os.path.join(source_dir, name)
|
||||
@@ -102,48 +91,28 @@ def handle_config(args, ctx: CLIContext) -> None:
|
||||
"""
|
||||
Handle 'pkgmgr config' subcommands.
|
||||
"""
|
||||
|
||||
user_config_path = ctx.user_config_path
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# config show
|
||||
# ------------------------------------------------------------
|
||||
if args.subcommand == "show":
|
||||
if args.all or (not args.identifiers):
|
||||
# Full merged config view
|
||||
show_config([], user_config_path, full_config=True)
|
||||
else:
|
||||
# Show only matching entries from user config
|
||||
user_config = _load_user_config(user_config_path)
|
||||
selected = resolve_repos(
|
||||
args.identifiers,
|
||||
user_config.get("repositories", []),
|
||||
args.identifiers, user_config.get("repositories", [])
|
||||
)
|
||||
if selected:
|
||||
show_config(
|
||||
selected,
|
||||
user_config_path,
|
||||
full_config=False,
|
||||
)
|
||||
show_config(selected, user_config_path, full_config=False)
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# config add
|
||||
# ------------------------------------------------------------
|
||||
if args.subcommand == "add":
|
||||
interactive_add(ctx.config_merged, user_config_path)
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# config edit
|
||||
# ------------------------------------------------------------
|
||||
if args.subcommand == "edit":
|
||||
run_command(f"nano {user_config_path}")
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# config init
|
||||
# ------------------------------------------------------------
|
||||
if args.subcommand == "init":
|
||||
user_config = _load_user_config(user_config_path)
|
||||
config_init(
|
||||
@@ -154,9 +123,6 @@ def handle_config(args, ctx: CLIContext) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# config delete
|
||||
# ------------------------------------------------------------
|
||||
if args.subcommand == "delete":
|
||||
user_config = _load_user_config(user_config_path)
|
||||
|
||||
@@ -167,10 +133,7 @@ def handle_config(args, ctx: CLIContext) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
to_delete = resolve_repos(
|
||||
args.identifiers,
|
||||
user_config.get("repositories", []),
|
||||
)
|
||||
to_delete = resolve_repos(args.identifiers, user_config.get("repositories", []))
|
||||
new_repos = [
|
||||
entry
|
||||
for entry in user_config.get("repositories", [])
|
||||
@@ -181,9 +144,6 @@ def handle_config(args, ctx: CLIContext) -> None:
|
||||
print(f"Deleted {len(to_delete)} entries from user config.")
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# config ignore
|
||||
# ------------------------------------------------------------
|
||||
if args.subcommand == "ignore":
|
||||
user_config = _load_user_config(user_config_path)
|
||||
|
||||
@@ -194,17 +154,10 @@ def handle_config(args, ctx: CLIContext) -> None:
|
||||
)
|
||||
return
|
||||
|
||||
to_modify = resolve_repos(
|
||||
args.identifiers,
|
||||
user_config.get("repositories", []),
|
||||
)
|
||||
to_modify = resolve_repos(args.identifiers, user_config.get("repositories", []))
|
||||
|
||||
for entry in user_config["repositories"]:
|
||||
key = (
|
||||
entry.get("provider"),
|
||||
entry.get("account"),
|
||||
entry.get("repository"),
|
||||
)
|
||||
key = (entry.get("provider"), entry.get("account"), entry.get("repository"))
|
||||
for mod in to_modify:
|
||||
mod_key = (
|
||||
mod.get("provider"),
|
||||
@@ -218,21 +171,9 @@ def handle_config(args, ctx: CLIContext) -> None:
|
||||
save_user_config(user_config, user_config_path)
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# config update
|
||||
# ------------------------------------------------------------
|
||||
if args.subcommand == "update":
|
||||
"""
|
||||
Copy default YAML configs from the installed package into the
|
||||
user's ~/.config/pkgmgr directory.
|
||||
|
||||
This will overwrite files with the same name (except config.yaml).
|
||||
"""
|
||||
_update_default_configs(user_config_path)
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Unknown subcommand
|
||||
# ------------------------------------------------------------
|
||||
print(f"Unknown config subcommand: {args.subcommand}")
|
||||
sys.exit(2)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# src/pkgmgr/core/config/load.py
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
@@ -7,31 +8,28 @@ Load and merge pkgmgr configuration.
|
||||
Layering rules:
|
||||
|
||||
1. Defaults / category files:
|
||||
- Zuerst werden alle *.yml/*.yaml (außer config.yaml) im
|
||||
Benutzerverzeichnis geladen:
|
||||
- First load all *.yml/*.yaml (except config.yaml) from the user directory:
|
||||
~/.config/pkgmgr/
|
||||
|
||||
- Falls dort keine passenden Dateien existieren, wird auf die im
|
||||
Paket / Projekt mitgelieferten Config-Verzeichnisse zurückgegriffen:
|
||||
- If no matching files exist there, fall back to defaults shipped with pkgmgr:
|
||||
|
||||
<pkg_root>/config_defaults
|
||||
<pkg_root>/config
|
||||
<project_root>/config_defaults
|
||||
<project_root>/config
|
||||
|
||||
Dabei werden ebenfalls alle *.yml/*.yaml als Layer geladen.
|
||||
During development (src-layout), we optionally also check:
|
||||
<repo_root>/config
|
||||
|
||||
- Der Dateiname ohne Endung (stem) wird als Kategorie-Name
|
||||
verwendet und in repo["category_files"] eingetragen.
|
||||
All *.yml/*.yaml files are loaded as layers.
|
||||
|
||||
- The filename stem is used as category name and stored in repo["category_files"].
|
||||
|
||||
2. User config:
|
||||
- ~/.config/pkgmgr/config.yaml (oder der übergebene Pfad)
|
||||
wird geladen und PER LISTEN-MERGE über die Defaults gelegt:
|
||||
- ~/.config/pkgmgr/config.yaml (or the provided path)
|
||||
is loaded and merged over defaults:
|
||||
- directories: dict deep-merge
|
||||
- repositories: per _merge_repo_lists (kein Löschen!)
|
||||
- repositories: per _merge_repo_lists (no deletions!)
|
||||
|
||||
3. Ergebnis:
|
||||
- Ein dict mit mindestens:
|
||||
3. Result:
|
||||
- A dict with at least:
|
||||
config["directories"] (dict)
|
||||
config["repositories"] (list[dict])
|
||||
"""
|
||||
@@ -40,7 +38,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -48,7 +46,7 @@ Repo = Dict[str, Any]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hilfsfunktionen
|
||||
# Helper functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -85,17 +83,16 @@ def _merge_repo_lists(
|
||||
"""
|
||||
Merge two repository lists, matching by (provider, account, repository).
|
||||
|
||||
- Wenn ein Repo aus new_list noch nicht existiert, wird es hinzugefügt.
|
||||
- Wenn es existiert, werden seine Felder per Deep-Merge überschrieben.
|
||||
- Wenn category_name gesetzt ist, wird dieser in
|
||||
repo["category_files"] eingetragen.
|
||||
- If a repo from new_list does not exist, it is added.
|
||||
- If it exists, its fields are deep-merged (override wins).
|
||||
- If category_name is set, it is appended to repo["category_files"].
|
||||
"""
|
||||
index: Dict[Tuple[str, str, str], Repo] = {_repo_key(r): r for r in base_list}
|
||||
|
||||
for src in new_list:
|
||||
key = _repo_key(src)
|
||||
if key == ("", "", ""):
|
||||
# Unvollständiger Schlüssel -> einfach anhängen
|
||||
# Incomplete key -> append as-is
|
||||
dst = dict(src)
|
||||
if category_name:
|
||||
dst.setdefault("category_files", [])
|
||||
@@ -143,10 +140,9 @@ def _load_layer_dir(
|
||||
"""
|
||||
Load all *.yml/*.yaml from a directory as layered defaults.
|
||||
|
||||
- skip_filename: Dateiname (z.B. "config.yaml"), der ignoriert
|
||||
werden soll (z.B. User-Config).
|
||||
- skip_filename: filename (e.g. "config.yaml") to ignore.
|
||||
|
||||
Rückgabe:
|
||||
Returns:
|
||||
{
|
||||
"directories": {...},
|
||||
"repositories": [...],
|
||||
@@ -171,7 +167,7 @@ def _load_layer_dir(
|
||||
|
||||
for path in yaml_files:
|
||||
data = _load_yaml_file(path)
|
||||
category_name = path.stem # Dateiname ohne .yml/.yaml
|
||||
category_name = path.stem
|
||||
|
||||
dirs = data.get("directories")
|
||||
if isinstance(dirs, dict):
|
||||
@@ -192,8 +188,11 @@ def _load_layer_dir(
|
||||
|
||||
def _load_defaults_from_package_or_project() -> Dict[str, Any]:
|
||||
"""
|
||||
Fallback: load default configs from various possible install or development
|
||||
layouts (pip-installed, editable install, source repo with src/ layout).
|
||||
Fallback: load default configs from possible install or dev layouts.
|
||||
|
||||
Supported locations:
|
||||
- <pkg_root>/config (installed wheel / editable)
|
||||
- <repo_root>/config (optional dev fallback when pkg_root is src/pkgmgr)
|
||||
"""
|
||||
try:
|
||||
import pkgmgr # type: ignore
|
||||
@@ -201,25 +200,16 @@ def _load_defaults_from_package_or_project() -> Dict[str, Any]:
|
||||
return {"directories": {}, "repositories": []}
|
||||
|
||||
pkg_root = Path(pkgmgr.__file__).resolve().parent
|
||||
roots = set()
|
||||
candidates: List[Path] = []
|
||||
|
||||
# Case 1: installed package (site-packages/pkgmgr)
|
||||
roots.add(pkg_root)
|
||||
# Always prefer package-internal config dir
|
||||
candidates.append(pkg_root / "config")
|
||||
|
||||
# Case 2: parent directory (site-packages/, src/)
|
||||
roots.add(pkg_root.parent)
|
||||
|
||||
# Case 3: src-layout during development:
|
||||
# repo_root/src/pkgmgr -> repo_root
|
||||
# Dev fallback: repo_root/src/pkgmgr -> repo_root/config
|
||||
parent = pkg_root.parent
|
||||
if parent.name == "src":
|
||||
roots.add(parent.parent)
|
||||
|
||||
# Candidate config dirs
|
||||
candidates = []
|
||||
for root in roots:
|
||||
candidates.append(root / "config_defaults")
|
||||
candidates.append(root / "config")
|
||||
repo_root = parent.parent
|
||||
candidates.append(repo_root / "config")
|
||||
|
||||
for cand in candidates:
|
||||
defaults = _load_layer_dir(cand, skip_filename=None)
|
||||
@@ -230,7 +220,7 @@ def _load_defaults_from_package_or_project() -> Dict[str, Any]:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hauptfunktion
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -238,53 +228,49 @@ def load_config(user_config_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Load and merge configuration for pkgmgr.
|
||||
|
||||
Schritte:
|
||||
1. Ermittle ~/.config/pkgmgr/ (oder das Verzeichnis von user_config_path).
|
||||
2. Lade alle *.yml/*.yaml dort (außer der User-Config selbst) als
|
||||
Defaults / Kategorie-Layer.
|
||||
3. Wenn dort nichts gefunden wurde, Fallback auf Paket/Projekt.
|
||||
4. Lade die User-Config-Datei selbst (falls vorhanden).
|
||||
Steps:
|
||||
1. Determine ~/.config/pkgmgr/ (or dir of user_config_path).
|
||||
2. Load all *.yml/*.yaml in that dir (except the user config file) as defaults.
|
||||
3. If nothing found, fall back to package defaults.
|
||||
4. Load the user config file (if present).
|
||||
5. Merge:
|
||||
- directories: deep-merge (Defaults <- User)
|
||||
- repositories: _merge_repo_lists (Defaults <- User)
|
||||
- directories: deep-merge (defaults <- user)
|
||||
- repositories: _merge_repo_lists (defaults <- user)
|
||||
"""
|
||||
user_config_path_expanded = os.path.expanduser(user_config_path)
|
||||
user_cfg_path = Path(user_config_path_expanded)
|
||||
|
||||
config_dir = user_cfg_path.parent
|
||||
if not str(config_dir):
|
||||
# Fallback, falls jemand nur "config.yaml" übergibt
|
||||
config_dir = Path(os.path.expanduser("~/.config/pkgmgr"))
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
user_cfg_name = user_cfg_path.name
|
||||
|
||||
# 1+2) Defaults / Kategorie-Layer aus dem User-Verzeichnis
|
||||
# 1+2) Defaults from user directory
|
||||
defaults = _load_layer_dir(config_dir, skip_filename=user_cfg_name)
|
||||
|
||||
# 3) Falls dort nichts gefunden wurde, Fallback auf Paket/Projekt
|
||||
# 3) Fallback to package defaults
|
||||
if not defaults["directories"] and not defaults["repositories"]:
|
||||
defaults = _load_defaults_from_package_or_project()
|
||||
|
||||
defaults.setdefault("directories", {})
|
||||
defaults.setdefault("repositories", [])
|
||||
|
||||
# 4) User-Config
|
||||
# 4) User config
|
||||
user_cfg: Dict[str, Any] = {}
|
||||
if user_cfg_path.is_file():
|
||||
user_cfg = _load_yaml_file(user_cfg_path)
|
||||
user_cfg.setdefault("directories", {})
|
||||
user_cfg.setdefault("repositories", [])
|
||||
|
||||
# 5) Merge: directories deep-merge, repositories listen-merge
|
||||
# 5) Merge
|
||||
merged: Dict[str, Any] = {}
|
||||
|
||||
# directories
|
||||
merged["directories"] = {}
|
||||
_deep_merge(merged["directories"], defaults["directories"])
|
||||
_deep_merge(merged["directories"], user_cfg["directories"])
|
||||
|
||||
# repositories
|
||||
merged["repositories"] = []
|
||||
_merge_repo_lists(
|
||||
merged["repositories"], defaults["repositories"], category_name=None
|
||||
@@ -293,7 +279,7 @@ def load_config(user_config_path: str) -> Dict[str, Any]:
|
||||
merged["repositories"], user_cfg["repositories"], category_name=None
|
||||
)
|
||||
|
||||
# andere Top-Level-Keys (falls vorhanden)
|
||||
# Merge other top-level keys
|
||||
other_keys = (set(defaults.keys()) | set(user_cfg.keys())) - {
|
||||
"directories",
|
||||
"repositories",
|
||||
|
||||
118
tests/integration/test_config_defaults_integration.py
Normal file
118
tests/integration/test_config_defaults_integration.py
Normal file
@@ -0,0 +1,118 @@
|
||||
# tests/integration/test_config_defaults_integration.py
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import yaml
|
||||
|
||||
from pkgmgr.core.config.load import load_config
|
||||
from pkgmgr.cli.commands import config as config_cmd
|
||||
|
||||
|
||||
class ConfigDefaultsIntegrationTest(unittest.TestCase):
|
||||
def test_defaults_yaml_is_loaded_and_can_be_copied_to_user_config_dir(self):
|
||||
"""
|
||||
Integration test:
|
||||
- Create a temp "site-packages/pkgmgr" fake install root
|
||||
- Put defaults under "<pkg_root>/config/defaults.yaml"
|
||||
- Verify:
|
||||
A) load_config() picks up defaults from that config folder when user dir has no defaults
|
||||
B) _update_default_configs() copies defaults.yaml into ~/.config/pkgmgr/
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
|
||||
# Fake HOME for user config
|
||||
home = root / "home"
|
||||
user_cfg_dir = home / ".config" / "pkgmgr"
|
||||
user_cfg_dir.mkdir(parents=True)
|
||||
user_config_path = str(user_cfg_dir / "config.yaml")
|
||||
|
||||
# Create a user config file that should NOT be overwritten by update
|
||||
(user_cfg_dir / "config.yaml").write_text(
|
||||
yaml.safe_dump({"directories": {"user_only": "/home/user"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Fake pkg install layout:
|
||||
# pkg_root = <root>/site-packages/pkgmgr
|
||||
site_packages = root / "site-packages"
|
||||
pkg_root = site_packages / "pkgmgr"
|
||||
pkg_root.mkdir(parents=True)
|
||||
|
||||
# defaults live inside the package now: <pkg_root>/config/defaults.yaml
|
||||
config_dir = pkg_root / "config"
|
||||
config_dir.mkdir(parents=True)
|
||||
|
||||
defaults_payload = {
|
||||
"directories": {
|
||||
"repositories": "/opt/Repositories",
|
||||
"binaries": "/usr/local/bin",
|
||||
},
|
||||
"repositories": [
|
||||
{"provider": "github", "account": "acme", "repository": "demo"}
|
||||
],
|
||||
}
|
||||
(config_dir / "defaults.yaml").write_text(
|
||||
yaml.safe_dump(defaults_payload),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Provide fake pkgmgr module so your functions resolve pkg_root correctly
|
||||
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||
|
||||
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||
with patch.dict(os.environ, {"HOME": str(home)}):
|
||||
# A) load_config should fall back to <pkg_root>/config/defaults.yaml
|
||||
merged = load_config(user_config_path)
|
||||
|
||||
self.assertEqual(
|
||||
merged["directories"]["repositories"], "/opt/Repositories"
|
||||
)
|
||||
self.assertEqual(
|
||||
merged["directories"]["binaries"], "/usr/local/bin"
|
||||
)
|
||||
|
||||
# user-only key must still exist (user config merges over defaults)
|
||||
self.assertEqual(merged["directories"]["user_only"], "/home/user")
|
||||
|
||||
self.assertIn("repositories", merged)
|
||||
self.assertTrue(
|
||||
any(
|
||||
r.get("provider") == "github"
|
||||
and r.get("account") == "acme"
|
||||
and r.get("repository") == "demo"
|
||||
for r in merged["repositories"]
|
||||
)
|
||||
)
|
||||
|
||||
# B) update_default_configs should copy defaults.yaml to ~/.config/pkgmgr/
|
||||
before_config_yaml = (user_cfg_dir / "config.yaml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
config_cmd._update_default_configs(user_config_path)
|
||||
|
||||
self.assertTrue((user_cfg_dir / "defaults.yaml").is_file())
|
||||
copied_defaults = yaml.safe_load(
|
||||
(user_cfg_dir / "defaults.yaml").read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual(
|
||||
copied_defaults["directories"]["repositories"],
|
||||
"/opt/Repositories",
|
||||
)
|
||||
|
||||
after_config_yaml = (user_cfg_dir / "config.yaml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertEqual(after_config_yaml, before_config_yaml)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
135
tests/unit/pkgmgr/core/config/test_cli_update.py
Normal file
135
tests/unit/pkgmgr/core/config/test_cli_update.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.cli.commands import config as config_cmd
|
||||
|
||||
|
||||
class FindDefaultsSourceDirTests(unittest.TestCase):
|
||||
def test_prefers_pkg_root_config_over_project_root_config(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
pkg_root = root / "site-packages" / "pkgmgr"
|
||||
pkg_root.mkdir(parents=True)
|
||||
|
||||
# both exist
|
||||
(pkg_root / "config").mkdir(parents=True)
|
||||
(pkg_root.parent / "config").mkdir(parents=True)
|
||||
|
||||
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||
found = config_cmd._find_defaults_source_dir()
|
||||
|
||||
self.assertEqual(Path(found).resolve(), (pkg_root / "config").resolve())
|
||||
|
||||
def test_falls_back_to_project_root_config(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
pkg_root = root / "site-packages" / "pkgmgr"
|
||||
pkg_root.mkdir(parents=True)
|
||||
|
||||
# only project_root config exists
|
||||
(pkg_root.parent / "config").mkdir(parents=True)
|
||||
|
||||
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||
found = config_cmd._find_defaults_source_dir()
|
||||
|
||||
self.assertEqual(
|
||||
Path(found).resolve(), (pkg_root.parent / "config").resolve()
|
||||
)
|
||||
|
||||
def test_returns_none_when_no_config_dirs_exist(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
pkg_root = root / "site-packages" / "pkgmgr"
|
||||
pkg_root.mkdir(parents=True)
|
||||
|
||||
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||
found = config_cmd._find_defaults_source_dir()
|
||||
|
||||
self.assertIsNone(found)
|
||||
|
||||
|
||||
class UpdateDefaultConfigsTests(unittest.TestCase):
|
||||
def test_copies_yaml_files_skips_config_yaml(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
source_dir = root / "src"
|
||||
source_dir.mkdir()
|
||||
|
||||
# Create files
|
||||
(source_dir / "a.yaml").write_text("x: 1\n", encoding="utf-8")
|
||||
(source_dir / "b.yml").write_text("y: 2\n", encoding="utf-8")
|
||||
(source_dir / "config.yaml").write_text(
|
||||
"should_not_copy: true\n", encoding="utf-8"
|
||||
)
|
||||
(source_dir / "notes.txt").write_text("nope\n", encoding="utf-8")
|
||||
|
||||
home = root / "home"
|
||||
dest_cfg_dir = home / ".config" / "pkgmgr"
|
||||
dest_cfg_dir.mkdir(parents=True)
|
||||
user_config_path = str(dest_cfg_dir / "config.yaml")
|
||||
|
||||
# Patch the source dir finder to our temp source_dir
|
||||
with patch.object(
|
||||
config_cmd, "_find_defaults_source_dir", return_value=str(source_dir)
|
||||
):
|
||||
with patch.dict(os.environ, {"HOME": str(home)}):
|
||||
config_cmd._update_default_configs(user_config_path)
|
||||
|
||||
self.assertTrue((dest_cfg_dir / "a.yaml").is_file())
|
||||
self.assertTrue((dest_cfg_dir / "b.yml").is_file())
|
||||
self.assertFalse(
|
||||
(dest_cfg_dir / "config.yaml")
|
||||
.read_text(encoding="utf-8")
|
||||
.startswith("should_not_copy")
|
||||
)
|
||||
|
||||
# Ensure config.yaml was not overwritten (it may exist, but should remain original if we create it)
|
||||
# We'll strengthen: create an original config.yaml then re-run
|
||||
(dest_cfg_dir / "config.yaml").write_text(
|
||||
"original: true\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
config_cmd, "_find_defaults_source_dir", return_value=str(source_dir)
|
||||
):
|
||||
with patch.dict(os.environ, {"HOME": str(home)}):
|
||||
config_cmd._update_default_configs(user_config_path)
|
||||
|
||||
self.assertEqual(
|
||||
(dest_cfg_dir / "config.yaml").read_text(encoding="utf-8"),
|
||||
"original: true\n",
|
||||
)
|
||||
|
||||
def test_prints_warning_and_returns_when_no_source_dir(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
home = root / "home"
|
||||
dest_cfg_dir = home / ".config" / "pkgmgr"
|
||||
dest_cfg_dir.mkdir(parents=True)
|
||||
user_config_path = str(dest_cfg_dir / "config.yaml")
|
||||
|
||||
buf = io.StringIO()
|
||||
with patch.object(
|
||||
config_cmd, "_find_defaults_source_dir", return_value=None
|
||||
):
|
||||
with patch("sys.stdout", buf):
|
||||
with patch.dict(os.environ, {"HOME": str(home)}):
|
||||
config_cmd._update_default_configs(user_config_path)
|
||||
|
||||
out = buf.getvalue()
|
||||
self.assertIn("[WARN] No config directory found", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
271
tests/unit/pkgmgr/core/config/test_load.py
Normal file
271
tests/unit/pkgmgr/core/config/test_load.py
Normal file
@@ -0,0 +1,271 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import yaml
|
||||
|
||||
from pkgmgr.core.config.load import (
|
||||
_deep_merge,
|
||||
_merge_repo_lists,
|
||||
_load_layer_dir,
|
||||
_load_defaults_from_package_or_project,
|
||||
load_config,
|
||||
)
|
||||
|
||||
|
||||
class DeepMergeTests(unittest.TestCase):
|
||||
def test_deep_merge_overrides_scalars_and_merges_dicts(self):
|
||||
base = {"a": 1, "b": {"x": 1, "y": 2}, "c": {"k": 1}}
|
||||
override = {"a": 2, "b": {"y": 99, "z": 3}, "c": 7}
|
||||
merged = _deep_merge(base, override)
|
||||
|
||||
self.assertEqual(merged["a"], 2)
|
||||
self.assertEqual(merged["b"]["x"], 1)
|
||||
self.assertEqual(merged["b"]["y"], 99)
|
||||
self.assertEqual(merged["b"]["z"], 3)
|
||||
self.assertEqual(merged["c"], 7)
|
||||
|
||||
|
||||
class MergeRepoListsTests(unittest.TestCase):
|
||||
def test_merge_repo_lists_adds_new_repo_and_tracks_category(self):
|
||||
base = []
|
||||
new = [{"provider": "github", "account": "a", "repository": "r", "x": 1}]
|
||||
_merge_repo_lists(base, new, category_name="cat1")
|
||||
|
||||
self.assertEqual(len(base), 1)
|
||||
self.assertEqual(base[0]["provider"], "github")
|
||||
self.assertEqual(base[0]["x"], 1)
|
||||
self.assertIn("category_files", base[0])
|
||||
self.assertIn("cat1", base[0]["category_files"])
|
||||
|
||||
def test_merge_repo_lists_merges_existing_repo_fields(self):
|
||||
base = [
|
||||
{
|
||||
"provider": "github",
|
||||
"account": "a",
|
||||
"repository": "r",
|
||||
"x": 1,
|
||||
"d": {"a": 1},
|
||||
}
|
||||
]
|
||||
new = [
|
||||
{
|
||||
"provider": "github",
|
||||
"account": "a",
|
||||
"repository": "r",
|
||||
"x": 2,
|
||||
"d": {"b": 2},
|
||||
}
|
||||
]
|
||||
_merge_repo_lists(base, new, category_name="cat2")
|
||||
|
||||
self.assertEqual(len(base), 1)
|
||||
self.assertEqual(base[0]["x"], 2)
|
||||
self.assertEqual(base[0]["d"]["a"], 1)
|
||||
self.assertEqual(base[0]["d"]["b"], 2)
|
||||
self.assertIn("cat2", base[0]["category_files"])
|
||||
|
||||
def test_merge_repo_lists_incomplete_key_appends(self):
|
||||
base = []
|
||||
new = [{"foo": "bar"}] # no provider/account/repository
|
||||
_merge_repo_lists(base, new, category_name="cat")
|
||||
|
||||
self.assertEqual(len(base), 1)
|
||||
self.assertEqual(base[0]["foo"], "bar")
|
||||
self.assertIn("cat", base[0].get("category_files", []))
|
||||
|
||||
|
||||
class LoadLayerDirTests(unittest.TestCase):
|
||||
def test_load_layer_dir_merges_directories_and_repos_across_files_sorted(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cfg_dir = Path(td)
|
||||
|
||||
# 10_b.yaml should be applied after 01_a.yaml due to name sorting
|
||||
(cfg_dir / "01_a.yaml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"directories": {"repositories": "/opt/Repos"},
|
||||
"repositories": [
|
||||
{
|
||||
"provider": "github",
|
||||
"account": "a",
|
||||
"repository": "r1",
|
||||
"x": 1,
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(cfg_dir / "10_b.yaml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"directories": {"binaries": "/usr/local/bin"},
|
||||
"repositories": [
|
||||
{
|
||||
"provider": "github",
|
||||
"account": "a",
|
||||
"repository": "r1",
|
||||
"x": 2,
|
||||
},
|
||||
{"provider": "github", "account": "a", "repository": "r2"},
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
defaults = _load_layer_dir(cfg_dir, skip_filename="config.yaml")
|
||||
|
||||
self.assertEqual(defaults["directories"]["repositories"], "/opt/Repos")
|
||||
self.assertEqual(defaults["directories"]["binaries"], "/usr/local/bin")
|
||||
|
||||
# r1 merged: x becomes 2 and has category_files including both stems
|
||||
repos = defaults["repositories"]
|
||||
self.assertEqual(len(repos), 2)
|
||||
r1 = next(r for r in repos if r["repository"] == "r1")
|
||||
self.assertEqual(r1["x"], 2)
|
||||
self.assertIn("01_a", r1.get("category_files", []))
|
||||
self.assertIn("10_b", r1.get("category_files", []))
|
||||
|
||||
def test_load_layer_dir_skips_config_yaml(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cfg_dir = Path(td)
|
||||
(cfg_dir / "config.yaml").write_text(
|
||||
yaml.safe_dump({"directories": {"x": 1}}), encoding="utf-8"
|
||||
)
|
||||
(cfg_dir / "defaults.yaml").write_text(
|
||||
yaml.safe_dump({"directories": {"x": 2}}), encoding="utf-8"
|
||||
)
|
||||
|
||||
defaults = _load_layer_dir(cfg_dir, skip_filename="config.yaml")
|
||||
# only defaults.yaml should apply
|
||||
self.assertEqual(defaults["directories"]["x"], 2)
|
||||
|
||||
|
||||
class DefaultsFromPackageOrProjectTests(unittest.TestCase):
|
||||
def test_defaults_from_pkg_root_config_wins(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
pkg_root = root / "site-packages" / "pkgmgr"
|
||||
cfg_dir = pkg_root / "config"
|
||||
cfg_dir.mkdir(parents=True)
|
||||
|
||||
(cfg_dir / "defaults.yaml").write_text(
|
||||
yaml.safe_dump(
|
||||
{"directories": {"repositories": "/opt/Repos"}, "repositories": []}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||
defaults = _load_defaults_from_package_or_project()
|
||||
|
||||
self.assertEqual(defaults["directories"]["repositories"], "/opt/Repos")
|
||||
|
||||
def test_defaults_from_repo_root_src_layout(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
repo_root = Path(td) / "repo"
|
||||
pkg_root = repo_root / "src" / "pkgmgr"
|
||||
cfg_dir = repo_root / "config"
|
||||
cfg_dir.mkdir(parents=True)
|
||||
pkg_root.mkdir(parents=True)
|
||||
|
||||
(cfg_dir / "defaults.yaml").write_text(
|
||||
yaml.safe_dump(
|
||||
{"directories": {"binaries": "/usr/local/bin"}, "repositories": []}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||
defaults = _load_defaults_from_package_or_project()
|
||||
|
||||
self.assertEqual(defaults["directories"]["binaries"], "/usr/local/bin")
|
||||
|
||||
def test_defaults_returns_empty_when_no_config_found(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
pkg_root = Path(td) / "site-packages" / "pkgmgr"
|
||||
pkg_root.mkdir(parents=True)
|
||||
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||
|
||||
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||
defaults = _load_defaults_from_package_or_project()
|
||||
|
||||
self.assertEqual(defaults, {"directories": {}, "repositories": []})
|
||||
|
||||
|
||||
class LoadConfigIntegrationUnitTests(unittest.TestCase):
|
||||
def test_load_config_prefers_user_dir_defaults_over_package_defaults(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
home = Path(td) / "home"
|
||||
user_cfg_dir = home / ".config" / "pkgmgr"
|
||||
user_cfg_dir.mkdir(parents=True)
|
||||
user_config_path = str(user_cfg_dir / "config.yaml")
|
||||
|
||||
# user dir defaults exist -> should be used, package fallback must not matter
|
||||
(user_cfg_dir / "aa.yaml").write_text(
|
||||
yaml.safe_dump({"directories": {"repositories": "/USER/Repos"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(user_cfg_dir / "config.yaml").write_text(
|
||||
yaml.safe_dump({"directories": {"binaries": "/USER/bin"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, {"HOME": str(home)}):
|
||||
merged = load_config(user_config_path)
|
||||
|
||||
self.assertEqual(merged["directories"]["repositories"], "/USER/Repos")
|
||||
self.assertEqual(merged["directories"]["binaries"], "/USER/bin")
|
||||
|
||||
def test_load_config_falls_back_to_package_when_user_dir_has_no_defaults(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
home = Path(td) / "home"
|
||||
user_cfg_dir = home / ".config" / "pkgmgr"
|
||||
user_cfg_dir.mkdir(parents=True)
|
||||
user_config_path = str(user_cfg_dir / "config.yaml")
|
||||
|
||||
# Only user config exists, no other yaml defaults
|
||||
(user_cfg_dir / "config.yaml").write_text(
|
||||
yaml.safe_dump({"directories": {"x": 1}}), encoding="utf-8"
|
||||
)
|
||||
|
||||
# Provide package defaults via fake pkgmgr + pkg_root/config
|
||||
root = Path(td) / "site-packages"
|
||||
pkg_root = root / "pkgmgr"
|
||||
cfg_dir = (
|
||||
root / "config"
|
||||
) # NOTE: load.py checks multiple roots, including pkg_root.parent (=site-packages)
|
||||
pkg_root.mkdir(parents=True)
|
||||
cfg_dir.mkdir(parents=True)
|
||||
|
||||
(cfg_dir / "defaults.yaml").write_text(
|
||||
yaml.safe_dump(
|
||||
{"directories": {"repositories": "/PKG/Repos"}, "repositories": []}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||
with patch.dict(os.environ, {"HOME": str(home)}):
|
||||
merged = load_config(user_config_path)
|
||||
|
||||
# directories are merged: defaults then user
|
||||
self.assertEqual(merged["directories"]["repositories"], "/PKG/Repos")
|
||||
self.assertEqual(merged["directories"]["x"], 1)
|
||||
self.assertIn("repositories", merged)
|
||||
self.assertIsInstance(merged["repositories"], list)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user