diff --git a/.gitignore b/.gitignore index 70e57f6..e66a304 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,6 @@ MANIFEST # PyInstaller *.manifest -*.spec # Installer logs pip-log.txt diff --git a/README.md b/README.md index 0705fa0..9baca8d 100644 --- a/README.md +++ b/README.md @@ -12,21 +12,40 @@ Requires `libdnf5` (system package, not from PyPI) and configured Fedora reposit ## Usage +### Check RPM files (recommended) + +Check actual RPM files from a build before pushing to repositories: + +```bash +fedora-revdep-check --rpms /path/to/build/*.rpm [options] +fedora-revdep-check --rpm-dir /path/to/build/RPMS/noarch/ [options] +``` + +This reads the actual provides from the RPM files, including their exact versions, which is more accurate than simulating version changes. + +### Simulate version change (legacy mode) + +Simulate updating a package to a new version: + ```bash fedora-revdep-check [options] ``` +**Note**: This mode assumes all binary RPMs and their provides will have the same version, which may not always be accurate. + ### Examples ```bash -# Check rawhide (default) -fedora-revdep-check jupyterlab 4.7.0 +# Check RPM files from a build (recommended) +fedora-revdep-check --rpms ~/rpmbuild/RPMS/noarch/*.rpm +fedora-revdep-check --rpm-dir ~/koji-download/python-sphinx-9.1.0-1.fc45/ -# Verbose output +# Legacy mode: simulate version change +fedora-revdep-check jupyterlab 4.7.0 fedora-revdep-check pytest 8.0.0 --verbose # Use stable Fedora instead of rawhide -fedora-revdep-check numpy 2.0.0 --repo fedora --repo fedora-source +fedora-revdep-check --rpms *.rpm --repo fedora --repo fedora-source # Check specific Fedora version fedora-revdep-check python-requests 2.32.0 --repo fedora-40 --repo fedora-40-source @@ -34,6 +53,8 @@ fedora-revdep-check python-requests 2.32.0 --repo fedora-40 --repo fedora-40-sou ### Options +- `--rpms FILE [FILE ...]` - RPM file(s) to check +- `--rpm-dir DIR` - Directory containing RPM files to check - `-v, --verbose` - Show detailed analysis - `-r, --repo ` - Repository to enable (can be specified multiple times, default: rawhide, rawhide-source, koji, and koji-source) @@ -70,14 +91,25 @@ The tool distinguishes between: ## How It Works -1. Finds all binary packages built from the SRPM -2. Extracts their provides (excluding bundled) +### RPM File Mode (Recommended) + +1. Reads RPM files and extracts their provides with actual versions +2. Determines the source package name from the RPM headers +3. Finds reverse dependencies for each provide in Fedora repositories +4. Checks if each requirement would be satisfied by the provides in the RPM files +5. Reports packages that would fail to build or install + +### Legacy Mode (Version Simulation) + +1. Finds all binary packages built from the SRPM in repositories +2. Extracts their provides and simulates them with the new version 3. Finds reverse dependencies for each provide -4. Filters to latest versions only (no duplicates) -5. Checks if the new version satisfies all requirements -6. Reports packages that would fail to build or install +4. Checks if the simulated new version satisfies all requirements +5. Reports packages that would fail to build or install + +**Note**: Both modes automatically exclude packages from the same SRPM (they'll be updated together). -Packages from the same SRPM are automatically excluded (they'll be updated together). +**Note**: Both modes distinguish between new conflicts and already-broken packages to avoid false positives. ## Development diff --git a/fedora_revdep_check.py b/fedora_revdep_check.py index 63191a0..90c8e6b 100755 --- a/fedora_revdep_check.py +++ b/fedora_revdep_check.py @@ -6,16 +6,21 @@ in Fedora rawhide. Uses DNF Python bindings with cached repository data. Usage: - fedora-revdep-check + fedora-revdep-check --rpms /path/to/build/*.rpm + fedora-revdep-check --rpm-dir /path/to/build/ + fedora-revdep-check # Legacy mode Example: - fedora-revdep-check jupyterlab 4.7.0 + fedora-revdep-check --rpms ~/rpmbuild/RPMS/noarch/*.rpm + fedora-revdep-check jupyterlab 4.7.0 # Legacy """ import sys import re import argparse import operator +import os +import glob from collections import defaultdict from typing import Dict, List, Tuple import libdnf5 @@ -295,6 +300,126 @@ def get_provides(self, package: libdnf5.rpm.Package) -> List[Tuple[str, str]]: return provides + def read_rpm_provides(self, rpm_files: List[str]) -> Dict: + """ + Read provides from RPM files. + + Args: + rpm_files: List of paths to RPM files + + Returns: + Dictionary with: + - srpm_name: Source package name + - provides: Dict mapping provide name to list of (rpm_file, version, full_provide_string) + - rpm_info: Dict mapping rpm_file to metadata (name, epoch, version, release, arch) + """ + if self.verbose: + print(f"Reading {len(rpm_files)} RPM file(s)...\n") + + ts = rpm.TransactionSet() + ts.setVSFlags(rpm._RPMVSF_NOSIGNATURES | rpm._RPMVSF_NODIGESTS) + + provides_map = defaultdict(list) + rpm_info = {} + srpm_names = set() + + for rpm_file in rpm_files: + if not os.path.exists(rpm_file): + raise FileNotFoundError(f"RPM file not found: {rpm_file}") + + try: + with open(rpm_file, 'rb') as f: + hdr = ts.hdrFromFdno(f.fileno()) + + name = hdr[rpm.RPMTAG_NAME] + epoch = hdr[rpm.RPMTAG_EPOCH] if hdr[rpm.RPMTAG_EPOCH] is not None else 0 + version = hdr[rpm.RPMTAG_VERSION] + release = hdr[rpm.RPMTAG_RELEASE] + arch = hdr[rpm.RPMTAG_ARCH] + sourcerpm = hdr[rpm.RPMTAG_SOURCERPM] + + # Extract SRPM name from SOURCERPM tag + if sourcerpm: + srpm_name = sourcerpm.rsplit('-', 2)[0] + srpm_names.add(srpm_name) + else: + srpm_names.add(name) + + # Skip source RPMs for provides (RPMTAG_SOURCEPACKAGE is set for SRPMs + # regardless of the arch tag, which may report 'noarch' for some SRPMs) + if hdr[rpm.RPMTAG_SOURCEPACKAGE]: + if self.verbose: + print(f"Skipping source RPM: {name}-{version}-{release}") + continue + + evr = f"{epoch}:{version}-{release}" if epoch else f"{version}-{release}" + rpm_info[rpm_file] = { + 'name': name, + 'epoch': epoch, + 'version': version, + 'release': release, + 'arch': arch, + 'evr': evr, + } + + if self.verbose: + print(f"Reading {name}-{evr}.{arch}") + + # Extract provides + provides_names = hdr[rpm.RPMTAG_PROVIDENAME] + provides_flags = hdr[rpm.RPMTAG_PROVIDEFLAGS] + provides_versions = hdr[rpm.RPMTAG_PROVIDEVERSION] + + if provides_names: + for i, prov_name in enumerate(provides_names): + + # Skip bundled provides + if prov_name.startswith('bundled('): + continue + + prov_version = None + full_provide = prov_name + + if provides_versions and i < len(provides_versions) and provides_versions[i]: + prov_version = provides_versions[i] + + if provides_flags and i < len(provides_flags): + flags = provides_flags[i] + eq = flags & rpm.RPMSENSE_EQUAL + gt = flags & rpm.RPMSENSE_GREATER + lt = flags & rpm.RPMSENSE_LESS + if eq and gt: + full_provide = f"{prov_name} >= {prov_version}" + elif eq and lt: + full_provide = f"{prov_name} <= {prov_version}" + elif eq: + full_provide = f"{prov_name} = {prov_version}" + elif gt: + full_provide = f"{prov_name} > {prov_version}" + elif lt: + full_provide = f"{prov_name} < {prov_version}" + + provides_map[prov_name].append((rpm_file, prov_version, full_provide)) + + except Exception as e: + raise RuntimeError(f"Failed to read RPM file {rpm_file}: {e}") + + if len(srpm_names) == 0: + raise ValueError("Could not determine source package name from RPM files") + elif len(srpm_names) > 1: + raise ValueError(f"RPM files come from multiple source packages: {', '.join(srpm_names)}") + + srpm_name = srpm_names.pop() + + if self.verbose: + print(f"\nFound {len(provides_map)} unique provides (excluding bundled)\n") + + return { + 'srpm_name': srpm_name, + 'provides': provides_map, + 'rpm_info': rpm_info + } + def find_reverse_dependencies(self, provide_name: str) -> List[libdnf5.rpm.Package]: """Find all packages that require the given provide.""" query = libdnf5.rpm.PackageQuery(self.base) @@ -415,6 +540,145 @@ def simulate_version_change(self, srpm_name: str, new_version: str) -> Dict: 'conflicts': conflicts } + def check_rpm_files(self, rpm_files: List[str]) -> Dict: + """ + Check if RPM files would break reverse dependencies. + + Args: + rpm_files: List of paths to RPM files to check + + Returns: + Dictionary with conflict information (same format as simulate_version_change) + """ + # Read provides from RPM files + rpm_data = self.read_rpm_provides(rpm_files) + srpm_name = rpm_data['srpm_name'] + provides_map = rpm_data['provides'] + rpm_info = rpm_data['rpm_info'] + + if self.verbose: + print(f"Checking reverse dependencies for {srpm_name}...\n") + + # Get current packages from repository for comparison + current_packages = self.get_binary_packages(srpm_name) + + # Build a map of current provides and their versions for "already broken" detection + current_provides_map = {} + for pkg in current_packages: + for prov_name, prov_version, prov_str in self.get_provides(pkg): + if prov_name not in current_provides_map: + current_provides_map[prov_name] = [] + # Note: Order must match what _check_requirement_conflict expects: (pkg, prov_str, prov_version) + current_provides_map[prov_name].append((pkg, prov_str, prov_version)) + + conflicts = [] + + # Track checked requirements to avoid duplicates + checked_requirements = set() + + # Check each provide from the RPM files + for prov_name, prov_instances in provides_map.items(): + if self.verbose: + print(f"Checking provide '{prov_name}'") + + # Get the version from the first instance (they should all be the same build) + # prov_instances is a list of (rpm_file, prov_version, full_provide) tuples + rpm_file = prov_instances[0][0] if prov_instances else None + prov_version = prov_instances[0][1] if prov_instances and prov_instances[0][1] else None + + # Determine the new version to use + # For provides that include RPM package names (like python3-sphinx), + # we should use the full EVR with epoch + # For dist provides (like python3dist(sphinx)), we use just the version without epoch + if ':' in prov_version if prov_version else False: + # Version already has epoch + new_version = prov_version + elif rpm_file and rpm_file in rpm_info: + # Check if this is a package name provide or dist provide + rpm_name = rpm_info[rpm_file]['name'] + if prov_name == rpm_name or prov_name.startswith(f"{rpm_name}("): + # Package name provide - use full EVR with epoch + new_version = rpm_info[rpm_file]['evr'] + else: + # Dist provide - use version without epoch + new_version = prov_version if prov_version else rpm_info[rpm_file]['version'] + else: + new_version = prov_version + + if not new_version: + if self.verbose: + print(f" Warning: No version found for provide '{prov_name}', skipping") + continue + + # Find packages that require this provide + rdeps = self.find_reverse_dependencies(prov_name) + + if self.verbose: + print(f" Found {len(rdeps)} reverse dependencies") + + # Get current provides for "already broken" detection + current_prov_info = current_provides_map.get(prov_name, []) + + # Check each reverse dependency + for rdep_pkg in rdeps: + # Skip packages from the same SRPM (they'll be updated together) + if rdep_pkg.get_source_name() == srpm_name: + if self.verbose: + print(f" Skipping {rdep_pkg.get_name()} (same SRPM)") + continue + + pkg_key = f"{rdep_pkg.get_name()}-{rdep_pkg.get_version()}-{rdep_pkg.get_release()}" + + # Check requirements against the new provides + for req in rdep_pkg.get_requires(): + req_str = req.to_string() + + # Check if this requirement matches the provide we're testing + if not self._requirement_matches_provide(req_str, prov_name): + continue + + # Avoid checking the same requirement multiple times + check_key = (pkg_key, req_str) + if check_key in checked_requirements: + continue + checked_requirements.add(check_key) + + if self.verbose: + print(f" Checking: {rdep_pkg.get_name()} requires {req_str}") + + # Check if requirement would be satisfied by the new version + conflict = self._check_requirement_conflict( + req_str, prov_name, new_version, rdep_pkg, current_prov_info + ) + + if conflict: + conflicts.append(conflict) + if self.verbose: + print(f" -> CONFLICT: {conflict['failed_constraint']}") + + # Prepare binary package list from RPM info + binary_packages = [ + f"{info['name']}-{info['evr']}.{info['arch']}" + for info in rpm_info.values() + ] + + # Determine a representative version for the result + # Use the EVR from the first RPM (they should all be from the same build) + new_version = list(rpm_info.values())[0]['evr'] if rpm_info else "unknown" + + if self.verbose: + if conflicts: + print(f"\nFound {len(conflicts)} conflict(s)") + else: + print("\nNo conflicts detected") + + return { + 'srpm_name': srpm_name, + 'new_version': new_version, + 'binary_packages': binary_packages, + 'conflicts': conflicts + } + def _requirement_matches_provide(self, req_str: str, prov_name: str) -> bool: """ Check if a requirement string actually references the given provide. @@ -534,7 +798,9 @@ def _check_requirement_conflict( for pkg, prov_str, prov_version in prov_info_list: # Use the package version if provide doesn't have its own version current_ver = prov_version if prov_version else pkg.get_version() - if not self._version_satisfies(current_ver, constraint['op'], constraint['version']): + # Strip epoch for dist provides, just like we do for new_version + current_ver_to_check = current_ver if provide_uses_epoch else current_ver.split(':', 1)[-1] + if not self._version_satisfies(current_ver_to_check, constraint['op'], constraint['version']): current_version_also_fails = True break @@ -675,16 +941,23 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: + # Check RPM files from a build + %(prog)s --rpms ~/rpmbuild/RPMS/noarch/*.rpm + %(prog)s --rpm-dir ~/rpmbuild/RPMS/noarch/ + + # Legacy mode (simulates a version change) %(prog)s jupyterlab 4.7.0 %(prog)s python-requests 2.32.0 --verbose - %(prog)s pytest 8.0.0 --repo fedora --repo fedora-source - %(prog)s numpy 2.0.0 --repo fedora-40 --repo fedora-40-source %(prog)s pytest 8.0.0 --releasever 44 """ ) - parser.add_argument('srpm_name', help='Source package name (SRPM)') - parser.add_argument('new_version', help='New version to test') + parser.add_argument('srpm_name', nargs='?', help='Source package name (SRPM) - legacy mode') + parser.add_argument('new_version', nargs='?', help='New version to test - legacy mode') + parser.add_argument('--rpms', nargs='+', metavar='FILE', + help='RPM file(s) to check') + parser.add_argument('--rpm-dir', metavar='DIR', + help='Directory containing RPM files to check') parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output') parser.add_argument('-r', '--repo', action='append', dest='repos', @@ -697,9 +970,37 @@ def main(): args = parser.parse_args() + # Determine mode: RPM files or legacy SRPM name + version + rpm_files = [] + + if args.rpms: + # Use specified RPM files + rpm_files = args.rpms + elif args.rpm_dir: + # Find all .rpm files in the directory + if not os.path.isdir(args.rpm_dir): + print(f"ERROR: Directory not found: {args.rpm_dir}", file=sys.stderr) + sys.exit(1) + rpm_files = glob.glob(os.path.join(args.rpm_dir, "*.rpm")) + if not rpm_files: + print(f"ERROR: No .rpm files found in {args.rpm_dir}", file=sys.stderr) + sys.exit(1) + elif args.srpm_name and args.new_version: + # Legacy mode: SRPM name + version + pass + else: + parser.error("Either specify --rpms/--rpm-dir, or provide srpm_name and new_version") + try: checker = FedoraRevDepChecker(verbose=args.verbose, repos=args.repos, releasever=args.releasever) - results = checker.simulate_version_change(args.srpm_name, args.new_version) + + if rpm_files: + # RPM file mode + results = checker.check_rpm_files(rpm_files) + else: + # Legacy mode + results = checker.simulate_version_change(args.srpm_name, args.new_version) + checker.print_results(results) # Exit with error code if NEW conflicts found (not already-broken packages) diff --git a/tests/e2e/test_cli_rpm_mode.py b/tests/e2e/test_cli_rpm_mode.py new file mode 100644 index 0000000..1b5473c --- /dev/null +++ b/tests/e2e/test_cli_rpm_mode.py @@ -0,0 +1,48 @@ +""" +End-to-end tests for CLI with RPM file mode. + +Tests the command-line interface with --rpms and --rpm-dir options. +""" + +import pytest +from unittest.mock import patch +from fedora_revdep_check import main + + +class TestCLIRPMMode: + """Test CLI with --rpms option.""" + + def test_cli_rpm_dir_not_found(self, tmp_path, capsys): + """Test CLI with --rpm-dir when directory doesn't exist.""" + nonexistent = tmp_path / 'nonexistent' + test_args = ['fedora-revdep-check', '--rpm-dir', str(nonexistent)] + + with patch('sys.argv', test_args): + with pytest.raises(SystemExit) as excinfo: + main() + + assert excinfo.value.code == 1 + captured = capsys.readouterr() + assert 'Directory not found' in captured.err + + def test_cli_rpm_dir_no_rpms(self, tmp_path, capsys): + """Test CLI with --rpm-dir when directory has no RPM files.""" + test_args = ['fedora-revdep-check', '--rpm-dir', str(tmp_path)] + + with patch('sys.argv', test_args): + with pytest.raises(SystemExit) as excinfo: + main() + + assert excinfo.value.code == 1 + captured = capsys.readouterr() + assert 'No .rpm files found' in captured.err + + def test_cli_missing_arguments(self, capsys): + """Test CLI with no arguments shows error.""" + test_args = ['fedora-revdep-check'] + + with patch('sys.argv', test_args): + with pytest.raises(SystemExit) as excinfo: + main() + + assert excinfo.value.code == 2 # argparse error diff --git a/tests/fixtures/rpms/rebuild.sh b/tests/fixtures/rpms/rebuild.sh new file mode 100755 index 0000000..a40fc5d --- /dev/null +++ b/tests/fixtures/rpms/rebuild.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Rebuild all test RPM fixtures from the spec files in specs/. +# Run this script when you add a new spec or need to regenerate existing RPMs. +# +# Requirements: rpmbuild + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD=$(mktemp -d) +trap 'rm -rf "$BUILD"' EXIT + +build_binary() { + rpmbuild --define "_topdir $BUILD" --define "dist %{nil}" --nodeps -bb "$1" 2>&1 +} + +build_source() { + rpmbuild --define "_topdir $BUILD" --define "dist %{nil}" --nodeps -bs "$1" 2>&1 +} + +for spec in "$SCRIPT_DIR"/specs/*.spec; do + echo "Building binary: $spec" + build_binary "$spec" +done + +echo "Building source RPM: revdeptest-foo.spec" +build_source "$SCRIPT_DIR/specs/revdeptest-foo.spec" + +cp "$BUILD"/RPMS/noarch/*.rpm "$SCRIPT_DIR"/ +cp "$BUILD"/SRPMS/*.rpm "$SCRIPT_DIR"/ + +echo "" +echo "Built RPMs:" +ls -lh "$SCRIPT_DIR"/*.rpm diff --git a/tests/fixtures/rpms/revdeptest-bar-1.0-1.noarch.rpm b/tests/fixtures/rpms/revdeptest-bar-1.0-1.noarch.rpm new file mode 100644 index 0000000..0bc1f9d Binary files /dev/null and b/tests/fixtures/rpms/revdeptest-bar-1.0-1.noarch.rpm differ diff --git a/tests/fixtures/rpms/revdeptest-bundled-1.0-1.noarch.rpm b/tests/fixtures/rpms/revdeptest-bundled-1.0-1.noarch.rpm new file mode 100644 index 0000000..cef8bb9 Binary files /dev/null and b/tests/fixtures/rpms/revdeptest-bundled-1.0-1.noarch.rpm differ diff --git a/tests/fixtures/rpms/revdeptest-epoch-9.1.0-1.noarch.rpm b/tests/fixtures/rpms/revdeptest-epoch-9.1.0-1.noarch.rpm new file mode 100644 index 0000000..5d295b9 Binary files /dev/null and b/tests/fixtures/rpms/revdeptest-epoch-9.1.0-1.noarch.rpm differ diff --git a/tests/fixtures/rpms/revdeptest-foo-1.0-1.noarch.rpm b/tests/fixtures/rpms/revdeptest-foo-1.0-1.noarch.rpm new file mode 100644 index 0000000..2f40005 Binary files /dev/null and b/tests/fixtures/rpms/revdeptest-foo-1.0-1.noarch.rpm differ diff --git a/tests/fixtures/rpms/revdeptest-foo-1.0-1.src.rpm b/tests/fixtures/rpms/revdeptest-foo-1.0-1.src.rpm new file mode 100644 index 0000000..5c81d92 Binary files /dev/null and b/tests/fixtures/rpms/revdeptest-foo-1.0-1.src.rpm differ diff --git a/tests/fixtures/rpms/revdeptest-multi-1.0-1.noarch.rpm b/tests/fixtures/rpms/revdeptest-multi-1.0-1.noarch.rpm new file mode 100644 index 0000000..bc8266e Binary files /dev/null and b/tests/fixtures/rpms/revdeptest-multi-1.0-1.noarch.rpm differ diff --git a/tests/fixtures/rpms/revdeptest-multi-sub-1.0-1.noarch.rpm b/tests/fixtures/rpms/revdeptest-multi-sub-1.0-1.noarch.rpm new file mode 100644 index 0000000..4b1d7f3 Binary files /dev/null and b/tests/fixtures/rpms/revdeptest-multi-sub-1.0-1.noarch.rpm differ diff --git a/tests/fixtures/rpms/specs/revdeptest-bar.spec b/tests/fixtures/rpms/specs/revdeptest-bar.spec new file mode 100644 index 0000000..b7b51b5 --- /dev/null +++ b/tests/fixtures/rpms/specs/revdeptest-bar.spec @@ -0,0 +1,17 @@ +Name: revdeptest-bar +Version: 1.0 +Release: 1 +Summary: Test package (second SRPM) for fedora-revdep-check tests +License: CC0-1.0 +BuildArch: noarch + +Provides: python3dist(revdeptest-bar) = 1.0 + +%description +Minimal test package from a different SRPM for fedora-revdep-check unit tests. + +%install + +%files + +%changelog diff --git a/tests/fixtures/rpms/specs/revdeptest-bundled.spec b/tests/fixtures/rpms/specs/revdeptest-bundled.spec new file mode 100644 index 0000000..f55c4fe --- /dev/null +++ b/tests/fixtures/rpms/specs/revdeptest-bundled.spec @@ -0,0 +1,18 @@ +Name: revdeptest-bundled +Version: 1.0 +Release: 1 +Summary: Test package with bundled provides for fedora-revdep-check tests +License: CC0-1.0 +BuildArch: noarch + +Provides: bundled(libfoo) = 2.0 +Provides: bundled(libbar) = 3.0 + +%description +Minimal test package with bundled provides for fedora-revdep-check unit tests. + +%install + +%files + +%changelog diff --git a/tests/fixtures/rpms/specs/revdeptest-epoch.spec b/tests/fixtures/rpms/specs/revdeptest-epoch.spec new file mode 100644 index 0000000..c54cda8 --- /dev/null +++ b/tests/fixtures/rpms/specs/revdeptest-epoch.spec @@ -0,0 +1,18 @@ +Name: revdeptest-epoch +Epoch: 1 +Version: 9.1.0 +Release: 1 +Summary: Test package with epoch for fedora-revdep-check tests +License: CC0-1.0 +BuildArch: noarch + +Provides: python3dist(revdeptest-epoch) = 9.1.0 + +%description +Minimal test package with epoch for fedora-revdep-check unit tests. + +%install + +%files + +%changelog diff --git a/tests/fixtures/rpms/specs/revdeptest-foo.spec b/tests/fixtures/rpms/specs/revdeptest-foo.spec new file mode 100644 index 0000000..6b1ae70 --- /dev/null +++ b/tests/fixtures/rpms/specs/revdeptest-foo.spec @@ -0,0 +1,17 @@ +Name: revdeptest-foo +Version: 1.0 +Release: 1 +Summary: Test package for fedora-revdep-check tests +License: CC0-1.0 +BuildArch: noarch + +Provides: python3dist(revdeptest-foo) = 1.0 + +%description +Minimal test package for fedora-revdep-check unit tests. + +%install + +%files + +%changelog diff --git a/tests/fixtures/rpms/specs/revdeptest-multi.spec b/tests/fixtures/rpms/specs/revdeptest-multi.spec new file mode 100644 index 0000000..2dcddfd --- /dev/null +++ b/tests/fixtures/rpms/specs/revdeptest-multi.spec @@ -0,0 +1,28 @@ +Name: revdeptest-multi +Version: 1.0 +Release: 1 +Summary: Test package with subpackage for fedora-revdep-check tests +License: CC0-1.0 +BuildArch: noarch + +Provides: python3dist(revdeptest-multi) = 1.0 + +%description +Main test package with a subpackage for fedora-revdep-check unit tests. + +%package sub +Summary: Subpackage of revdeptest-multi +BuildArch: noarch + +Provides: python3dist(revdeptest-multi-sub) = 1.0 + +%description sub +Subpackage for fedora-revdep-check unit tests. + +%install + +%files + +%files sub + +%changelog diff --git a/tests/integration/test_rpm_checking.py b/tests/integration/test_rpm_checking.py new file mode 100644 index 0000000..76ed110 --- /dev/null +++ b/tests/integration/test_rpm_checking.py @@ -0,0 +1,187 @@ +""" +Integration tests for check_rpm_files() functionality. + +Uses real RPM files from tests/fixtures/rpms/ for the RPM-reading layer. +The DNF layer (repository queries, reverse dependency lookup) is still mocked +via MockBase/MockPackage — that part has its own tests elsewhere. + +Spec files for rebuilding the fixtures live in tests/fixtures/rpms/specs/. +""" + +from pathlib import Path + +from fedora_revdep_check import FedoraRevDepChecker +from tests.fixtures.mock_packages import MockPackage, MockBase + +RPM_DIR = Path(__file__).parent.parent / 'fixtures' / 'rpms' + + +class TestCheckRPMFiles: + """Test check_rpm_files() end-to-end with real RPM files.""" + + def test_check_rpm_files_no_conflicts(self): + """Updating revdeptest-foo to 1.0 satisfies all existing requirements.""" + packages = [ + MockPackage( + name='revdeptest-foo', + version='0.9', + release='1', + arch='noarch', + source_name='revdeptest-foo', + provides=[ + 'revdeptest-foo', + 'python3dist(revdeptest-foo) = 0.9', + ] + ), + MockPackage( + name='consumer', + version='1.0', + release='1', + arch='noarch', + source_name='consumer', + requires=[ + 'python3dist(revdeptest-foo) >= 0.5', + ] + ), + ] + checker = FedoraRevDepChecker(verbose=False, base=MockBase(packages=packages)) + + result = checker.check_rpm_files([str(RPM_DIR / 'revdeptest-foo-1.0-1.noarch.rpm')]) + + assert result['srpm_name'] == 'revdeptest-foo' + assert len(result['conflicts']) == 0 + + def test_check_rpm_files_with_conflicts(self): + """Updating revdeptest-foo to 1.0 breaks a package requiring < 1.0.""" + packages = [ + MockPackage( + name='revdeptest-foo', + version='0.9', + release='1', + arch='noarch', + source_name='revdeptest-foo', + provides=[ + 'revdeptest-foo', + 'python3dist(revdeptest-foo) = 0.9', + ] + ), + MockPackage( + name='old-consumer', + version='1.0', + release='1', + arch='noarch', + source_name='old-consumer', + requires=[ + 'python3dist(revdeptest-foo) < 1.0', + ] + ), + ] + checker = FedoraRevDepChecker(verbose=False, base=MockBase(packages=packages)) + + result = checker.check_rpm_files([str(RPM_DIR / 'revdeptest-foo-1.0-1.noarch.rpm')]) + + assert result['srpm_name'] == 'revdeptest-foo' + assert len(result['conflicts']) == 1 + conflict = result['conflicts'][0] + assert conflict['rdep_source'] == 'old-consumer' + assert conflict['provide_name'] == 'python3dist(revdeptest-foo)' + assert 'python3dist(revdeptest-foo) < 1.0' in conflict['failed_constraint'] + + def test_check_rpm_files_with_epoch(self): + """Updating revdeptest-epoch (Epoch:1) to 9.1.0 satisfies >= 1:8.0.0.""" + packages = [ + MockPackage( + name='revdeptest-epoch', + version='8.0.0', + release='1', + arch='noarch', + source_name='revdeptest-epoch', + epoch='1', + provides=[ + 'revdeptest-epoch', + 'revdeptest-epoch = 1:8.0.0-1', + 'python3dist(revdeptest-epoch) = 8.0.0', + ] + ), + MockPackage( + name='consumer', + version='1.0', + release='1', + arch='noarch', + source_name='consumer', + requires=[ + 'revdeptest-epoch >= 1:8.0.0', + ] + ), + ] + checker = FedoraRevDepChecker(verbose=False, base=MockBase(packages=packages)) + + result = checker.check_rpm_files([str(RPM_DIR / 'revdeptest-epoch-9.1.0-1.noarch.rpm')]) + + assert result['srpm_name'] == 'revdeptest-epoch' + assert len(result['conflicts']) == 0 + + def test_check_rpm_files_skips_same_srpm(self): + """Packages from the same SRPM are not flagged as conflicts.""" + packages = [ + MockPackage( + name='revdeptest-multi', + version='0.9', + release='1', + arch='noarch', + source_name='revdeptest-multi', + provides=[ + 'revdeptest-multi', + 'python3dist(revdeptest-multi) = 0.9', + ] + ), + MockPackage( + name='revdeptest-multi-sub', + version='0.9', + release='1', + arch='noarch', + source_name='revdeptest-multi', + requires=[ + 'revdeptest-multi = 0.9', + ] + ), + ] + checker = FedoraRevDepChecker(verbose=False, base=MockBase(packages=packages)) + + result = checker.check_rpm_files([str(RPM_DIR / 'revdeptest-multi-1.0-1.noarch.rpm')]) + + assert len(result['conflicts']) == 0 + + def test_check_rpm_files_already_broken(self): + """A conflict that exists before the update is marked already_broken.""" + packages = [ + MockPackage( + name='revdeptest-foo', + version='0.9', + release='1', + arch='noarch', + source_name='revdeptest-foo', + provides=[ + 'revdeptest-foo', + 'python3dist(revdeptest-foo) = 0.9', + ] + ), + MockPackage( + name='old-consumer', + version='1.0', + release='1', + arch='noarch', + source_name='old-consumer', + requires=[ + 'python3dist(revdeptest-foo) < 0.5', # already broken with 0.9 + ] + ), + ] + checker = FedoraRevDepChecker(verbose=False, base=MockBase(packages=packages)) + + result = checker.check_rpm_files([str(RPM_DIR / 'revdeptest-foo-1.0-1.noarch.rpm')]) + + assert len(result['conflicts']) == 1 + conflict = result['conflicts'][0] + assert conflict['already_broken'] is True + assert 'python3dist(revdeptest-foo) < 0.5' in conflict['failed_constraint'] diff --git a/tests/unit/test_rpm_reading.py b/tests/unit/test_rpm_reading.py new file mode 100644 index 0000000..371a32c --- /dev/null +++ b/tests/unit/test_rpm_reading.py @@ -0,0 +1,101 @@ +""" +Unit tests for RPM file reading functionality. + +Uses real RPM files from tests/fixtures/rpms/ — no rpm module mocking. +Spec files for rebuilding the fixtures live in tests/fixtures/rpms/specs/. +""" + +import pytest +from pathlib import Path +from fedora_revdep_check import FedoraRevDepChecker + +RPM_DIR = Path(__file__).parent.parent / 'fixtures' / 'rpms' + + +class TestReadRPMProvides: + """Test read_rpm_provides() against real RPM files.""" + + @pytest.fixture + def checker(self, mock_dnf_base): + return FedoraRevDepChecker(verbose=False, base=mock_dnf_base) + + def test_read_single_rpm_file(self, checker): + """Read a single RPM with Epoch: 1 and verify header fields.""" + rpm_file = str(RPM_DIR / 'revdeptest-epoch-9.1.0-1.noarch.rpm') + + result = checker.read_rpm_provides([rpm_file]) + + assert result['srpm_name'] == 'revdeptest-epoch' + assert 'revdeptest-epoch' in result['provides'] + assert 'python3dist(revdeptest-epoch)' in result['provides'] + assert len(result['rpm_info']) == 1 + + info = result['rpm_info'][rpm_file] + assert info['name'] == 'revdeptest-epoch' + assert info['epoch'] == 1 + assert info['version'] == '9.1.0' + assert info['release'] == '1' + assert info['arch'] == 'noarch' + assert info['evr'] == '1:9.1.0-1' + + def test_read_multiple_rpm_files(self, checker): + """Read two binary RPMs from the same SRPM.""" + rpm_main = str(RPM_DIR / 'revdeptest-multi-1.0-1.noarch.rpm') + rpm_sub = str(RPM_DIR / 'revdeptest-multi-sub-1.0-1.noarch.rpm') + + result = checker.read_rpm_provides([rpm_main, rpm_sub]) + + assert result['srpm_name'] == 'revdeptest-multi' + assert 'revdeptest-multi' in result['provides'] + assert 'python3dist(revdeptest-multi)' in result['provides'] + assert 'revdeptest-multi-sub' in result['provides'] + assert 'python3dist(revdeptest-multi-sub)' in result['provides'] + assert len(result['rpm_info']) == 2 + + def test_skip_source_rpm(self, checker): + """Source RPMs must be skipped even though their arch tag is 'noarch'. + + Real SRPMs on Fedora have Arch=noarch in their header, not Arch=src. + The correct signal is RPMTAG_SOURCEPACKAGE=1, which is what the + implementation checks. + """ + srpm = str(RPM_DIR / 'revdeptest-foo-1.0-1.src.rpm') + + result = checker.read_rpm_provides([srpm]) + + assert result['srpm_name'] == 'revdeptest-foo' + assert len(result['provides']) == 0 + assert len(result['rpm_info']) == 0 + + def test_skip_bundled_provides(self, checker): + """Provides that start with 'bundled(' must be filtered out.""" + rpm_file = str(RPM_DIR / 'revdeptest-bundled-1.0-1.noarch.rpm') + + result = checker.read_rpm_provides([rpm_file]) + + assert 'revdeptest-bundled' in result['provides'] + assert 'bundled(libfoo)' not in result['provides'] + assert 'bundled(libbar)' not in result['provides'] + + def test_mixed_source_packages_error(self, checker): + """Passing RPMs from two different SRPMs must raise ValueError.""" + rpm_foo = str(RPM_DIR / 'revdeptest-foo-1.0-1.noarch.rpm') + rpm_bar = str(RPM_DIR / 'revdeptest-bar-1.0-1.noarch.rpm') + + with pytest.raises(ValueError, match="multiple source packages"): + checker.read_rpm_provides([rpm_foo, rpm_bar]) + + def test_file_not_found_error(self, checker): + """A path that does not exist must raise FileNotFoundError.""" + with pytest.raises(FileNotFoundError, match="RPM file not found"): + checker.read_rpm_provides(['/nonexistent/file.rpm']) + + def test_rpm_without_epoch(self, checker): + """Epoch defaults to 0 and evr is formatted without an epoch prefix.""" + rpm_file = str(RPM_DIR / 'revdeptest-foo-1.0-1.noarch.rpm') + + result = checker.read_rpm_provides([rpm_file]) + + info = result['rpm_info'][rpm_file] + assert info['epoch'] == 0 + assert info['evr'] == '1.0-1'