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
This commit is contained in:
Kevin Veen-Birkenbach
2025-12-21 15:26:01 +01:00
parent f66af0157b
commit aa489811e3
6 changed files with 64 additions and 135 deletions

View File

@@ -44,10 +44,11 @@ pkgmgr = "pkgmgr.cli:main"
# Source layout: all packages live under "src/"
[tool.setuptools]
package-dir = { "" = "src" }
include-package-data = true
[tool.setuptools.packages.find]
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,23 +39,16 @@ 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):
Preferred location:
- <pkg_root>/config
- <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",
project_root / "config",
]
for cand in candidates:
cand = pkg_root / "config"
if cand.is_dir():
return str(cand)
return None
@@ -84,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)
@@ -98,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(
@@ -150,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)
@@ -163,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", [])
@@ -177,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)
@@ -190,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"),
@@ -214,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,29 +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
<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])
"""
@@ -38,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
@@ -46,7 +46,7 @@ Repo = Dict[str, Any]
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# Helper functions
# ---------------------------------------------------------------------------
@@ -83,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", [])
@@ -141,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": [...],
@@ -169,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):
@@ -190,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
@@ -199,24 +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")
repo_root = parent.parent
candidates.append(repo_root / "config")
for cand in candidates:
defaults = _load_layer_dir(cand, skip_filename=None)
@@ -227,7 +220,7 @@ def _load_defaults_from_package_or_project() -> Dict[str, Any]:
# ---------------------------------------------------------------------------
# Hauptfunktion
# Public API
# ---------------------------------------------------------------------------
@@ -235,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
@@ -290,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

@@ -20,13 +20,11 @@ class ConfigDefaultsIntegrationTest(unittest.TestCase):
"""
Integration test:
- Create a temp "site-packages/pkgmgr" fake install root
- Put defaults under "<project_root>/config/defaults.yaml"
where project_root == pkg_root.parent (as per your current logic)
- 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)
@@ -44,15 +42,12 @@ class ConfigDefaultsIntegrationTest(unittest.TestCase):
# Fake pkg install layout:
# pkg_root = <root>/site-packages/pkgmgr
# project_root = pkg_root.parent = <root>/site-packages
site_packages = root / "site-packages"
pkg_root = site_packages / "pkgmgr"
pkg_root.mkdir(parents=True)
# This is the "project_root/config" candidate for both:
# - load.py: roots include pkg_root.parent -> site-packages, so it checks site-packages/config
# - cli/config.py: project_root == pkg_root.parent -> site-packages, so it checks site-packages/config
config_dir = site_packages / "config"
# defaults live inside the package now: <pkg_root>/config/defaults.yaml
config_dir = pkg_root / "config"
config_dir.mkdir(parents=True)
defaults_payload = {
@@ -74,7 +69,7 @@ class ConfigDefaultsIntegrationTest(unittest.TestCase):
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
with patch.dict(os.environ, {"HOME": str(home)}):
# A) load_config should fall back to site-packages/config/defaults.yaml
# A) load_config should fall back to <pkg_root>/config/defaults.yaml
merged = load_config(user_config_path)
self.assertEqual(
@@ -98,7 +93,6 @@ class ConfigDefaultsIntegrationTest(unittest.TestCase):
)
# B) update_default_configs should copy defaults.yaml to ~/.config/pkgmgr/
# (and should not overwrite config.yaml)
before_config_yaml = (user_cfg_dir / "config.yaml").read_text(
encoding="utf-8"
)