diff --git a/pyproject.toml b/pyproject.toml index 7c68d87..5e76685 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/pkgmgr/cli/commands/config.py b/src/pkgmgr/cli/commands/config.py index da13d56..d2bff0b 100644 --- a/src/pkgmgr/cli/commands/config.py +++ b/src/pkgmgr/cli/commands/config.py @@ -1,3 +1,4 @@ +# src/pkgmgr/cli/commands/config.py #!/usr/bin/env python3 # -*- 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]: """ - 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: - /config - - /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: - if cand.is_dir(): - return str(cand) + 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) diff --git a/config/__init__.py b/src/pkgmgr/config/__init__.py similarity index 100% rename from config/__init__.py rename to src/pkgmgr/config/__init__.py diff --git a/config/defaults.yaml b/src/pkgmgr/config/defaults.yaml similarity index 100% rename from config/defaults.yaml rename to src/pkgmgr/config/defaults.yaml diff --git a/src/pkgmgr/core/config/load.py b/src/pkgmgr/core/config/load.py index b1f2a17..dca19fc 100644 --- a/src/pkgmgr/core/config/load.py +++ b/src/pkgmgr/core/config/load.py @@ -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: /config - /config - Dabei werden ebenfalls alle *.yml/*.yaml als Layer geladen. + During development (src-layout), we optionally also check: + /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: + - /config (installed wheel / editable) + - /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", diff --git a/tests/integration/test_config_defaults_integration.py b/tests/integration/test_config_defaults_integration.py index e92864f..43cc09f 100644 --- a/tests/integration/test_config_defaults_integration.py +++ b/tests/integration/test_config_defaults_integration.py @@ -20,13 +20,11 @@ class ConfigDefaultsIntegrationTest(unittest.TestCase): """ Integration test: - Create a temp "site-packages/pkgmgr" fake install root - - Put defaults under "/config/defaults.yaml" - where project_root == pkg_root.parent (as per your current logic) + - Put defaults under "/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 = /site-packages/pkgmgr - # project_root = pkg_root.parent = /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: /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 /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" )