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/" # Source layout: all packages live under "src/"
[tool.setuptools] [tool.setuptools]
package-dir = { "" = "src" } package-dir = { "" = "src" }
include-package-data = true
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
include = ["pkgmgr*"] include = ["pkgmgr*"]
[tool.setuptools.package-data] [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 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
@@ -38,25 +39,18 @@ def _load_user_config(user_config_path: str) -> Dict[str, Any]:
def _find_defaults_source_dir() -> Optional[str]: def _find_defaults_source_dir() -> Optional[str]:
""" """
Find the directory inside the installed pkgmgr package OR the Find the directory inside the installed pkgmgr package that contains
project root that contains default config files. the default config files.
Preferred locations (in dieser Reihenfolge): Preferred location:
- <pkg_root>/config - <pkg_root>/config
- <project_root>/config
""" """
import pkgmgr # local import to avoid circular deps import pkgmgr # local import to avoid circular deps
pkg_root = Path(pkgmgr.__file__).resolve().parent pkg_root = Path(pkgmgr.__file__).resolve().parent
project_root = pkg_root.parent cand = pkg_root / "config"
if cand.is_dir():
candidates = [ return str(cand)
pkg_root / "config",
project_root / "config",
]
for cand in candidates:
if cand.is_dir():
return str(cand)
return None return None
@@ -84,7 +78,6 @@ def _update_default_configs(user_config_path: str) -> None:
if not (lower.endswith(".yml") or lower.endswith(".yaml")): if not (lower.endswith(".yml") or lower.endswith(".yaml")):
continue continue
if name == "config.yaml": if name == "config.yaml":
# Never overwrite the user config template / live config
continue continue
src = os.path.join(source_dir, name) src = os.path.join(source_dir, name)
@@ -98,48 +91,28 @@ def handle_config(args, ctx: CLIContext) -> None:
""" """
Handle 'pkgmgr config' subcommands. Handle 'pkgmgr config' subcommands.
""" """
user_config_path = ctx.user_config_path user_config_path = ctx.user_config_path
# ------------------------------------------------------------
# config show
# ------------------------------------------------------------
if args.subcommand == "show": if args.subcommand == "show":
if args.all or (not args.identifiers): if args.all or (not args.identifiers):
# Full merged config view
show_config([], user_config_path, full_config=True) show_config([], user_config_path, full_config=True)
else: else:
# Show only matching entries from user config
user_config = _load_user_config(user_config_path) user_config = _load_user_config(user_config_path)
selected = resolve_repos( selected = resolve_repos(
args.identifiers, args.identifiers, user_config.get("repositories", [])
user_config.get("repositories", []),
) )
if selected: if selected:
show_config( show_config(selected, user_config_path, full_config=False)
selected,
user_config_path,
full_config=False,
)
return return
# ------------------------------------------------------------
# config add
# ------------------------------------------------------------
if args.subcommand == "add": if args.subcommand == "add":
interactive_add(ctx.config_merged, user_config_path) interactive_add(ctx.config_merged, user_config_path)
return return
# ------------------------------------------------------------
# config edit
# ------------------------------------------------------------
if args.subcommand == "edit": if args.subcommand == "edit":
run_command(f"nano {user_config_path}") run_command(f"nano {user_config_path}")
return return
# ------------------------------------------------------------
# config init
# ------------------------------------------------------------
if args.subcommand == "init": if args.subcommand == "init":
user_config = _load_user_config(user_config_path) user_config = _load_user_config(user_config_path)
config_init( config_init(
@@ -150,9 +123,6 @@ def handle_config(args, ctx: CLIContext) -> None:
) )
return return
# ------------------------------------------------------------
# config delete
# ------------------------------------------------------------
if args.subcommand == "delete": if args.subcommand == "delete":
user_config = _load_user_config(user_config_path) user_config = _load_user_config(user_config_path)
@@ -163,10 +133,7 @@ def handle_config(args, ctx: CLIContext) -> None:
) )
return return
to_delete = resolve_repos( to_delete = resolve_repos(args.identifiers, user_config.get("repositories", []))
args.identifiers,
user_config.get("repositories", []),
)
new_repos = [ new_repos = [
entry entry
for entry in user_config.get("repositories", []) 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.") print(f"Deleted {len(to_delete)} entries from user config.")
return return
# ------------------------------------------------------------
# config ignore
# ------------------------------------------------------------
if args.subcommand == "ignore": if args.subcommand == "ignore":
user_config = _load_user_config(user_config_path) user_config = _load_user_config(user_config_path)
@@ -190,17 +154,10 @@ def handle_config(args, ctx: CLIContext) -> None:
) )
return return
to_modify = resolve_repos( to_modify = resolve_repos(args.identifiers, user_config.get("repositories", []))
args.identifiers,
user_config.get("repositories", []),
)
for entry in user_config["repositories"]: for entry in user_config["repositories"]:
key = ( key = (entry.get("provider"), entry.get("account"), entry.get("repository"))
entry.get("provider"),
entry.get("account"),
entry.get("repository"),
)
for mod in to_modify: for mod in to_modify:
mod_key = ( mod_key = (
mod.get("provider"), mod.get("provider"),
@@ -214,21 +171,9 @@ def handle_config(args, ctx: CLIContext) -> None:
save_user_config(user_config, user_config_path) save_user_config(user_config, user_config_path)
return return
# ------------------------------------------------------------
# config update
# ------------------------------------------------------------
if args.subcommand == "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) _update_default_configs(user_config_path)
return return
# ------------------------------------------------------------
# Unknown subcommand
# ------------------------------------------------------------
print(f"Unknown config subcommand: {args.subcommand}") print(f"Unknown config subcommand: {args.subcommand}")
sys.exit(2) sys.exit(2)

View File

@@ -1,3 +1,4 @@
# src/pkgmgr/core/config/load.py
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
@@ -7,29 +8,28 @@ Load and merge pkgmgr configuration.
Layering rules: Layering rules:
1. Defaults / category files: 1. Defaults / category files:
- Zuerst werden alle *.yml/*.yaml (außer config.yaml) im - First load all *.yml/*.yaml (except config.yaml) from the user directory:
Benutzerverzeichnis geladen:
~/.config/pkgmgr/ ~/.config/pkgmgr/
- Falls dort keine passenden Dateien existieren, wird auf die im - If no matching files exist there, fall back to defaults shipped with pkgmgr:
Paket / Projekt mitgelieferten Config-Verzeichnisse zurückgegriffen:
<pkg_root>/config <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 All *.yml/*.yaml files are loaded as layers.
verwendet und in repo["category_files"] eingetragen.
- The filename stem is used as category name and stored in repo["category_files"].
2. User config: 2. User config:
- ~/.config/pkgmgr/config.yaml (oder der übergebene Pfad) - ~/.config/pkgmgr/config.yaml (or the provided path)
wird geladen und PER LISTEN-MERGE über die Defaults gelegt: is loaded and merged over defaults:
- directories: dict deep-merge - directories: dict deep-merge
- repositories: per _merge_repo_lists (kein Löschen!) - repositories: per _merge_repo_lists (no deletions!)
3. Ergebnis: 3. Result:
- Ein dict mit mindestens: - A dict with at least:
config["directories"] (dict) config["directories"] (dict)
config["repositories"] (list[dict]) config["repositories"] (list[dict])
""" """
@@ -38,7 +38,7 @@ from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Tuple, Optional from typing import Any, Dict, List, Optional, Tuple
import yaml 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). Merge two repository lists, matching by (provider, account, repository).
- Wenn ein Repo aus new_list noch nicht existiert, wird es hinzugefügt. - If a repo from new_list does not exist, it is added.
- Wenn es existiert, werden seine Felder per Deep-Merge überschrieben. - If it exists, its fields are deep-merged (override wins).
- Wenn category_name gesetzt ist, wird dieser in - If category_name is set, it is appended to repo["category_files"].
repo["category_files"] eingetragen.
""" """
index: Dict[Tuple[str, str, str], Repo] = {_repo_key(r): r for r in base_list} index: Dict[Tuple[str, str, str], Repo] = {_repo_key(r): r for r in base_list}
for src in new_list: for src in new_list:
key = _repo_key(src) key = _repo_key(src)
if key == ("", "", ""): if key == ("", "", ""):
# Unvollständiger Schlüssel -> einfach anhängen # Incomplete key -> append as-is
dst = dict(src) dst = dict(src)
if category_name: if category_name:
dst.setdefault("category_files", []) dst.setdefault("category_files", [])
@@ -141,10 +140,9 @@ def _load_layer_dir(
""" """
Load all *.yml/*.yaml from a directory as layered defaults. Load all *.yml/*.yaml from a directory as layered defaults.
- skip_filename: Dateiname (z.B. "config.yaml"), der ignoriert - skip_filename: filename (e.g. "config.yaml") to ignore.
werden soll (z.B. User-Config).
Rückgabe: Returns:
{ {
"directories": {...}, "directories": {...},
"repositories": [...], "repositories": [...],
@@ -169,7 +167,7 @@ def _load_layer_dir(
for path in yaml_files: for path in yaml_files:
data = _load_yaml_file(path) data = _load_yaml_file(path)
category_name = path.stem # Dateiname ohne .yml/.yaml category_name = path.stem
dirs = data.get("directories") dirs = data.get("directories")
if isinstance(dirs, dict): if isinstance(dirs, dict):
@@ -190,8 +188,11 @@ def _load_layer_dir(
def _load_defaults_from_package_or_project() -> Dict[str, Any]: def _load_defaults_from_package_or_project() -> Dict[str, Any]:
""" """
Fallback: load default configs from various possible install or development Fallback: load default configs from possible install or dev layouts.
layouts (pip-installed, editable install, source repo with src/ layout).
Supported locations:
- <pkg_root>/config (installed wheel / editable)
- <repo_root>/config (optional dev fallback when pkg_root is src/pkgmgr)
""" """
try: try:
import pkgmgr # type: ignore import pkgmgr # type: ignore
@@ -199,24 +200,16 @@ def _load_defaults_from_package_or_project() -> Dict[str, Any]:
return {"directories": {}, "repositories": []} return {"directories": {}, "repositories": []}
pkg_root = Path(pkgmgr.__file__).resolve().parent pkg_root = Path(pkgmgr.__file__).resolve().parent
roots = set() candidates: List[Path] = []
# Case 1: installed package (site-packages/pkgmgr) # Always prefer package-internal config dir
roots.add(pkg_root) candidates.append(pkg_root / "config")
# Case 2: parent directory (site-packages/, src/) # Dev fallback: repo_root/src/pkgmgr -> repo_root/config
roots.add(pkg_root.parent)
# Case 3: src-layout during development:
# repo_root/src/pkgmgr -> repo_root
parent = pkg_root.parent parent = pkg_root.parent
if parent.name == "src": if parent.name == "src":
roots.add(parent.parent) repo_root = parent.parent
candidates.append(repo_root / "config")
# Candidate config dirs
candidates = []
for root in roots:
candidates.append(root / "config")
for cand in candidates: for cand in candidates:
defaults = _load_layer_dir(cand, skip_filename=None) 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. Load and merge configuration for pkgmgr.
Schritte: Steps:
1. Ermittle ~/.config/pkgmgr/ (oder das Verzeichnis von user_config_path). 1. Determine ~/.config/pkgmgr/ (or dir of user_config_path).
2. Lade alle *.yml/*.yaml dort (außer der User-Config selbst) als 2. Load all *.yml/*.yaml in that dir (except the user config file) as defaults.
Defaults / Kategorie-Layer. 3. If nothing found, fall back to package defaults.
3. Wenn dort nichts gefunden wurde, Fallback auf Paket/Projekt. 4. Load the user config file (if present).
4. Lade die User-Config-Datei selbst (falls vorhanden).
5. Merge: 5. Merge:
- directories: deep-merge (Defaults <- User) - directories: deep-merge (defaults <- user)
- repositories: _merge_repo_lists (Defaults <- User) - repositories: _merge_repo_lists (defaults <- user)
""" """
user_config_path_expanded = os.path.expanduser(user_config_path) user_config_path_expanded = os.path.expanduser(user_config_path)
user_cfg_path = Path(user_config_path_expanded) user_cfg_path = Path(user_config_path_expanded)
config_dir = user_cfg_path.parent config_dir = user_cfg_path.parent
if not str(config_dir): if not str(config_dir):
# Fallback, falls jemand nur "config.yaml" übergibt
config_dir = Path(os.path.expanduser("~/.config/pkgmgr")) config_dir = Path(os.path.expanduser("~/.config/pkgmgr"))
config_dir.mkdir(parents=True, exist_ok=True) config_dir.mkdir(parents=True, exist_ok=True)
user_cfg_name = user_cfg_path.name 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) 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"]: if not defaults["directories"] and not defaults["repositories"]:
defaults = _load_defaults_from_package_or_project() defaults = _load_defaults_from_package_or_project()
defaults.setdefault("directories", {}) defaults.setdefault("directories", {})
defaults.setdefault("repositories", []) defaults.setdefault("repositories", [])
# 4) User-Config # 4) User config
user_cfg: Dict[str, Any] = {} user_cfg: Dict[str, Any] = {}
if user_cfg_path.is_file(): if user_cfg_path.is_file():
user_cfg = _load_yaml_file(user_cfg_path) user_cfg = _load_yaml_file(user_cfg_path)
user_cfg.setdefault("directories", {}) user_cfg.setdefault("directories", {})
user_cfg.setdefault("repositories", []) user_cfg.setdefault("repositories", [])
# 5) Merge: directories deep-merge, repositories listen-merge # 5) Merge
merged: Dict[str, Any] = {} merged: Dict[str, Any] = {}
# directories
merged["directories"] = {} merged["directories"] = {}
_deep_merge(merged["directories"], defaults["directories"]) _deep_merge(merged["directories"], defaults["directories"])
_deep_merge(merged["directories"], user_cfg["directories"]) _deep_merge(merged["directories"], user_cfg["directories"])
# repositories
merged["repositories"] = [] merged["repositories"] = []
_merge_repo_lists( _merge_repo_lists(
merged["repositories"], defaults["repositories"], category_name=None 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 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())) - { other_keys = (set(defaults.keys()) | set(user_cfg.keys())) - {
"directories", "directories",
"repositories", "repositories",

View File

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