diff --git a/src/pkgmgr/actions/release/workflow.py b/src/pkgmgr/actions/release/workflow.py index 87bcdcb..acff9d9 100644 --- a/src/pkgmgr/actions/release/workflow.py +++ b/src/pkgmgr/actions/release/workflow.py @@ -1,10 +1,13 @@ +# src/pkgmgr/actions/release/workflow.py from __future__ import annotations -from typing import Optional + import os import sys +from typing import Optional from pkgmgr.actions.branch import close_branch from pkgmgr.core.git import get_current_branch, GitError +from pkgmgr.core.repository.paths import resolve_repo_paths from .files import ( update_changelog, @@ -55,8 +58,12 @@ def _release_impl( print(f"New version: {new_ver_str} ({release_type})") repo_root = os.path.dirname(os.path.abspath(pyproject_path)) + paths = resolve_repo_paths(repo_root) + + # --- Update versioned files ------------------------------------------------ update_pyproject_version(pyproject_path, new_ver_str, preview=preview) + changelog_message = update_changelog( changelog_path, new_ver_str, @@ -64,38 +71,46 @@ def _release_impl( preview=preview, ) - flake_path = os.path.join(repo_root, "flake.nix") - update_flake_version(flake_path, new_ver_str, preview=preview) + update_flake_version(paths.flake_nix, new_ver_str, preview=preview) - pkgbuild_path = os.path.join(repo_root, "PKGBUILD") - update_pkgbuild_version(pkgbuild_path, new_ver_str, preview=preview) + if paths.arch_pkgbuild: + update_pkgbuild_version(paths.arch_pkgbuild, new_ver_str, preview=preview) + else: + print("[INFO] No PKGBUILD found (packaging/arch/PKGBUILD or PKGBUILD). Skipping.") - spec_path = os.path.join(repo_root, "package-manager.spec") - update_spec_version(spec_path, new_ver_str, preview=preview) + if paths.rpm_spec: + update_spec_version(paths.rpm_spec, new_ver_str, preview=preview) + else: + print("[INFO] No RPM spec file found. Skipping spec version update.") effective_message: Optional[str] = message if effective_message is None and isinstance(changelog_message, str): if changelog_message.strip(): effective_message = changelog_message.strip() - debian_changelog_path = os.path.join(repo_root, "debian", "changelog") package_name = os.path.basename(repo_root) or "package-manager" - update_debian_changelog( - debian_changelog_path, - package_name=package_name, - new_version=new_ver_str, - message=effective_message, - preview=preview, - ) + if paths.debian_changelog: + update_debian_changelog( + paths.debian_changelog, + package_name=package_name, + new_version=new_ver_str, + message=effective_message, + preview=preview, + ) + else: + print("[INFO] No debian changelog found. Skipping debian/changelog update.") - update_spec_changelog( - spec_path=spec_path, - package_name=package_name, - new_version=new_ver_str, - message=effective_message, - preview=preview, - ) + if paths.rpm_spec: + update_spec_changelog( + spec_path=paths.rpm_spec, + package_name=package_name, + new_version=new_ver_str, + message=effective_message, + preview=preview, + ) + + # --- Git commit / tag / push ---------------------------------------------- commit_msg = f"Release version {new_ver_str}" tag_msg = effective_message or commit_msg @@ -103,12 +118,12 @@ def _release_impl( files_to_add = [ pyproject_path, changelog_path, - flake_path, - pkgbuild_path, - spec_path, - debian_changelog_path, + paths.flake_nix, + paths.arch_pkgbuild, + paths.rpm_spec, + paths.debian_changelog, ] - existing_files = [p for p in files_to_add if p and os.path.exists(p)] + existing_files = [p for p in files_to_add if isinstance(p, str) and p and os.path.exists(p)] if preview: for path in existing_files: diff --git a/src/pkgmgr/core/repository/paths.py b/src/pkgmgr/core/repository/paths.py new file mode 100644 index 0000000..d6777ef --- /dev/null +++ b/src/pkgmgr/core/repository/paths.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Central repository path resolver. + +Goal: +- Provide ONE place to define where packaging / changelog / metadata files live. +- Prefer modern layout (packaging/*) but stay backwards-compatible with legacy + root-level paths. + +Both: +- readers (pkgmgr.core.version.source) +- writers (pkgmgr.actions.release.workflow) + +should use this module instead of hardcoding paths. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Iterable, Optional + + +@dataclass(frozen=True) +class RepoPaths: + repo_dir: str + + pyproject_toml: str + flake_nix: str + + # Human changelog (typically Markdown) + changelog_md: Optional[str] + + # Packaging-related files + arch_pkgbuild: Optional[str] + debian_changelog: Optional[str] + rpm_spec: Optional[str] + + +def _first_existing(candidates: Iterable[str]) -> Optional[str]: + for p in candidates: + if p and os.path.isfile(p): + return p + return None + + +def _find_first_spec_in_dir(dir_path: str) -> Optional[str]: + if not os.path.isdir(dir_path): + return None + try: + for fn in sorted(os.listdir(dir_path)): + if fn.endswith(".spec"): + p = os.path.join(dir_path, fn) + if os.path.isfile(p): + return p + except OSError: + return None + return None + + +def resolve_repo_paths(repo_dir: str) -> RepoPaths: + """ + Resolve canonical file locations for a repository. + + Preferences (new layout first, legacy fallback second): + - PKGBUILD: packaging/arch/PKGBUILD -> PKGBUILD + - Debian changelog: packaging/debian/changelog -> debian/changelog + - RPM spec: packaging/fedora/package-manager.spec + -> first *.spec in packaging/fedora + -> first *.spec in repo root + - CHANGELOG.md: CHANGELOG.md -> packaging/CHANGELOG.md (optional fallback) + + Notes: + - This resolver only returns paths; it does not read/parse files. + - Callers should treat Optional paths as "may not exist". + """ + repo_dir = os.path.abspath(repo_dir) + + pyproject_toml = os.path.join(repo_dir, "pyproject.toml") + flake_nix = os.path.join(repo_dir, "flake.nix") + + changelog_md = _first_existing( + [ + os.path.join(repo_dir, "CHANGELOG.md"), + os.path.join(repo_dir, "packaging", "CHANGELOG.md"), + ] + ) + + arch_pkgbuild = _first_existing( + [ + os.path.join(repo_dir, "packaging", "arch", "PKGBUILD"), + os.path.join(repo_dir, "PKGBUILD"), + ] + ) + + debian_changelog = _first_existing( + [ + os.path.join(repo_dir, "packaging", "debian", "changelog"), + os.path.join(repo_dir, "debian", "changelog"), + ] + ) + + # RPM spec: prefer the canonical file, else first spec in packaging/fedora, else first spec in repo root. + rpm_spec = _first_existing( + [ + os.path.join(repo_dir, "packaging", "fedora", "package-manager.spec"), + ] + ) + if rpm_spec is None: + rpm_spec = _find_first_spec_in_dir(os.path.join(repo_dir, "packaging", "fedora")) + if rpm_spec is None: + rpm_spec = _find_first_spec_in_dir(repo_dir) + + return RepoPaths( + repo_dir=repo_dir, + pyproject_toml=pyproject_toml, + flake_nix=flake_nix, + changelog_md=changelog_md, + arch_pkgbuild=arch_pkgbuild, + debian_changelog=debian_changelog, + rpm_spec=rpm_spec, + ) diff --git a/src/pkgmgr/core/version/source.py b/src/pkgmgr/core/version/source.py index 194b497..2ded27b 100644 --- a/src/pkgmgr/core/version/source.py +++ b/src/pkgmgr/core/version/source.py @@ -1,3 +1,4 @@ +# src/pkgmgr/core/version/source.py from __future__ import annotations import os @@ -6,6 +7,8 @@ from typing import Optional import yaml +from pkgmgr.core.repository.paths import resolve_repo_paths + def read_pyproject_version(repo_dir: str) -> Optional[str]: """ @@ -13,7 +16,8 @@ def read_pyproject_version(repo_dir: str) -> Optional[str]: Expects a PEP 621-style [project] table with a 'version' field. """ - path = os.path.join(repo_dir, "pyproject.toml") + paths = resolve_repo_paths(repo_dir) + path = paths.pyproject_toml if not os.path.isfile(path): return None @@ -39,7 +43,8 @@ def read_pyproject_project_name(repo_dir: str) -> Optional[str]: This is required to correctly resolve installed Python package versions via importlib.metadata. """ - path = os.path.join(repo_dir, "pyproject.toml") + paths = resolve_repo_paths(repo_dir) + path = paths.pyproject_toml if not os.path.isfile(path): return None @@ -65,7 +70,8 @@ def read_flake_version(repo_dir: str) -> Optional[str]: Looks for: version = "X.Y.Z"; """ - path = os.path.join(repo_dir, "flake.nix") + paths = resolve_repo_paths(repo_dir) + path = paths.flake_nix if not os.path.isfile(path): return None @@ -84,15 +90,16 @@ def read_flake_version(repo_dir: str) -> Optional[str]: def read_pkgbuild_version(repo_dir: str) -> Optional[str]: """ - Read the version from PKGBUILD in repo_dir. + Read the version from PKGBUILD (preferring packaging/arch/PKGBUILD). Combines pkgver and pkgrel if both exist: pkgver=1.2.3 pkgrel=1 -> 1.2.3-1 """ - path = os.path.join(repo_dir, "PKGBUILD") - if not os.path.isfile(path): + paths = resolve_repo_paths(repo_dir) + path = paths.arch_pkgbuild + if not path or not os.path.isfile(path): return None try: @@ -117,13 +124,19 @@ def read_pkgbuild_version(repo_dir: str) -> Optional[str]: def read_debian_changelog_version(repo_dir: str) -> Optional[str]: """ - Read the latest version from debian/changelog. + Read the latest version from debian changelog. + + Preferred path: + packaging/debian/changelog + Fallback: + debian/changelog Expected format: package (1.2.3-1) unstable; urgency=medium """ - path = os.path.join(repo_dir, "debian", "changelog") - if not os.path.isfile(path): + paths = resolve_repo_paths(repo_dir) + path = paths.debian_changelog + if not path or not os.path.isfile(path): return None try: @@ -146,37 +159,40 @@ def read_spec_version(repo_dir: str) -> Optional[str]: """ Read the version from an RPM spec file. + Preferred paths: + packaging/fedora/package-manager.spec + packaging/fedora/*.spec + repo_root/*.spec + Combines: Version: 1.2.3 Release: 1%{?dist} -> 1.2.3-1 """ - for fn in os.listdir(repo_dir): - if not fn.endswith(".spec"): - continue + paths = resolve_repo_paths(repo_dir) + path = paths.rpm_spec + if not path or not os.path.isfile(path): + return None - path = os.path.join(repo_dir, fn) - try: - with open(path, "r", encoding="utf-8") as f: - text = f.read() - except Exception: - return None + try: + with open(path, "r", encoding="utf-8") as f: + text = f.read() + except Exception: + return None - ver_match = re.search(r"^Version:\s*(.+)$", text, re.MULTILINE) - if not ver_match: - return None - version = ver_match.group(1).strip() + ver_match = re.search(r"^Version:\s*(.+)$", text, re.MULTILINE) + if not ver_match: + return None + version = ver_match.group(1).strip() - rel_match = re.search(r"^Release:\s*(.+)$", text, re.MULTILINE) - if rel_match: - release_raw = rel_match.group(1).strip() - release = release_raw.split("%", 1)[0].split(" ", 1)[0].strip() - if release: - return f"{version}-{release}" + rel_match = re.search(r"^Release:\s*(.+)$", text, re.MULTILINE) + if rel_match: + release_raw = rel_match.group(1).strip() + release = release_raw.split("%", 1)[0].split(" ", 1)[0].strip() + if release: + return f"{version}-{release}" - return version or None - - return None + return version or None def read_ansible_galaxy_version(repo_dir: str) -> Optional[str]: diff --git a/tests/integration/test_repository_paths_exist.py b/tests/integration/test_repository_paths_exist.py new file mode 100644 index 0000000..6511ba0 --- /dev/null +++ b/tests/integration/test_repository_paths_exist.py @@ -0,0 +1,64 @@ +# tests/integration/test_repository_paths_exist.py +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from pkgmgr.core.repository.paths import resolve_repo_paths + + +def _find_repo_root() -> Path: + """ + Locate the pkgmgr repository root from the test location. + + This assumes the standard layout: + repo_root/ + src/pkgmgr/... + tests/integration/... + """ + here = Path(__file__).resolve() + for parent in here.parents: + if (parent / "pyproject.toml").is_file() and (parent / "src" / "pkgmgr").is_dir(): + return parent + raise RuntimeError("Could not determine repository root for pkgmgr integration test") + + +def test_pkgmgr_repository_paths_exist() -> None: + """ + Integration test: verify that the pkgmgr repository provides all + canonical files defined by RepoPaths. + + pkgmgr acts as the TEMPLATE repository for all other packages. + Therefore, every path resolved here is expected to exist. + """ + repo_root = _find_repo_root() + paths = resolve_repo_paths(str(repo_root)) + + missing: list[str] = [] + + def _require(path: str | None, description: str) -> None: + if not path: + missing.append(f"{description}: ") + return + if not os.path.isfile(path): + missing.append(f"{description}: {path} (missing)") + + # Core metadata (must always exist) + _require(paths.pyproject_toml, "pyproject.toml") + _require(paths.flake_nix, "flake.nix") + + # Human-facing changelog (pkgmgr must provide one) + _require(paths.changelog_md, "CHANGELOG.md") + + # Packaging files (pkgmgr is the reference implementation) + _require(paths.arch_pkgbuild, "Arch PKGBUILD") + _require(paths.debian_changelog, "Debian changelog") + _require(paths.rpm_spec, "RPM spec file") + + if missing: + pytest.fail( + "pkgmgr repository does not satisfy the canonical repository layout:\n" + + "\n".join(f" - {item}" for item in missing) + )