Compare commits

..

4 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
28df54503e Release version 1.9.2
Some checks are pending
Mark stable commit / test-unit (push) Waiting to run
Mark stable commit / test-integration (push) Waiting to run
Mark stable commit / test-env-virtual (push) Waiting to run
Mark stable commit / test-env-nix (push) Waiting to run
Mark stable commit / test-e2e (push) Waiting to run
Mark stable commit / test-virgin-user (push) Waiting to run
Mark stable commit / test-virgin-root (push) Waiting to run
Mark stable commit / lint-shell (push) Waiting to run
Mark stable commit / lint-python (push) Waiting to run
Mark stable commit / mark-stable (push) Blocked by required conditions
2025-12-21 15:30:22 +01:00
Kevin Veen-Birkenbach
aa489811e3 fix(config): package and load default configs correctly
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
- Ship default YAML configs inside the pkgmgr package
- Ensure defaults are loaded when no user config exists
- Keep user configs fully respected and non-overwritten
- Fix config update command to copy packaged defaults reliably

https://chatgpt.com/share/6947e74f-573c-800f-b93d-5ed341fcd1a3
2025-12-21 15:26:01 +01:00
Kevin Veen-Birkenbach
f66af0157b Release version 1.9.1
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
2025-12-21 13:38:58 +01:00
Kevin Veen-Birkenbach
b0b3ccf5aa fix(packaging): stop including legacy pkgmgr.installers package
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
- Restrict setuptools package discovery to src/ (pkgmgr* only)
- Drop config/ as a Python package mapping (keep config as plain data dir)
- Remove config_defaults fallback paths and use config/ exclusively
- Add unit + integration tests for defaults.yaml loading and CLI update copying

https://chatgpt.com/share/6947e74f-573c-800f-b93d-5ed341fcd1a3
2025-12-21 13:25:38 +01:00
13 changed files with 620 additions and 140 deletions

View File

@@ -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***.

View File

@@ -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 = ./.;

View File

@@ -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')

View File

@@ -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***.

View File

@@ -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.

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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",

View 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()

View 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()

View 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()