From f7dbc4f71e4e8bd456f3904ca48a3bf0e468964f Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Thu, 5 Mar 2026 11:53:07 +0100 Subject: [PATCH 1/7] Make it possible to get new provides from RPM files --- README.md | 52 +++- fedora_revdep_check.py | 317 +++++++++++++++++++- tests/e2e/test_cli_rpm_mode.py | 60 ++++ tests/integration/test_rpm_checking.py | 398 +++++++++++++++++++++++++ tests/unit/test_rpm_reading.py | 319 ++++++++++++++++++++ 5 files changed, 1128 insertions(+), 18 deletions(-) create mode 100644 tests/e2e/test_cli_rpm_mode.py create mode 100644 tests/integration/test_rpm_checking.py create mode 100644 tests/unit/test_rpm_reading.py 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..69cae28 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].decode() if isinstance(hdr[rpm.RPMTAG_NAME], bytes) else hdr[rpm.RPMTAG_NAME] + epoch = hdr[rpm.RPMTAG_EPOCH] if hdr[rpm.RPMTAG_EPOCH] is not None else 0 + version = hdr[rpm.RPMTAG_VERSION].decode() if isinstance(hdr[rpm.RPMTAG_VERSION], bytes) else hdr[rpm.RPMTAG_VERSION] + release = hdr[rpm.RPMTAG_RELEASE].decode() if isinstance(hdr[rpm.RPMTAG_RELEASE], bytes) else hdr[rpm.RPMTAG_RELEASE] + arch = hdr[rpm.RPMTAG_ARCH].decode() if isinstance(hdr[rpm.RPMTAG_ARCH], bytes) else hdr[rpm.RPMTAG_ARCH] + sourcerpm = hdr[rpm.RPMTAG_SOURCERPM] + if sourcerpm: + sourcerpm = sourcerpm.decode() if isinstance(sourcerpm, bytes) else sourcerpm + + # Extract SRPM name from SOURCERPM tag + if sourcerpm: + # Format is typically: name-version-release.src.rpm + srpm_name = sourcerpm.rsplit('-', 2)[0] + srpm_names.add(srpm_name) + else: + # This is a source RPM + srpm_names.add(name) + + # Skip source RPMs for provides + if arch == 'src': + if self.verbose: + print(f"Skipping source RPM: {name}-{version}-{release}.{arch}") + continue + + rpm_info[rpm_file] = { + 'name': name, + 'epoch': epoch, + 'version': version, + 'release': release, + 'arch': arch, + 'evr': f"{epoch}:{version}-{release}" if epoch else f"{version}-{release}" + } + + if self.verbose: + print(f"Reading {name}-{epoch}:{version}-{release}.{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): + prov_name = prov_name.decode() if isinstance(prov_name, bytes) else prov_name + + # Skip bundled provides + if prov_name.startswith('bundled('): + continue + + prov_version = None + full_provide = prov_name + + # Check if there's a version + if provides_versions and i < len(provides_versions) and provides_versions[i]: + prov_version = provides_versions[i].decode() if isinstance(provides_versions[i], bytes) else provides_versions[i] + + # Get the operator if flags are set + if provides_flags and i < len(provides_flags): + flags = provides_flags[i] + if flags & rpm.RPMSENSE_EQUAL: + full_provide = f"{prov_name} = {prov_version}" + elif flags & rpm.RPMSENSE_GREATER: + full_provide = f"{prov_name} > {prov_version}" + elif flags & rpm.RPMSENSE_LESS: + full_provide = f"{prov_name} < {prov_version}" + else: + 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..1198714 --- /dev/null +++ b/tests/e2e/test_cli_rpm_mode.py @@ -0,0 +1,60 @@ +""" +End-to-end tests for CLI with RPM file mode. + +Tests the command-line interface with --rpms and --rpm-dir options. +""" + +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, capsys): + """Test CLI with --rpm-dir when directory doesn't exist.""" + test_args = ['fedora-revdep-check', '--rpm-dir', '/nonexistent'] + + with patch('sys.argv', test_args), \ + patch('os.path.isdir', return_value=False): + + exit_code = 0 + try: + main() + except SystemExit as e: + exit_code = e.code + + assert exit_code == 1 + captured = capsys.readouterr() + assert 'Directory not found' in captured.err + + def test_cli_rpm_dir_no_rpms(self, capsys): + """Test CLI with --rpm-dir when directory has no RPM files.""" + test_args = ['fedora-revdep-check', '--rpm-dir', '/tmp/empty'] + + with patch('sys.argv', test_args), \ + patch('os.path.isdir', return_value=True), \ + patch('glob.glob', return_value=[]): + + exit_code = 0 + try: + main() + except SystemExit as e: + exit_code = e.code + + assert exit_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): + exit_code = 0 + try: + main() + except SystemExit as e: + exit_code = e.code + + assert exit_code == 2 # argparse error diff --git a/tests/integration/test_rpm_checking.py b/tests/integration/test_rpm_checking.py new file mode 100644 index 0000000..873dcd4 --- /dev/null +++ b/tests/integration/test_rpm_checking.py @@ -0,0 +1,398 @@ +""" +Integration tests for check_rpm_files() functionality. + +Tests the complete workflow of checking RPM files for reverse dependency conflicts. +""" + +from unittest.mock import Mock, mock_open, patch +from fedora_revdep_check import FedoraRevDepChecker +from tests.fixtures.mock_packages import MockPackage, MockBase + + +class MockRPMHeader: + """Mock RPM header for testing.""" + + def __init__(self, name, version, release, arch, epoch=None, sourcerpm=None, + provides_names=None, provides_versions=None, provides_flags=None): + self.data = { + 1000: name.encode() if isinstance(name, str) else name, + 1001: version.encode() if isinstance(version, str) else version, + 1002: release.encode() if isinstance(release, str) else release, + 1022: arch.encode() if isinstance(arch, str) else arch, + 1003: epoch, + 1044: sourcerpm.encode() if sourcerpm and isinstance(sourcerpm, str) else sourcerpm, + 1047: provides_names, + 1113: provides_flags, + 1048: provides_versions, + } + + def __getitem__(self, key): + return self.data.get(key) + + +class TestCheckRPMFiles: + """Test check_rpm_files() integration.""" + + def test_check_rpm_files_no_conflicts(self): + """Test checking RPM files that don't cause conflicts.""" + # Create a mock base with packages that depend on pytest >= 7.0 + packages = [ + MockPackage( + name='python3-pytest', + version='7.0.0', + release='1.fc45', + arch='noarch', + source_name='pytest', + provides=[ + 'python3-pytest', + 'python3dist(pytest) = 7.0.0', + ] + ), + MockPackage( + name='python3-tox', + version='4.0.0', + release='1.fc45', + arch='noarch', + source_name='tox', + requires=[ + 'python3dist(pytest) >= 6.0', + ] + ), + ] + base = MockBase(packages=packages) + checker = FedoraRevDepChecker(verbose=False, base=base) + + # Mock RPM for pytest 7.1.0 + mock_header = MockRPMHeader( + name='python3-pytest', + version='7.1.0', + release='1.fc45', + arch='noarch', + sourcerpm='pytest-7.1.0-1.fc45.src.rpm', + provides_names=[ + b'python3-pytest', + b'python3dist(pytest)', + ], + provides_versions=[ + b'7.1.0-1.fc45', + b'7.1.0', + ], + provides_flags=[8, 8] + ) + + mock_ts = Mock() + mock_ts.hdrFromFdno = Mock(return_value=mock_header) + + with patch('rpm.TransactionSet', return_value=mock_ts), \ + patch('rpm._RPMVSF_NOSIGNATURES', 0), \ + patch('rpm._RPMVSF_NODIGESTS', 0), \ + patch('rpm.RPMTAG_NAME', 1000), \ + patch('rpm.RPMTAG_VERSION', 1001), \ + patch('rpm.RPMTAG_RELEASE', 1002), \ + patch('rpm.RPMTAG_ARCH', 1022), \ + patch('rpm.RPMTAG_EPOCH', 1003), \ + patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_PROVIDENAME', 1047), \ + patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ + patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ + patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open()): + + result = checker.check_rpm_files(['/tmp/pytest.rpm']) + + assert result['srpm_name'] == 'pytest' + assert result['new_version'] == '7.1.0-1.fc45' + assert len(result['conflicts']) == 0 + + def test_check_rpm_files_with_conflicts(self): + """Test checking RPM files that cause conflicts.""" + # Create a mock base with packages that depend on jupyterlab < 4.7 + packages = [ + MockPackage( + name='python3-jupyterlab', + version='4.6.0', + release='1.fc45', + arch='noarch', + source_name='jupyterlab', + provides=[ + 'python3-jupyterlab', + 'python3dist(jupyterlab) = 4.6.0', + ] + ), + MockPackage( + name='python3-jupyter-server', + version='2.0.0', + release='1.fc45', + arch='noarch', + source_name='jupyter-server', + requires=[ + 'python3dist(jupyterlab) < 4.7', + ] + ), + ] + base = MockBase(packages=packages) + checker = FedoraRevDepChecker(verbose=False, base=base) + + # Mock RPM for jupyterlab 4.7.0 + mock_header = MockRPMHeader( + name='python3-jupyterlab', + version='4.7.0', + release='1.fc45', + arch='noarch', + sourcerpm='jupyterlab-4.7.0-1.fc45.src.rpm', + provides_names=[ + b'python3-jupyterlab', + b'python3dist(jupyterlab)', + ], + provides_versions=[ + b'4.7.0-1.fc45', + b'4.7.0', + ], + provides_flags=[8, 8] + ) + + mock_ts = Mock() + mock_ts.hdrFromFdno = Mock(return_value=mock_header) + + with patch('rpm.TransactionSet', return_value=mock_ts), \ + patch('rpm._RPMVSF_NOSIGNATURES', 0), \ + patch('rpm._RPMVSF_NODIGESTS', 0), \ + patch('rpm.RPMTAG_NAME', 1000), \ + patch('rpm.RPMTAG_VERSION', 1001), \ + patch('rpm.RPMTAG_RELEASE', 1002), \ + patch('rpm.RPMTAG_ARCH', 1022), \ + patch('rpm.RPMTAG_EPOCH', 1003), \ + patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_PROVIDENAME', 1047), \ + patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ + patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ + patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open()): + + result = checker.check_rpm_files(['/tmp/jupyterlab.rpm']) + + assert result['srpm_name'] == 'jupyterlab' + assert len(result['conflicts']) == 1 + conflict = result['conflicts'][0] + assert conflict['rdep_source'] == 'jupyter-server' + assert conflict['provide_name'] == 'python3dist(jupyterlab)' + assert 'python3dist(jupyterlab) < 4.7' in conflict['failed_constraint'] + + def test_check_rpm_files_with_epoch(self): + """Test checking RPM files with epochs.""" + # Create a mock base with packages requiring sphinx >= 1:8.0.0 + packages = [ + MockPackage( + name='python3-sphinx', + version='8.0.0', + release='1.fc45', + arch='noarch', + source_name='python-sphinx', # Must match SOURCERPM + epoch='1', + provides=[ + 'python3-sphinx', + 'python3-sphinx = 1:8.0.0-1.fc45', + 'python3dist(sphinx) = 8.0.0', + ] + ), + MockPackage( + name='python3-docs', + version='1.0.0', + release='1.fc45', + arch='noarch', + source_name='python-docs', + requires=[ + 'python3-sphinx >= 1:8.0.0', + ] + ), + ] + base = MockBase(packages=packages) + checker = FedoraRevDepChecker(verbose=False, base=base) + + # Mock RPM for sphinx 1:9.1.0 + mock_header = MockRPMHeader( + name='python3-sphinx', + version='9.1.0', + release='1.fc45', + arch='noarch', + epoch=1, + sourcerpm='python-sphinx-9.1.0-1.fc45.src.rpm', + provides_names=[ + b'python3-sphinx', + b'python3dist(sphinx)', + ], + provides_versions=[ + b'9.1.0-1.fc45', + b'9.1.0', + ], + provides_flags=[8, 8] + ) + + mock_ts = Mock() + mock_ts.hdrFromFdno = Mock(return_value=mock_header) + + with patch('rpm.TransactionSet', return_value=mock_ts), \ + patch('rpm._RPMVSF_NOSIGNATURES', 0), \ + patch('rpm._RPMVSF_NODIGESTS', 0), \ + patch('rpm.RPMTAG_NAME', 1000), \ + patch('rpm.RPMTAG_VERSION', 1001), \ + patch('rpm.RPMTAG_RELEASE', 1002), \ + patch('rpm.RPMTAG_ARCH', 1022), \ + patch('rpm.RPMTAG_EPOCH', 1003), \ + patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_PROVIDENAME', 1047), \ + patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ + patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ + patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open()): + + result = checker.check_rpm_files(['/tmp/sphinx.rpm']) + + assert result['srpm_name'] == 'python-sphinx' + # Should have no conflicts (1:9.1.0 >= 1:8.0.0) + assert len(result['conflicts']) == 0 + + def test_check_rpm_files_skips_same_srpm(self): + """Test that packages from same SRPM are skipped.""" + # Create a mock base where packages from the same SRPM depend on each other + packages = [ + MockPackage( + name='micropipenv', + version='1.10.0', + release='1.fc45', + arch='noarch', + source_name='micropipenv', + provides=[ + 'micropipenv', + 'micropipenv = 1.10.0-1.fc45', + ] + ), + MockPackage( + name='micropipenv+toml', + version='1.10.0', + release='1.fc45', + arch='noarch', + source_name='micropipenv', + requires=[ + 'micropipenv = 1.10.0', + ] + ), + ] + base = MockBase(packages=packages) + checker = FedoraRevDepChecker(verbose=False, base=base) + + # Mock RPM for micropipenv 1.11.0 + mock_header = MockRPMHeader( + name='micropipenv', + version='1.11.0', + release='1.fc45', + arch='noarch', + sourcerpm='micropipenv-1.11.0-1.fc45.src.rpm', + provides_names=[ + b'micropipenv', + ], + provides_versions=[ + b'1.11.0-1.fc45', + ], + provides_flags=[8] + ) + + mock_ts = Mock() + mock_ts.hdrFromFdno = Mock(return_value=mock_header) + + with patch('rpm.TransactionSet', return_value=mock_ts), \ + patch('rpm._RPMVSF_NOSIGNATURES', 0), \ + patch('rpm._RPMVSF_NODIGESTS', 0), \ + patch('rpm.RPMTAG_NAME', 1000), \ + patch('rpm.RPMTAG_VERSION', 1001), \ + patch('rpm.RPMTAG_RELEASE', 1002), \ + patch('rpm.RPMTAG_ARCH', 1022), \ + patch('rpm.RPMTAG_EPOCH', 1003), \ + patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_PROVIDENAME', 1047), \ + patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ + patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ + patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open()): + + result = checker.check_rpm_files(['/tmp/micropipenv.rpm']) + + # micropipenv+toml should be skipped (same SRPM) + assert len(result['conflicts']) == 0 + + def test_check_rpm_files_already_broken(self): + """Test detection of already-broken packages.""" + # Create a mock base where a package already fails with current version + packages = [ + MockPackage( + name='library', + version='4.0.0', + release='1.fc45', + arch='noarch', + source_name='library', + provides=[ + 'library', + 'python3dist(library) = 4.0.0', + ] + ), + MockPackage( + name='python3-old-package', + version='1.0.0', + release='1.fc45', + arch='noarch', + source_name='old-package', + requires=[ + 'python3dist(library) < 3.0', # Already broken with 4.0.0 + ] + ), + ] + base = MockBase(packages=packages) + checker = FedoraRevDepChecker(verbose=False, base=base) + + # Mock RPM for library 5.0.0 + mock_header = MockRPMHeader( + name='library', + version='5.0.0', + release='1.fc45', + arch='noarch', + sourcerpm='library-5.0.0-1.fc45.src.rpm', + provides_names=[ + b'library', + b'python3dist(library)', + ], + provides_versions=[ + b'5.0.0-1.fc45', + b'5.0.0', + ], + provides_flags=[8, 8] + ) + + mock_ts = Mock() + mock_ts.hdrFromFdno = Mock(return_value=mock_header) + + with patch('rpm.TransactionSet', return_value=mock_ts), \ + patch('rpm._RPMVSF_NOSIGNATURES', 0), \ + patch('rpm._RPMVSF_NODIGESTS', 0), \ + patch('rpm.RPMTAG_NAME', 1000), \ + patch('rpm.RPMTAG_VERSION', 1001), \ + patch('rpm.RPMTAG_RELEASE', 1002), \ + patch('rpm.RPMTAG_ARCH', 1022), \ + patch('rpm.RPMTAG_EPOCH', 1003), \ + patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_PROVIDENAME', 1047), \ + patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ + patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ + patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open()): + + result = checker.check_rpm_files(['/tmp/library.rpm']) + + assert len(result['conflicts']) == 1 + conflict = result['conflicts'][0] + assert conflict['already_broken'] is True + assert 'python3dist(library) < 3.0' 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..bc5c0ae --- /dev/null +++ b/tests/unit/test_rpm_reading.py @@ -0,0 +1,319 @@ +""" +Unit tests for RPM file reading functionality. + +Tests the read_rpm_provides() method which reads provides from actual RPM files. +""" + +import pytest +from unittest.mock import Mock, mock_open, patch +from fedora_revdep_check import FedoraRevDepChecker + + +class MockRPMHeader: + """Mock RPM header for testing.""" + + def __init__(self, name, version, release, arch, epoch=None, sourcerpm=None, + provides_names=None, provides_versions=None, provides_flags=None): + self.data = { + 1000: name.encode() if isinstance(name, str) else name, # NAME + 1001: version.encode() if isinstance(version, str) else version, # VERSION + 1002: release.encode() if isinstance(release, str) else release, # RELEASE + 1022: arch.encode() if isinstance(arch, str) else arch, # ARCH + 1003: epoch, # EPOCH + 1044: sourcerpm.encode() if sourcerpm and isinstance(sourcerpm, str) else sourcerpm, # SOURCERPM + 1047: provides_names, # PROVIDENAME + 1113: provides_flags, # PROVIDEFLAGS + 1048: provides_versions, # PROVIDEVERSION + } + + def __getitem__(self, key): + return self.data.get(key) + + +class TestReadRPMProvides: + """Test read_rpm_provides() method.""" + + @pytest.fixture + def checker(self, mock_dnf_base): + """Create checker instance with mocked DNF base.""" + return FedoraRevDepChecker(verbose=False, base=mock_dnf_base) + + def test_read_single_rpm_file(self, checker): + """Test reading a single RPM file.""" + mock_header = MockRPMHeader( + name='python3-sphinx', + version='9.1.0', + release='1.fc45', + arch='noarch', + epoch=1, + sourcerpm='python-sphinx-9.1.0-1.fc45.src.rpm', + provides_names=[ + b'python3-sphinx', + b'python3dist(sphinx)', + ], + provides_versions=[ + b'9.1.0-1.fc45', + b'9.1.0', + ], + provides_flags=[ + 8, # RPMSENSE_EQUAL + 8, # RPMSENSE_EQUAL + ] + ) + + mock_ts = Mock() + mock_ts.hdrFromFdno = Mock(return_value=mock_header) + + with patch('rpm.TransactionSet', return_value=mock_ts), \ + patch('rpm._RPMVSF_NOSIGNATURES', 0), \ + patch('rpm._RPMVSF_NODIGESTS', 0), \ + patch('rpm.RPMTAG_NAME', 1000), \ + patch('rpm.RPMTAG_VERSION', 1001), \ + patch('rpm.RPMTAG_RELEASE', 1002), \ + patch('rpm.RPMTAG_ARCH', 1022), \ + patch('rpm.RPMTAG_EPOCH', 1003), \ + patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_PROVIDENAME', 1047), \ + patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ + patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ + patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open()): + + result = checker.read_rpm_provides(['/tmp/test.rpm']) + + assert result['srpm_name'] == 'python-sphinx' + assert 'python3-sphinx' in result['provides'] + assert 'python3dist(sphinx)' in result['provides'] + assert len(result['rpm_info']) == 1 + + rpm_info = result['rpm_info']['/tmp/test.rpm'] + assert rpm_info['name'] == 'python3-sphinx' + assert rpm_info['version'] == '9.1.0' + assert rpm_info['release'] == '1.fc45' + assert rpm_info['arch'] == 'noarch' + assert rpm_info['epoch'] == 1 + + def test_read_multiple_rpm_files(self, checker): + """Test reading multiple RPM files from same SRPM.""" + mock_header1 = MockRPMHeader( + name='python3-sphinx', + version='9.1.0', + release='1.fc45', + arch='noarch', + epoch=1, + sourcerpm='python-sphinx-9.1.0-1.fc45.src.rpm', + provides_names=[b'python3-sphinx', b'python3dist(sphinx)'], + provides_versions=[b'9.1.0-1.fc45', b'9.1.0'], + provides_flags=[8, 8] + ) + + mock_header2 = MockRPMHeader( + name='python3-sphinx-latex', + version='9.1.0', + release='1.fc45', + arch='noarch', + epoch=1, + sourcerpm='python-sphinx-9.1.0-1.fc45.src.rpm', + provides_names=[b'python3-sphinx-latex'], + provides_versions=[b'9.1.0-1.fc45'], + provides_flags=[8] + ) + + mock_ts = Mock() + mock_ts.hdrFromFdno = Mock(side_effect=[mock_header1, mock_header2]) + + with patch('rpm.TransactionSet', return_value=mock_ts), \ + patch('rpm._RPMVSF_NOSIGNATURES', 0), \ + patch('rpm._RPMVSF_NODIGESTS', 0), \ + patch('rpm.RPMTAG_NAME', 1000), \ + patch('rpm.RPMTAG_VERSION', 1001), \ + patch('rpm.RPMTAG_RELEASE', 1002), \ + patch('rpm.RPMTAG_ARCH', 1022), \ + patch('rpm.RPMTAG_EPOCH', 1003), \ + patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_PROVIDENAME', 1047), \ + patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ + patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ + patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open()): + + result = checker.read_rpm_provides(['/tmp/test1.rpm', '/tmp/test2.rpm']) + + assert result['srpm_name'] == 'python-sphinx' + assert 'python3-sphinx' in result['provides'] + assert 'python3dist(sphinx)' in result['provides'] + assert 'python3-sphinx-latex' in result['provides'] + assert len(result['rpm_info']) == 2 + + def test_skip_source_rpm(self, checker): + """Test that source RPMs are skipped for provides.""" + mock_header = MockRPMHeader( + name='python-sphinx', + version='9.1.0', + release='1.fc45', + arch='src', + sourcerpm=None, + provides_names=[b'python-sphinx'], + provides_versions=[b'9.1.0-1.fc45'], + provides_flags=[8] + ) + + mock_ts = Mock() + mock_ts.hdrFromFdno = Mock(return_value=mock_header) + + with patch('rpm.TransactionSet', return_value=mock_ts), \ + patch('rpm._RPMVSF_NOSIGNATURES', 0), \ + patch('rpm._RPMVSF_NODIGESTS', 0), \ + patch('rpm.RPMTAG_NAME', 1000), \ + patch('rpm.RPMTAG_VERSION', 1001), \ + patch('rpm.RPMTAG_RELEASE', 1002), \ + patch('rpm.RPMTAG_ARCH', 1022), \ + patch('rpm.RPMTAG_EPOCH', 1003), \ + patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_PROVIDENAME', 1047), \ + patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ + patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ + patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open()): + + result = checker.read_rpm_provides(['/tmp/test.src.rpm']) + + assert result['srpm_name'] == 'python-sphinx' + # Source RPM provides should be skipped + assert len(result['provides']) == 0 + assert len(result['rpm_info']) == 0 + + def test_skip_bundled_provides(self, checker): + """Test that bundled provides are filtered out.""" + mock_header = MockRPMHeader( + name='myapp', + version='1.0.0', + release='1.fc45', + arch='noarch', + sourcerpm='myapp-1.0.0-1.fc45.src.rpm', + provides_names=[ + b'myapp', + b'bundled(libfoo)', + b'bundled(libbar)', + ], + provides_versions=[b'1.0.0', b'2.0', b'3.0'], + provides_flags=[8, 8, 8] + ) + + mock_ts = Mock() + mock_ts.hdrFromFdno = Mock(return_value=mock_header) + + with patch('rpm.TransactionSet', return_value=mock_ts), \ + patch('rpm._RPMVSF_NOSIGNATURES', 0), \ + patch('rpm._RPMVSF_NODIGESTS', 0), \ + patch('rpm.RPMTAG_NAME', 1000), \ + patch('rpm.RPMTAG_VERSION', 1001), \ + patch('rpm.RPMTAG_RELEASE', 1002), \ + patch('rpm.RPMTAG_ARCH', 1022), \ + patch('rpm.RPMTAG_EPOCH', 1003), \ + patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_PROVIDENAME', 1047), \ + patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ + patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ + patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open()): + + result = checker.read_rpm_provides(['/tmp/myapp.rpm']) + + assert 'myapp' 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): + """Test that mixing RPMs from different SRPMs raises error.""" + mock_header1 = MockRPMHeader( + name='python3-sphinx', + version='9.1.0', + release='1.fc45', + arch='noarch', + sourcerpm='python-sphinx-9.1.0-1.fc45.src.rpm', + provides_names=[b'python3-sphinx'], + provides_versions=[b'9.1.0'], + provides_flags=[8] + ) + + mock_header2 = MockRPMHeader( + name='python3-requests', + version='2.32.0', + release='1.fc45', + arch='noarch', + sourcerpm='python-requests-2.32.0-1.fc45.src.rpm', + provides_names=[b'python3-requests'], + provides_versions=[b'2.32.0'], + provides_flags=[8] + ) + + mock_ts = Mock() + mock_ts.hdrFromFdno = Mock(side_effect=[mock_header1, mock_header2]) + + with patch('rpm.TransactionSet', return_value=mock_ts), \ + patch('rpm._RPMVSF_NOSIGNATURES', 0), \ + patch('rpm._RPMVSF_NODIGESTS', 0), \ + patch('rpm.RPMTAG_NAME', 1000), \ + patch('rpm.RPMTAG_VERSION', 1001), \ + patch('rpm.RPMTAG_RELEASE', 1002), \ + patch('rpm.RPMTAG_ARCH', 1022), \ + patch('rpm.RPMTAG_EPOCH', 1003), \ + patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_PROVIDENAME', 1047), \ + patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ + patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ + patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open()): + + with pytest.raises(ValueError, match="multiple source packages"): + checker.read_rpm_provides(['/tmp/test1.rpm', '/tmp/test2.rpm']) + + def test_file_not_found_error(self, checker): + """Test error handling for missing RPM file.""" + with pytest.raises(FileNotFoundError, match="RPM file not found"): + checker.read_rpm_provides(['/nonexistent/file.rpm']) + + def test_rpm_without_epoch(self, checker): + """Test RPM file without epoch (epoch=None).""" + mock_header = MockRPMHeader( + name='mypackage', + version='1.0.0', + release='1.fc45', + arch='noarch', + epoch=None, + sourcerpm='mypackage-1.0.0-1.fc45.src.rpm', + provides_names=[b'mypackage'], + provides_versions=[b'1.0.0'], + provides_flags=[8] + ) + + mock_ts = Mock() + mock_ts.hdrFromFdno = Mock(return_value=mock_header) + + with patch('rpm.TransactionSet', return_value=mock_ts), \ + patch('rpm._RPMVSF_NOSIGNATURES', 0), \ + patch('rpm._RPMVSF_NODIGESTS', 0), \ + patch('rpm.RPMTAG_NAME', 1000), \ + patch('rpm.RPMTAG_VERSION', 1001), \ + patch('rpm.RPMTAG_RELEASE', 1002), \ + patch('rpm.RPMTAG_ARCH', 1022), \ + patch('rpm.RPMTAG_EPOCH', 1003), \ + patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_PROVIDENAME', 1047), \ + patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ + patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ + patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', mock_open()): + + result = checker.read_rpm_provides(['/tmp/test.rpm']) + + rpm_info = result['rpm_info']['/tmp/test.rpm'] + assert rpm_info['epoch'] == 0 + assert rpm_info['evr'] == '1.0.0-1.fc45' From 8769bd4f4b6e5ca1105a724c31a67a512756c039 Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Mon, 11 May 2026 08:40:55 +0200 Subject: [PATCH 2/7] Fix SRPM detection to use RPMTAG_SOURCEPACKAGE instead of arch == 'src' Real source RPMs on Fedora report arch='noarch' in their RPM header, not 'src'. Checking arch == 'src' silently skips the detection and causes the SRPM to appear as a binary package in the provides list and verbose output. RPMTAG_SOURCEPACKAGE is the correct tag: it is set to a truthy value for source RPMs regardless of how the arch tag is reported. Update test_skip_source_rpm to reflect real SRPM behaviour (arch='noarch', sourcepackage=1) and add RPMTAG_SOURCEPACKAGE to the rpm patches in both unit and integration tests. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- fedora_revdep_check.py | 7 ++++--- tests/integration/test_rpm_checking.py | 9 ++++++++- tests/unit/test_rpm_reading.py | 19 ++++++++++++++++--- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/fedora_revdep_check.py b/fedora_revdep_check.py index 69cae28..80f282d 100755 --- a/fedora_revdep_check.py +++ b/fedora_revdep_check.py @@ -349,10 +349,11 @@ def read_rpm_provides(self, rpm_files: List[str]) -> Dict: # This is a source RPM srpm_names.add(name) - # Skip source RPMs for provides - if arch == 'src': + # 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}.{arch}") + print(f"Skipping source RPM: {name}-{version}-{release}") continue rpm_info[rpm_file] = { diff --git a/tests/integration/test_rpm_checking.py b/tests/integration/test_rpm_checking.py index 873dcd4..4e3867e 100644 --- a/tests/integration/test_rpm_checking.py +++ b/tests/integration/test_rpm_checking.py @@ -13,7 +13,8 @@ class MockRPMHeader: """Mock RPM header for testing.""" def __init__(self, name, version, release, arch, epoch=None, sourcerpm=None, - provides_names=None, provides_versions=None, provides_flags=None): + sourcepackage=0, provides_names=None, provides_versions=None, + provides_flags=None): self.data = { 1000: name.encode() if isinstance(name, str) else name, 1001: version.encode() if isinstance(version, str) else version, @@ -21,6 +22,7 @@ def __init__(self, name, version, release, arch, epoch=None, sourcerpm=None, 1022: arch.encode() if isinstance(arch, str) else arch, 1003: epoch, 1044: sourcerpm.encode() if sourcerpm and isinstance(sourcerpm, str) else sourcerpm, + 1106: sourcepackage, 1047: provides_names, 1113: provides_flags, 1048: provides_versions, @@ -92,6 +94,7 @@ def test_check_rpm_files_no_conflicts(self): patch('rpm.RPMTAG_ARCH', 1022), \ patch('rpm.RPMTAG_EPOCH', 1003), \ patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ patch('rpm.RPMTAG_PROVIDENAME', 1047), \ patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ @@ -164,6 +167,7 @@ def test_check_rpm_files_with_conflicts(self): patch('rpm.RPMTAG_ARCH', 1022), \ patch('rpm.RPMTAG_EPOCH', 1003), \ patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ patch('rpm.RPMTAG_PROVIDENAME', 1047), \ patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ @@ -242,6 +246,7 @@ def test_check_rpm_files_with_epoch(self): patch('rpm.RPMTAG_ARCH', 1022), \ patch('rpm.RPMTAG_EPOCH', 1003), \ patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ patch('rpm.RPMTAG_PROVIDENAME', 1047), \ patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ @@ -312,6 +317,7 @@ def test_check_rpm_files_skips_same_srpm(self): patch('rpm.RPMTAG_ARCH', 1022), \ patch('rpm.RPMTAG_EPOCH', 1003), \ patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ patch('rpm.RPMTAG_PROVIDENAME', 1047), \ patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ @@ -383,6 +389,7 @@ def test_check_rpm_files_already_broken(self): patch('rpm.RPMTAG_ARCH', 1022), \ patch('rpm.RPMTAG_EPOCH', 1003), \ patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ patch('rpm.RPMTAG_PROVIDENAME', 1047), \ patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ diff --git a/tests/unit/test_rpm_reading.py b/tests/unit/test_rpm_reading.py index bc5c0ae..6e7c910 100644 --- a/tests/unit/test_rpm_reading.py +++ b/tests/unit/test_rpm_reading.py @@ -13,7 +13,8 @@ class MockRPMHeader: """Mock RPM header for testing.""" def __init__(self, name, version, release, arch, epoch=None, sourcerpm=None, - provides_names=None, provides_versions=None, provides_flags=None): + sourcepackage=0, provides_names=None, provides_versions=None, + provides_flags=None): self.data = { 1000: name.encode() if isinstance(name, str) else name, # NAME 1001: version.encode() if isinstance(version, str) else version, # VERSION @@ -21,6 +22,7 @@ def __init__(self, name, version, release, arch, epoch=None, sourcerpm=None, 1022: arch.encode() if isinstance(arch, str) else arch, # ARCH 1003: epoch, # EPOCH 1044: sourcerpm.encode() if sourcerpm and isinstance(sourcerpm, str) else sourcerpm, # SOURCERPM + 1106: sourcepackage, # SOURCEPACKAGE 1047: provides_names, # PROVIDENAME 1113: provides_flags, # PROVIDEFLAGS 1048: provides_versions, # PROVIDEVERSION @@ -73,6 +75,7 @@ def test_read_single_rpm_file(self, checker): patch('rpm.RPMTAG_ARCH', 1022), \ patch('rpm.RPMTAG_EPOCH', 1003), \ patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ patch('rpm.RPMTAG_PROVIDENAME', 1047), \ patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ @@ -132,6 +135,7 @@ def test_read_multiple_rpm_files(self, checker): patch('rpm.RPMTAG_ARCH', 1022), \ patch('rpm.RPMTAG_EPOCH', 1003), \ patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ patch('rpm.RPMTAG_PROVIDENAME', 1047), \ patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ @@ -148,13 +152,18 @@ def test_read_multiple_rpm_files(self, checker): assert len(result['rpm_info']) == 2 def test_skip_source_rpm(self, checker): - """Test that source RPMs are skipped for provides.""" + """Test that source RPMs are skipped for provides. + + Real SRPMs report arch='noarch' (not 'src'), so RPMTAG_SOURCEPACKAGE + is the correct way to identify them. + """ mock_header = MockRPMHeader( name='python-sphinx', version='9.1.0', release='1.fc45', - arch='src', + arch='noarch', # real SRPMs report noarch, not 'src' sourcerpm=None, + sourcepackage=1, # this is what identifies a source RPM provides_names=[b'python-sphinx'], provides_versions=[b'9.1.0-1.fc45'], provides_flags=[8] @@ -172,6 +181,7 @@ def test_skip_source_rpm(self, checker): patch('rpm.RPMTAG_ARCH', 1022), \ patch('rpm.RPMTAG_EPOCH', 1003), \ patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ patch('rpm.RPMTAG_PROVIDENAME', 1047), \ patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ @@ -215,6 +225,7 @@ def test_skip_bundled_provides(self, checker): patch('rpm.RPMTAG_ARCH', 1022), \ patch('rpm.RPMTAG_EPOCH', 1003), \ patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ patch('rpm.RPMTAG_PROVIDENAME', 1047), \ patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ @@ -264,6 +275,7 @@ def test_mixed_source_packages_error(self, checker): patch('rpm.RPMTAG_ARCH', 1022), \ patch('rpm.RPMTAG_EPOCH', 1003), \ patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ patch('rpm.RPMTAG_PROVIDENAME', 1047), \ patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ @@ -305,6 +317,7 @@ def test_rpm_without_epoch(self, checker): patch('rpm.RPMTAG_ARCH', 1022), \ patch('rpm.RPMTAG_EPOCH', 1003), \ patch('rpm.RPMTAG_SOURCERPM', 1044), \ + patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ patch('rpm.RPMTAG_PROVIDENAME', 1047), \ patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ From 3c1dbabae3e55ac9d550fefa1a0b4d3930df0f5c Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Mon, 11 May 2026 08:41:58 +0200 Subject: [PATCH 3/7] Fix >= and <= operator handling for RPM provides flags The previous code checked RPMSENSE_EQUAL first, which is a bitmask that is also set when the combined flag is >= (EQUAL|GREATER) or <= (EQUAL|LESS). This caused >= and <= constraints to be emitted as plain = in the provides string, silently misrepresenting the dependency. Check the combined EQUAL+GREATER and EQUAL+LESS cases before the individual EQUAL case so the correct operator string is produced. Add RPMSENSE_GREATER and RPMSENSE_LESS to the rpm patches in both unit and integration tests so the constants are well-defined during testing. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- fedora_revdep_check.py | 16 ++++++++++------ tests/integration/test_rpm_checking.py | 10 ++++++++++ tests/unit/test_rpm_reading.py | 12 ++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/fedora_revdep_check.py b/fedora_revdep_check.py index 80f282d..e106614 100755 --- a/fedora_revdep_check.py +++ b/fedora_revdep_check.py @@ -388,17 +388,21 @@ def read_rpm_provides(self, rpm_files: List[str]) -> Dict: if provides_versions and i < len(provides_versions) and provides_versions[i]: prov_version = provides_versions[i].decode() if isinstance(provides_versions[i], bytes) else provides_versions[i] - # Get the operator if flags are set if provides_flags and i < len(provides_flags): flags = provides_flags[i] - if flags & rpm.RPMSENSE_EQUAL: + 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 flags & rpm.RPMSENSE_GREATER: + elif gt: full_provide = f"{prov_name} > {prov_version}" - elif flags & rpm.RPMSENSE_LESS: + elif lt: full_provide = f"{prov_name} < {prov_version}" - else: - full_provide = f"{prov_name} = {prov_version}" provides_map[prov_name].append((rpm_file, prov_version, full_provide)) diff --git a/tests/integration/test_rpm_checking.py b/tests/integration/test_rpm_checking.py index 4e3867e..e1938c1 100644 --- a/tests/integration/test_rpm_checking.py +++ b/tests/integration/test_rpm_checking.py @@ -99,6 +99,8 @@ def test_check_rpm_files_no_conflicts(self): patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('rpm.RPMSENSE_GREATER', 4), \ + patch('rpm.RPMSENSE_LESS', 2), \ patch('os.path.exists', return_value=True), \ patch('builtins.open', mock_open()): @@ -172,6 +174,8 @@ def test_check_rpm_files_with_conflicts(self): patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('rpm.RPMSENSE_GREATER', 4), \ + patch('rpm.RPMSENSE_LESS', 2), \ patch('os.path.exists', return_value=True), \ patch('builtins.open', mock_open()): @@ -251,6 +255,8 @@ def test_check_rpm_files_with_epoch(self): patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('rpm.RPMSENSE_GREATER', 4), \ + patch('rpm.RPMSENSE_LESS', 2), \ patch('os.path.exists', return_value=True), \ patch('builtins.open', mock_open()): @@ -322,6 +328,8 @@ def test_check_rpm_files_skips_same_srpm(self): patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('rpm.RPMSENSE_GREATER', 4), \ + patch('rpm.RPMSENSE_LESS', 2), \ patch('os.path.exists', return_value=True), \ patch('builtins.open', mock_open()): @@ -394,6 +402,8 @@ def test_check_rpm_files_already_broken(self): patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('rpm.RPMSENSE_GREATER', 4), \ + patch('rpm.RPMSENSE_LESS', 2), \ patch('os.path.exists', return_value=True), \ patch('builtins.open', mock_open()): diff --git a/tests/unit/test_rpm_reading.py b/tests/unit/test_rpm_reading.py index 6e7c910..ea637f8 100644 --- a/tests/unit/test_rpm_reading.py +++ b/tests/unit/test_rpm_reading.py @@ -80,6 +80,8 @@ def test_read_single_rpm_file(self, checker): patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('rpm.RPMSENSE_GREATER', 4), \ + patch('rpm.RPMSENSE_LESS', 2), \ patch('os.path.exists', return_value=True), \ patch('builtins.open', mock_open()): @@ -140,6 +142,8 @@ def test_read_multiple_rpm_files(self, checker): patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('rpm.RPMSENSE_GREATER', 4), \ + patch('rpm.RPMSENSE_LESS', 2), \ patch('os.path.exists', return_value=True), \ patch('builtins.open', mock_open()): @@ -186,6 +190,8 @@ def test_skip_source_rpm(self, checker): patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('rpm.RPMSENSE_GREATER', 4), \ + patch('rpm.RPMSENSE_LESS', 2), \ patch('os.path.exists', return_value=True), \ patch('builtins.open', mock_open()): @@ -230,6 +236,8 @@ def test_skip_bundled_provides(self, checker): patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('rpm.RPMSENSE_GREATER', 4), \ + patch('rpm.RPMSENSE_LESS', 2), \ patch('os.path.exists', return_value=True), \ patch('builtins.open', mock_open()): @@ -280,6 +288,8 @@ def test_mixed_source_packages_error(self, checker): patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('rpm.RPMSENSE_GREATER', 4), \ + patch('rpm.RPMSENSE_LESS', 2), \ patch('os.path.exists', return_value=True), \ patch('builtins.open', mock_open()): @@ -322,6 +332,8 @@ def test_rpm_without_epoch(self, checker): patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ patch('rpm.RPMSENSE_EQUAL', 8), \ + patch('rpm.RPMSENSE_GREATER', 4), \ + patch('rpm.RPMSENSE_LESS', 2), \ patch('os.path.exists', return_value=True), \ patch('builtins.open', mock_open()): From 33a51d381fed4533bff9af2827039a1afbe9551e Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Mon, 11 May 2026 08:48:26 +0200 Subject: [PATCH 4/7] Simplify read_rpm_provides: remove bytes/str decode and fix verbose output The rpm Python bindings on Fedora (rpm >= 4.14) return str for header string fields, not bytes. The isinstance/decode guards on every field were dead code that added noise and made the mocks unnecessarily complex. Remove all .decode() / isinstance(..., bytes) calls and update MockRPMHeader and test data accordingly (no more b'...' literals in test fixtures). Compute evr once before rpm_info is populated and reuse it for the verbose "Reading ..." line, so the output omits the epoch when it is zero (matching the evr format already used in the rest of the output). Also use '7.1' instead of '7.1.0' in the provides version of the first integration test, which is more representative of what rpm actually reports for a package whose upstream version is '7.1'. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- fedora_revdep_check.py | 21 ++++----- tests/integration/test_rpm_checking.py | 53 +++++++++++------------ tests/unit/test_rpm_reading.py | 60 +++++++++++++------------- 3 files changed, 64 insertions(+), 70 deletions(-) diff --git a/fedora_revdep_check.py b/fedora_revdep_check.py index e106614..90c8e6b 100755 --- a/fedora_revdep_check.py +++ b/fedora_revdep_check.py @@ -331,22 +331,18 @@ def read_rpm_provides(self, rpm_files: List[str]) -> Dict: with open(rpm_file, 'rb') as f: hdr = ts.hdrFromFdno(f.fileno()) - name = hdr[rpm.RPMTAG_NAME].decode() if isinstance(hdr[rpm.RPMTAG_NAME], bytes) else hdr[rpm.RPMTAG_NAME] + 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].decode() if isinstance(hdr[rpm.RPMTAG_VERSION], bytes) else hdr[rpm.RPMTAG_VERSION] - release = hdr[rpm.RPMTAG_RELEASE].decode() if isinstance(hdr[rpm.RPMTAG_RELEASE], bytes) else hdr[rpm.RPMTAG_RELEASE] - arch = hdr[rpm.RPMTAG_ARCH].decode() if isinstance(hdr[rpm.RPMTAG_ARCH], bytes) else hdr[rpm.RPMTAG_ARCH] + version = hdr[rpm.RPMTAG_VERSION] + release = hdr[rpm.RPMTAG_RELEASE] + arch = hdr[rpm.RPMTAG_ARCH] sourcerpm = hdr[rpm.RPMTAG_SOURCERPM] - if sourcerpm: - sourcerpm = sourcerpm.decode() if isinstance(sourcerpm, bytes) else sourcerpm # Extract SRPM name from SOURCERPM tag if sourcerpm: - # Format is typically: name-version-release.src.rpm srpm_name = sourcerpm.rsplit('-', 2)[0] srpm_names.add(srpm_name) else: - # This is a source RPM srpm_names.add(name) # Skip source RPMs for provides (RPMTAG_SOURCEPACKAGE is set for SRPMs @@ -356,17 +352,18 @@ def read_rpm_provides(self, rpm_files: List[str]) -> Dict: 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': f"{epoch}:{version}-{release}" if epoch else f"{version}-{release}" + 'evr': evr, } if self.verbose: - print(f"Reading {name}-{epoch}:{version}-{release}.{arch}") + print(f"Reading {name}-{evr}.{arch}") # Extract provides provides_names = hdr[rpm.RPMTAG_PROVIDENAME] @@ -375,7 +372,6 @@ def read_rpm_provides(self, rpm_files: List[str]) -> Dict: if provides_names: for i, prov_name in enumerate(provides_names): - prov_name = prov_name.decode() if isinstance(prov_name, bytes) else prov_name # Skip bundled provides if prov_name.startswith('bundled('): @@ -384,9 +380,8 @@ def read_rpm_provides(self, rpm_files: List[str]) -> Dict: prov_version = None full_provide = prov_name - # Check if there's a version if provides_versions and i < len(provides_versions) and provides_versions[i]: - prov_version = provides_versions[i].decode() if isinstance(provides_versions[i], bytes) else provides_versions[i] + prov_version = provides_versions[i] if provides_flags and i < len(provides_flags): flags = provides_flags[i] diff --git a/tests/integration/test_rpm_checking.py b/tests/integration/test_rpm_checking.py index e1938c1..da01197 100644 --- a/tests/integration/test_rpm_checking.py +++ b/tests/integration/test_rpm_checking.py @@ -16,12 +16,12 @@ def __init__(self, name, version, release, arch, epoch=None, sourcerpm=None, sourcepackage=0, provides_names=None, provides_versions=None, provides_flags=None): self.data = { - 1000: name.encode() if isinstance(name, str) else name, - 1001: version.encode() if isinstance(version, str) else version, - 1002: release.encode() if isinstance(release, str) else release, - 1022: arch.encode() if isinstance(arch, str) else arch, + 1000: name, + 1001: version, + 1002: release, + 1022: arch, 1003: epoch, - 1044: sourcerpm.encode() if sourcerpm and isinstance(sourcerpm, str) else sourcerpm, + 1044: sourcerpm, 1106: sourcepackage, 1047: provides_names, 1113: provides_flags, @@ -64,20 +64,19 @@ def test_check_rpm_files_no_conflicts(self): base = MockBase(packages=packages) checker = FedoraRevDepChecker(verbose=False, base=base) - # Mock RPM for pytest 7.1.0 mock_header = MockRPMHeader( name='python3-pytest', - version='7.1.0', + version='7.1', release='1.fc45', arch='noarch', - sourcerpm='pytest-7.1.0-1.fc45.src.rpm', + sourcerpm='pytest-7.1-1.fc45.src.rpm', provides_names=[ - b'python3-pytest', - b'python3dist(pytest)', + 'python3-pytest', + 'python3dist(pytest)', ], provides_versions=[ - b'7.1.0-1.fc45', - b'7.1.0', + '7.1-1.fc45', + '7.1', ], provides_flags=[8, 8] ) @@ -107,7 +106,7 @@ def test_check_rpm_files_no_conflicts(self): result = checker.check_rpm_files(['/tmp/pytest.rpm']) assert result['srpm_name'] == 'pytest' - assert result['new_version'] == '7.1.0-1.fc45' + assert result['new_version'] == '7.1-1.fc45' assert len(result['conflicts']) == 0 def test_check_rpm_files_with_conflicts(self): @@ -147,12 +146,12 @@ def test_check_rpm_files_with_conflicts(self): arch='noarch', sourcerpm='jupyterlab-4.7.0-1.fc45.src.rpm', provides_names=[ - b'python3-jupyterlab', - b'python3dist(jupyterlab)', + 'python3-jupyterlab', + 'python3dist(jupyterlab)', ], provides_versions=[ - b'4.7.0-1.fc45', - b'4.7.0', + '4.7.0-1.fc45', + '4.7.0', ], provides_flags=[8, 8] ) @@ -228,12 +227,12 @@ def test_check_rpm_files_with_epoch(self): epoch=1, sourcerpm='python-sphinx-9.1.0-1.fc45.src.rpm', provides_names=[ - b'python3-sphinx', - b'python3dist(sphinx)', + 'python3-sphinx', + 'python3dist(sphinx)', ], provides_versions=[ - b'9.1.0-1.fc45', - b'9.1.0', + '9.1.0-1.fc45', + '9.1.0', ], provides_flags=[8, 8] ) @@ -303,10 +302,10 @@ def test_check_rpm_files_skips_same_srpm(self): arch='noarch', sourcerpm='micropipenv-1.11.0-1.fc45.src.rpm', provides_names=[ - b'micropipenv', + 'micropipenv', ], provides_versions=[ - b'1.11.0-1.fc45', + '1.11.0-1.fc45', ], provides_flags=[8] ) @@ -375,12 +374,12 @@ def test_check_rpm_files_already_broken(self): arch='noarch', sourcerpm='library-5.0.0-1.fc45.src.rpm', provides_names=[ - b'library', - b'python3dist(library)', + 'library', + 'python3dist(library)', ], provides_versions=[ - b'5.0.0-1.fc45', - b'5.0.0', + '5.0.0-1.fc45', + '5.0.0', ], provides_flags=[8, 8] ) diff --git a/tests/unit/test_rpm_reading.py b/tests/unit/test_rpm_reading.py index ea637f8..1810fed 100644 --- a/tests/unit/test_rpm_reading.py +++ b/tests/unit/test_rpm_reading.py @@ -16,16 +16,16 @@ def __init__(self, name, version, release, arch, epoch=None, sourcerpm=None, sourcepackage=0, provides_names=None, provides_versions=None, provides_flags=None): self.data = { - 1000: name.encode() if isinstance(name, str) else name, # NAME - 1001: version.encode() if isinstance(version, str) else version, # VERSION - 1002: release.encode() if isinstance(release, str) else release, # RELEASE - 1022: arch.encode() if isinstance(arch, str) else arch, # ARCH - 1003: epoch, # EPOCH - 1044: sourcerpm.encode() if sourcerpm and isinstance(sourcerpm, str) else sourcerpm, # SOURCERPM - 1106: sourcepackage, # SOURCEPACKAGE - 1047: provides_names, # PROVIDENAME - 1113: provides_flags, # PROVIDEFLAGS - 1048: provides_versions, # PROVIDEVERSION + 1000: name, + 1001: version, + 1002: release, + 1022: arch, + 1003: epoch, + 1044: sourcerpm, + 1106: sourcepackage, + 1047: provides_names, + 1113: provides_flags, + 1048: provides_versions, } def __getitem__(self, key): @@ -50,12 +50,12 @@ def test_read_single_rpm_file(self, checker): epoch=1, sourcerpm='python-sphinx-9.1.0-1.fc45.src.rpm', provides_names=[ - b'python3-sphinx', - b'python3dist(sphinx)', + 'python3-sphinx', + 'python3dist(sphinx)', ], provides_versions=[ - b'9.1.0-1.fc45', - b'9.1.0', + '9.1.0-1.fc45', + '9.1.0', ], provides_flags=[ 8, # RPMSENSE_EQUAL @@ -108,8 +108,8 @@ def test_read_multiple_rpm_files(self, checker): arch='noarch', epoch=1, sourcerpm='python-sphinx-9.1.0-1.fc45.src.rpm', - provides_names=[b'python3-sphinx', b'python3dist(sphinx)'], - provides_versions=[b'9.1.0-1.fc45', b'9.1.0'], + provides_names=['python3-sphinx', 'python3dist(sphinx)'], + provides_versions=['9.1.0-1.fc45', '9.1.0'], provides_flags=[8, 8] ) @@ -120,8 +120,8 @@ def test_read_multiple_rpm_files(self, checker): arch='noarch', epoch=1, sourcerpm='python-sphinx-9.1.0-1.fc45.src.rpm', - provides_names=[b'python3-sphinx-latex'], - provides_versions=[b'9.1.0-1.fc45'], + provides_names=['python3-sphinx-latex'], + provides_versions=['9.1.0-1.fc45'], provides_flags=[8] ) @@ -168,8 +168,8 @@ def test_skip_source_rpm(self, checker): arch='noarch', # real SRPMs report noarch, not 'src' sourcerpm=None, sourcepackage=1, # this is what identifies a source RPM - provides_names=[b'python-sphinx'], - provides_versions=[b'9.1.0-1.fc45'], + provides_names=['python-sphinx'], + provides_versions=['9.1.0-1.fc45'], provides_flags=[8] ) @@ -211,11 +211,11 @@ def test_skip_bundled_provides(self, checker): arch='noarch', sourcerpm='myapp-1.0.0-1.fc45.src.rpm', provides_names=[ - b'myapp', - b'bundled(libfoo)', - b'bundled(libbar)', + 'myapp', + 'bundled(libfoo)', + 'bundled(libbar)', ], - provides_versions=[b'1.0.0', b'2.0', b'3.0'], + provides_versions=['1.0.0', '2.0', '3.0'], provides_flags=[8, 8, 8] ) @@ -255,8 +255,8 @@ def test_mixed_source_packages_error(self, checker): release='1.fc45', arch='noarch', sourcerpm='python-sphinx-9.1.0-1.fc45.src.rpm', - provides_names=[b'python3-sphinx'], - provides_versions=[b'9.1.0'], + provides_names=['python3-sphinx'], + provides_versions=['9.1.0'], provides_flags=[8] ) @@ -266,8 +266,8 @@ def test_mixed_source_packages_error(self, checker): release='1.fc45', arch='noarch', sourcerpm='python-requests-2.32.0-1.fc45.src.rpm', - provides_names=[b'python3-requests'], - provides_versions=[b'2.32.0'], + provides_names=['python3-requests'], + provides_versions=['2.32.0'], provides_flags=[8] ) @@ -310,8 +310,8 @@ def test_rpm_without_epoch(self, checker): arch='noarch', epoch=None, sourcerpm='mypackage-1.0.0-1.fc45.src.rpm', - provides_names=[b'mypackage'], - provides_versions=[b'1.0.0'], + provides_names=['mypackage'], + provides_versions=['1.0.0'], provides_flags=[8] ) From f2079c21d3758024ae1376297c2dbd227b04026e Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Mon, 11 May 2026 08:50:09 +0200 Subject: [PATCH 5/7] Extract rpm_patches helper to reduce repetition in tests The 18-line patch block for rpm constants was duplicated across every test method in both the unit and integration test files. Extract it into an rpm_patches(mock_ts) context manager and switch all call sites to use it. Use parentheses instead of backslashes to wrap the multi-patch block inside the helper (PEP 8 preferred style). Also fix the unit test module docstring which incorrectly said "actual RPM files" when it always uses mocks. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- tests/integration/test_rpm_checking.py | 120 ++++++--------------- tests/unit/test_rpm_reading.py | 141 ++++++------------------- 2 files changed, 62 insertions(+), 199 deletions(-) diff --git a/tests/integration/test_rpm_checking.py b/tests/integration/test_rpm_checking.py index da01197..12c2e7f 100644 --- a/tests/integration/test_rpm_checking.py +++ b/tests/integration/test_rpm_checking.py @@ -4,6 +4,7 @@ Tests the complete workflow of checking RPM files for reverse dependency conflicts. """ +from contextlib import contextmanager from unittest.mock import Mock, mock_open, patch from fedora_revdep_check import FedoraRevDepChecker from tests.fixtures.mock_packages import MockPackage, MockBase @@ -32,6 +33,30 @@ def __getitem__(self, key): return self.data.get(key) +@contextmanager +def rpm_patches(mock_ts): + """Context manager providing all rpm module patches needed for tests.""" + with (patch('rpm.TransactionSet', return_value=mock_ts), + patch('rpm._RPMVSF_NOSIGNATURES', 0), + patch('rpm._RPMVSF_NODIGESTS', 0), + patch('rpm.RPMTAG_NAME', 1000), + patch('rpm.RPMTAG_VERSION', 1001), + patch('rpm.RPMTAG_RELEASE', 1002), + patch('rpm.RPMTAG_ARCH', 1022), + patch('rpm.RPMTAG_EPOCH', 1003), + patch('rpm.RPMTAG_SOURCERPM', 1044), + patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), + patch('rpm.RPMTAG_PROVIDENAME', 1047), + patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), + patch('rpm.RPMTAG_PROVIDEVERSION', 1048), + patch('rpm.RPMSENSE_EQUAL', 8), + patch('rpm.RPMSENSE_GREATER', 4), + patch('rpm.RPMSENSE_LESS', 2), + patch('os.path.exists', return_value=True), + patch('builtins.open', mock_open())): + yield + + class TestCheckRPMFiles: """Test check_rpm_files() integration.""" @@ -84,24 +109,7 @@ def test_check_rpm_files_no_conflicts(self): mock_ts = Mock() mock_ts.hdrFromFdno = Mock(return_value=mock_header) - with patch('rpm.TransactionSet', return_value=mock_ts), \ - patch('rpm._RPMVSF_NOSIGNATURES', 0), \ - patch('rpm._RPMVSF_NODIGESTS', 0), \ - patch('rpm.RPMTAG_NAME', 1000), \ - patch('rpm.RPMTAG_VERSION', 1001), \ - patch('rpm.RPMTAG_RELEASE', 1002), \ - patch('rpm.RPMTAG_ARCH', 1022), \ - patch('rpm.RPMTAG_EPOCH', 1003), \ - patch('rpm.RPMTAG_SOURCERPM', 1044), \ - patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ - patch('rpm.RPMTAG_PROVIDENAME', 1047), \ - patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ - patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ - patch('rpm.RPMSENSE_EQUAL', 8), \ - patch('rpm.RPMSENSE_GREATER', 4), \ - patch('rpm.RPMSENSE_LESS', 2), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.open', mock_open()): + with rpm_patches(mock_ts): result = checker.check_rpm_files(['/tmp/pytest.rpm']) @@ -159,24 +167,7 @@ def test_check_rpm_files_with_conflicts(self): mock_ts = Mock() mock_ts.hdrFromFdno = Mock(return_value=mock_header) - with patch('rpm.TransactionSet', return_value=mock_ts), \ - patch('rpm._RPMVSF_NOSIGNATURES', 0), \ - patch('rpm._RPMVSF_NODIGESTS', 0), \ - patch('rpm.RPMTAG_NAME', 1000), \ - patch('rpm.RPMTAG_VERSION', 1001), \ - patch('rpm.RPMTAG_RELEASE', 1002), \ - patch('rpm.RPMTAG_ARCH', 1022), \ - patch('rpm.RPMTAG_EPOCH', 1003), \ - patch('rpm.RPMTAG_SOURCERPM', 1044), \ - patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ - patch('rpm.RPMTAG_PROVIDENAME', 1047), \ - patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ - patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ - patch('rpm.RPMSENSE_EQUAL', 8), \ - patch('rpm.RPMSENSE_GREATER', 4), \ - patch('rpm.RPMSENSE_LESS', 2), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.open', mock_open()): + with rpm_patches(mock_ts): result = checker.check_rpm_files(['/tmp/jupyterlab.rpm']) @@ -240,24 +231,7 @@ def test_check_rpm_files_with_epoch(self): mock_ts = Mock() mock_ts.hdrFromFdno = Mock(return_value=mock_header) - with patch('rpm.TransactionSet', return_value=mock_ts), \ - patch('rpm._RPMVSF_NOSIGNATURES', 0), \ - patch('rpm._RPMVSF_NODIGESTS', 0), \ - patch('rpm.RPMTAG_NAME', 1000), \ - patch('rpm.RPMTAG_VERSION', 1001), \ - patch('rpm.RPMTAG_RELEASE', 1002), \ - patch('rpm.RPMTAG_ARCH', 1022), \ - patch('rpm.RPMTAG_EPOCH', 1003), \ - patch('rpm.RPMTAG_SOURCERPM', 1044), \ - patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ - patch('rpm.RPMTAG_PROVIDENAME', 1047), \ - patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ - patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ - patch('rpm.RPMSENSE_EQUAL', 8), \ - patch('rpm.RPMSENSE_GREATER', 4), \ - patch('rpm.RPMSENSE_LESS', 2), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.open', mock_open()): + with rpm_patches(mock_ts): result = checker.check_rpm_files(['/tmp/sphinx.rpm']) @@ -313,24 +287,7 @@ def test_check_rpm_files_skips_same_srpm(self): mock_ts = Mock() mock_ts.hdrFromFdno = Mock(return_value=mock_header) - with patch('rpm.TransactionSet', return_value=mock_ts), \ - patch('rpm._RPMVSF_NOSIGNATURES', 0), \ - patch('rpm._RPMVSF_NODIGESTS', 0), \ - patch('rpm.RPMTAG_NAME', 1000), \ - patch('rpm.RPMTAG_VERSION', 1001), \ - patch('rpm.RPMTAG_RELEASE', 1002), \ - patch('rpm.RPMTAG_ARCH', 1022), \ - patch('rpm.RPMTAG_EPOCH', 1003), \ - patch('rpm.RPMTAG_SOURCERPM', 1044), \ - patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ - patch('rpm.RPMTAG_PROVIDENAME', 1047), \ - patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ - patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ - patch('rpm.RPMSENSE_EQUAL', 8), \ - patch('rpm.RPMSENSE_GREATER', 4), \ - patch('rpm.RPMSENSE_LESS', 2), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.open', mock_open()): + with rpm_patches(mock_ts): result = checker.check_rpm_files(['/tmp/micropipenv.rpm']) @@ -387,24 +344,7 @@ def test_check_rpm_files_already_broken(self): mock_ts = Mock() mock_ts.hdrFromFdno = Mock(return_value=mock_header) - with patch('rpm.TransactionSet', return_value=mock_ts), \ - patch('rpm._RPMVSF_NOSIGNATURES', 0), \ - patch('rpm._RPMVSF_NODIGESTS', 0), \ - patch('rpm.RPMTAG_NAME', 1000), \ - patch('rpm.RPMTAG_VERSION', 1001), \ - patch('rpm.RPMTAG_RELEASE', 1002), \ - patch('rpm.RPMTAG_ARCH', 1022), \ - patch('rpm.RPMTAG_EPOCH', 1003), \ - patch('rpm.RPMTAG_SOURCERPM', 1044), \ - patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ - patch('rpm.RPMTAG_PROVIDENAME', 1047), \ - patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ - patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ - patch('rpm.RPMSENSE_EQUAL', 8), \ - patch('rpm.RPMSENSE_GREATER', 4), \ - patch('rpm.RPMSENSE_LESS', 2), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.open', mock_open()): + with rpm_patches(mock_ts): result = checker.check_rpm_files(['/tmp/library.rpm']) diff --git a/tests/unit/test_rpm_reading.py b/tests/unit/test_rpm_reading.py index 1810fed..56c0fa0 100644 --- a/tests/unit/test_rpm_reading.py +++ b/tests/unit/test_rpm_reading.py @@ -1,10 +1,11 @@ """ Unit tests for RPM file reading functionality. -Tests the read_rpm_provides() method which reads provides from actual RPM files. +Tests the read_rpm_provides() method using mocked RPM headers (no real RPM files). """ import pytest +from contextlib import contextmanager from unittest.mock import Mock, mock_open, patch from fedora_revdep_check import FedoraRevDepChecker @@ -32,6 +33,30 @@ def __getitem__(self, key): return self.data.get(key) +@contextmanager +def rpm_patches(mock_ts): + """Context manager providing all rpm module patches needed for tests.""" + with (patch('rpm.TransactionSet', return_value=mock_ts), + patch('rpm._RPMVSF_NOSIGNATURES', 0), + patch('rpm._RPMVSF_NODIGESTS', 0), + patch('rpm.RPMTAG_NAME', 1000), + patch('rpm.RPMTAG_VERSION', 1001), + patch('rpm.RPMTAG_RELEASE', 1002), + patch('rpm.RPMTAG_ARCH', 1022), + patch('rpm.RPMTAG_EPOCH', 1003), + patch('rpm.RPMTAG_SOURCERPM', 1044), + patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), + patch('rpm.RPMTAG_PROVIDENAME', 1047), + patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), + patch('rpm.RPMTAG_PROVIDEVERSION', 1048), + patch('rpm.RPMSENSE_EQUAL', 8), + patch('rpm.RPMSENSE_GREATER', 4), + patch('rpm.RPMSENSE_LESS', 2), + patch('os.path.exists', return_value=True), + patch('builtins.open', mock_open())): + yield + + class TestReadRPMProvides: """Test read_rpm_provides() method.""" @@ -66,24 +91,7 @@ def test_read_single_rpm_file(self, checker): mock_ts = Mock() mock_ts.hdrFromFdno = Mock(return_value=mock_header) - with patch('rpm.TransactionSet', return_value=mock_ts), \ - patch('rpm._RPMVSF_NOSIGNATURES', 0), \ - patch('rpm._RPMVSF_NODIGESTS', 0), \ - patch('rpm.RPMTAG_NAME', 1000), \ - patch('rpm.RPMTAG_VERSION', 1001), \ - patch('rpm.RPMTAG_RELEASE', 1002), \ - patch('rpm.RPMTAG_ARCH', 1022), \ - patch('rpm.RPMTAG_EPOCH', 1003), \ - patch('rpm.RPMTAG_SOURCERPM', 1044), \ - patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ - patch('rpm.RPMTAG_PROVIDENAME', 1047), \ - patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ - patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ - patch('rpm.RPMSENSE_EQUAL', 8), \ - patch('rpm.RPMSENSE_GREATER', 4), \ - patch('rpm.RPMSENSE_LESS', 2), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.open', mock_open()): + with rpm_patches(mock_ts): result = checker.read_rpm_provides(['/tmp/test.rpm']) @@ -128,24 +136,7 @@ def test_read_multiple_rpm_files(self, checker): mock_ts = Mock() mock_ts.hdrFromFdno = Mock(side_effect=[mock_header1, mock_header2]) - with patch('rpm.TransactionSet', return_value=mock_ts), \ - patch('rpm._RPMVSF_NOSIGNATURES', 0), \ - patch('rpm._RPMVSF_NODIGESTS', 0), \ - patch('rpm.RPMTAG_NAME', 1000), \ - patch('rpm.RPMTAG_VERSION', 1001), \ - patch('rpm.RPMTAG_RELEASE', 1002), \ - patch('rpm.RPMTAG_ARCH', 1022), \ - patch('rpm.RPMTAG_EPOCH', 1003), \ - patch('rpm.RPMTAG_SOURCERPM', 1044), \ - patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ - patch('rpm.RPMTAG_PROVIDENAME', 1047), \ - patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ - patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ - patch('rpm.RPMSENSE_EQUAL', 8), \ - patch('rpm.RPMSENSE_GREATER', 4), \ - patch('rpm.RPMSENSE_LESS', 2), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.open', mock_open()): + with rpm_patches(mock_ts): result = checker.read_rpm_provides(['/tmp/test1.rpm', '/tmp/test2.rpm']) @@ -176,24 +167,7 @@ def test_skip_source_rpm(self, checker): mock_ts = Mock() mock_ts.hdrFromFdno = Mock(return_value=mock_header) - with patch('rpm.TransactionSet', return_value=mock_ts), \ - patch('rpm._RPMVSF_NOSIGNATURES', 0), \ - patch('rpm._RPMVSF_NODIGESTS', 0), \ - patch('rpm.RPMTAG_NAME', 1000), \ - patch('rpm.RPMTAG_VERSION', 1001), \ - patch('rpm.RPMTAG_RELEASE', 1002), \ - patch('rpm.RPMTAG_ARCH', 1022), \ - patch('rpm.RPMTAG_EPOCH', 1003), \ - patch('rpm.RPMTAG_SOURCERPM', 1044), \ - patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ - patch('rpm.RPMTAG_PROVIDENAME', 1047), \ - patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ - patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ - patch('rpm.RPMSENSE_EQUAL', 8), \ - patch('rpm.RPMSENSE_GREATER', 4), \ - patch('rpm.RPMSENSE_LESS', 2), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.open', mock_open()): + with rpm_patches(mock_ts): result = checker.read_rpm_provides(['/tmp/test.src.rpm']) @@ -222,24 +196,7 @@ def test_skip_bundled_provides(self, checker): mock_ts = Mock() mock_ts.hdrFromFdno = Mock(return_value=mock_header) - with patch('rpm.TransactionSet', return_value=mock_ts), \ - patch('rpm._RPMVSF_NOSIGNATURES', 0), \ - patch('rpm._RPMVSF_NODIGESTS', 0), \ - patch('rpm.RPMTAG_NAME', 1000), \ - patch('rpm.RPMTAG_VERSION', 1001), \ - patch('rpm.RPMTAG_RELEASE', 1002), \ - patch('rpm.RPMTAG_ARCH', 1022), \ - patch('rpm.RPMTAG_EPOCH', 1003), \ - patch('rpm.RPMTAG_SOURCERPM', 1044), \ - patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ - patch('rpm.RPMTAG_PROVIDENAME', 1047), \ - patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ - patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ - patch('rpm.RPMSENSE_EQUAL', 8), \ - patch('rpm.RPMSENSE_GREATER', 4), \ - patch('rpm.RPMSENSE_LESS', 2), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.open', mock_open()): + with rpm_patches(mock_ts): result = checker.read_rpm_provides(['/tmp/myapp.rpm']) @@ -274,24 +231,7 @@ def test_mixed_source_packages_error(self, checker): mock_ts = Mock() mock_ts.hdrFromFdno = Mock(side_effect=[mock_header1, mock_header2]) - with patch('rpm.TransactionSet', return_value=mock_ts), \ - patch('rpm._RPMVSF_NOSIGNATURES', 0), \ - patch('rpm._RPMVSF_NODIGESTS', 0), \ - patch('rpm.RPMTAG_NAME', 1000), \ - patch('rpm.RPMTAG_VERSION', 1001), \ - patch('rpm.RPMTAG_RELEASE', 1002), \ - patch('rpm.RPMTAG_ARCH', 1022), \ - patch('rpm.RPMTAG_EPOCH', 1003), \ - patch('rpm.RPMTAG_SOURCERPM', 1044), \ - patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ - patch('rpm.RPMTAG_PROVIDENAME', 1047), \ - patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ - patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ - patch('rpm.RPMSENSE_EQUAL', 8), \ - patch('rpm.RPMSENSE_GREATER', 4), \ - patch('rpm.RPMSENSE_LESS', 2), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.open', mock_open()): + with rpm_patches(mock_ts): with pytest.raises(ValueError, match="multiple source packages"): checker.read_rpm_provides(['/tmp/test1.rpm', '/tmp/test2.rpm']) @@ -318,24 +258,7 @@ def test_rpm_without_epoch(self, checker): mock_ts = Mock() mock_ts.hdrFromFdno = Mock(return_value=mock_header) - with patch('rpm.TransactionSet', return_value=mock_ts), \ - patch('rpm._RPMVSF_NOSIGNATURES', 0), \ - patch('rpm._RPMVSF_NODIGESTS', 0), \ - patch('rpm.RPMTAG_NAME', 1000), \ - patch('rpm.RPMTAG_VERSION', 1001), \ - patch('rpm.RPMTAG_RELEASE', 1002), \ - patch('rpm.RPMTAG_ARCH', 1022), \ - patch('rpm.RPMTAG_EPOCH', 1003), \ - patch('rpm.RPMTAG_SOURCERPM', 1044), \ - patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), \ - patch('rpm.RPMTAG_PROVIDENAME', 1047), \ - patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), \ - patch('rpm.RPMTAG_PROVIDEVERSION', 1048), \ - patch('rpm.RPMSENSE_EQUAL', 8), \ - patch('rpm.RPMSENSE_GREATER', 4), \ - patch('rpm.RPMSENSE_LESS', 2), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.open', mock_open()): + with rpm_patches(mock_ts): result = checker.read_rpm_provides(['/tmp/test.rpm']) From 83ec79f29df279a1620a7d77839a3791a48cc08b Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Mon, 11 May 2026 08:51:37 +0200 Subject: [PATCH 6/7] Improve e2e tests: use tmp_path and pytest.raises The two --rpm-dir tests patched os.path.isdir and glob.glob directly, coupling the tests to implementation internals (the fact that the code uses os.path.isdir rather than pathlib, for example). Replace the mocked filesystem calls with real filesystem state: - test_cli_rpm_dir_not_found: pass tmp_path / 'nonexistent', which is a genuinely non-existent directory with no patching needed. - test_cli_rpm_dir_no_rpms: pass the empty tmp_path directly. Replace the try/except SystemExit pattern with pytest.raises(SystemExit) in all three tests, which is the idiomatic pytest style. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- tests/e2e/test_cli_rpm_mode.py | 48 +++++++++++++--------------------- 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/tests/e2e/test_cli_rpm_mode.py b/tests/e2e/test_cli_rpm_mode.py index 1198714..1b5473c 100644 --- a/tests/e2e/test_cli_rpm_mode.py +++ b/tests/e2e/test_cli_rpm_mode.py @@ -4,6 +4,7 @@ Tests the command-line interface with --rpms and --rpm-dir options. """ +import pytest from unittest.mock import patch from fedora_revdep_check import main @@ -11,50 +12,37 @@ class TestCLIRPMMode: """Test CLI with --rpms option.""" - def test_cli_rpm_dir_not_found(self, capsys): + def test_cli_rpm_dir_not_found(self, tmp_path, capsys): """Test CLI with --rpm-dir when directory doesn't exist.""" - test_args = ['fedora-revdep-check', '--rpm-dir', '/nonexistent'] + nonexistent = tmp_path / 'nonexistent' + test_args = ['fedora-revdep-check', '--rpm-dir', str(nonexistent)] - with patch('sys.argv', test_args), \ - patch('os.path.isdir', return_value=False): - - exit_code = 0 - try: + with patch('sys.argv', test_args): + with pytest.raises(SystemExit) as excinfo: main() - except SystemExit as e: - exit_code = e.code - assert exit_code == 1 - captured = capsys.readouterr() - assert 'Directory not found' in captured.err + assert excinfo.value.code == 1 + captured = capsys.readouterr() + assert 'Directory not found' in captured.err - def test_cli_rpm_dir_no_rpms(self, capsys): + 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', '/tmp/empty'] + test_args = ['fedora-revdep-check', '--rpm-dir', str(tmp_path)] - with patch('sys.argv', test_args), \ - patch('os.path.isdir', return_value=True), \ - patch('glob.glob', return_value=[]): - - exit_code = 0 - try: + with patch('sys.argv', test_args): + with pytest.raises(SystemExit) as excinfo: main() - except SystemExit as e: - exit_code = e.code - assert exit_code == 1 - captured = capsys.readouterr() - assert 'No .rpm files found' in captured.err + 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): - exit_code = 0 - try: + with pytest.raises(SystemExit) as excinfo: main() - except SystemExit as e: - exit_code = e.code - assert exit_code == 2 # argparse error + assert excinfo.value.code == 2 # argparse error From 8cb34fd233a0c59df840c9e600c4861606ea9a61 Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Mon, 11 May 2026 09:45:24 +0200 Subject: [PATCH 7/7] Add real RPM fixtures and rewrite RPM-reading tests to use them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the heavily-patched MockRPMHeader approach in test_rpm_reading.py and test_rpm_checking.py with actual RPM files built from minimal spec files. The mock approach was fragile in two ways that the reviewer flagged: - test_skip_source_rpm only passed because the mock hardcoded arch='src', but real SRPMs have arch='noarch' — the test was not testing real behaviour. - Every test carried 18 patch() calls that coupled tests to implementation details (which constants the code reads, whether it uses bytes or str, etc.). New layout: tests/fixtures/rpms/ specs/ - five spec files, one per test scenario rebuild.sh - regenerate RPMs from specs (requires rpmbuild) *.noarch.rpm - six pre-built binary RPMs *.src.rpm - one pre-built source RPM Packages (all noarch, no dist tag for cross-Fedora stability): revdeptest-foo 1.0 simple package, no epoch revdeptest-bar 1.0 different SRPM (for mixed-SRPM error test) revdeptest-bundled 1.0 has bundled(libfoo) and bundled(libbar) provides revdeptest-multi 1.0 main package with a subpackage (same SRPM) revdeptest-multi-sub 1.0 subpackage of revdeptest-multi revdeptest-epoch 9.1.0 Epoch: 1 package test_rpm_reading.py now imports nothing from unittest.mock and carries no rpm-module patches. It passes real paths to read_rpm_provides() and asserts on the real header values returned by the rpm library. test_rpm_checking.py keeps MockBase/MockPackage for the DNF layer (which has its own test coverage) but passes real RPM files into check_rpm_files(). Remove *.spec from .gitignore (added for PyInstaller; not relevant here and was hiding the new spec files from git). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .gitignore | 1 - tests/fixtures/rpms/rebuild.sh | 34 ++ .../rpms/revdeptest-bar-1.0-1.noarch.rpm | Bin 0 -> 6229 bytes .../rpms/revdeptest-bundled-1.0-1.noarch.rpm | Bin 0 -> 6261 bytes .../rpms/revdeptest-epoch-9.1.0-1.noarch.rpm | Bin 0 -> 6253 bytes .../rpms/revdeptest-foo-1.0-1.noarch.rpm | Bin 0 -> 6193 bytes .../rpms/revdeptest-foo-1.0-1.src.rpm | Bin 0 -> 6536 bytes .../rpms/revdeptest-multi-1.0-1.noarch.rpm | Bin 0 -> 6229 bytes .../revdeptest-multi-sub-1.0-1.noarch.rpm | Bin 0 -> 6189 bytes tests/fixtures/rpms/specs/revdeptest-bar.spec | 17 + .../rpms/specs/revdeptest-bundled.spec | 18 + .../fixtures/rpms/specs/revdeptest-epoch.spec | 18 + tests/fixtures/rpms/specs/revdeptest-foo.spec | 17 + .../fixtures/rpms/specs/revdeptest-multi.spec | 28 ++ tests/integration/test_rpm_checking.py | 355 +++++------------- tests/unit/test_rpm_reading.py | 278 +++----------- 16 files changed, 282 insertions(+), 484 deletions(-) create mode 100755 tests/fixtures/rpms/rebuild.sh create mode 100644 tests/fixtures/rpms/revdeptest-bar-1.0-1.noarch.rpm create mode 100644 tests/fixtures/rpms/revdeptest-bundled-1.0-1.noarch.rpm create mode 100644 tests/fixtures/rpms/revdeptest-epoch-9.1.0-1.noarch.rpm create mode 100644 tests/fixtures/rpms/revdeptest-foo-1.0-1.noarch.rpm create mode 100644 tests/fixtures/rpms/revdeptest-foo-1.0-1.src.rpm create mode 100644 tests/fixtures/rpms/revdeptest-multi-1.0-1.noarch.rpm create mode 100644 tests/fixtures/rpms/revdeptest-multi-sub-1.0-1.noarch.rpm create mode 100644 tests/fixtures/rpms/specs/revdeptest-bar.spec create mode 100644 tests/fixtures/rpms/specs/revdeptest-bundled.spec create mode 100644 tests/fixtures/rpms/specs/revdeptest-epoch.spec create mode 100644 tests/fixtures/rpms/specs/revdeptest-foo.spec create mode 100644 tests/fixtures/rpms/specs/revdeptest-multi.spec 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/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 0000000000000000000000000000000000000000..0bc1f9d62f021edd63d725d64a2810e180736937 GIT binary patch literal 6229 zcmeHLTZ2AAw zl3meYz-LkX0s4>!MFj;#MU+59L0|OQ2Nf0KB~f1_3d#CacO@hXfeQ$L|E1fwe(-Z|oNp=LWelnT{3c z%(6t^?b`-(ZQJFZ;W1x-Fu_mDb?)J--h{G-BBPm%GT6uwPisiWM4cDuqC6kb&LBCOO^URGG@ zEHeKYg@1>Y@z)jp16Ic0RQM9C%>SOkmtkf6V}-9M{8^1Vw<`Rl!m=*8KUoL%)VTu| z>$UE#ZRbu{#9Olp-=**i3Qxmgy_Tw{bENjSWIZz9QCK5rZ6rT&HD>A7?|MO&5ZvNuVtqSllJNQsqhtKUz9{+ z7FXz6f-q0V8qc(Yo!_a$~p1c{k6-BwH;d^*l@ zQ4}KB9mQfl8;b(7>zMTHjE0hmYraLc8mFupTs*=~h{arl(aQSp-s1713}%Sc)w?zn zQoGx)lv2CudtJM0xNyVSHDqzs8Imp4aaBX;f~nCkF4F;3wXo| zhY}m53BVAa9Os$`bjUd^PDUlp5rHhqghmguI7XkOlw-RwixPS=Pm>{yS%RsDMV_bm z0C!=GRd7Ehc~q{#pUu)k%exm29NfG6=~pN5QzvV=LU9Fl(?rf%hM9uOmkY4 zve18#hb2ksV`DOnM#p^{oxDSX6}L|-R8A5x7-FqF;#i3ls|g<=dparTBsm!+0WFyy zOWIT!JZ8n{YB69M0Uq9f$7Nj5AYw799SoC+JT=gqj-!%>Ic^ip(kLlKj%u#@W5L23 zgM?>MN}j}(A%>^!KE98VCR%+TC{n9?GATpq5@ze%32d92u5I=`gN2^gi>HWa1c`?DNHiA-Lb84}U6CZ@5WL#WR)6#TepSD! z`l@CYzKic%`mRaTlgnd5j!IdSG#sZQk%F4-4mBsqJAJLawc{)AU1;dodP1f?z~{%% z)aSQ=Q-n0`2ChR>5BfGX0~MdCc=H0Vg_dakI}qc^2o2+#e*jhf1vHFr{s~n1myk9* z#!NrtR^WS{8`yzscB8H(1IrVx7csNju`P~9d@B-mXxd)a4Z0?aBF~S)KyYE1zQcXX z^+QWC8FVbhA|_2+m|XaNV0+y1yN| z0<{9Q0<{9Q0<{9Q0<{9Q0<{9Q0<{9Q0<{AFcLgS%$m;6qYY+mS1|c{2CI;+{HxYUq z4ZcR}b)f2_EQbwf>QTOj@=26$vPSb?G{0B#Up0R~bLFGlh4zr<7d3xY^Gm=M+Pj)7 zpGDO_rTJx`%73i+UqF@rRP!r9RsVC%uL4#6Yt64|ex~BBdo(|*`F%jOKh+NwRqK8r z=4&_=w$wh5Z_I1{pyqFDJ`KctjW2=Fr?phY8)|-4?+{Rp|6cPDsQN3NRDV(N=C_(l z&4185()^s}%Admc=J|?mc>{>`;%`!K%V{9;EfqlhEk6MlN%q6CGe(RbVSQ;FkIR9f{X1inXUAe83nR*MBa$M^8LezS$_J7kF_ha^ zkf-7_9VdQvNghYB6eNk$@d`;Zmh-`F zwpLk>?IP#xd^99_L(@qdhAf}Ckw;lJbDIKcUWmN$#)e8LFg=$IN4YEtDR#t(+{1>l zK!qx&BJ*)y$?|V(FFTQBY|^=Sk{y$Ka~Z`ex2AU$&lIJYA$GgdcHo*iZExb9I+M5C zcG_luFoJDusp4$a`H~G7LM(NZldg2ZGH8^PS&xm&3?Cy_Qc#+Ohb1oyMC1yGQyXU~ zNFRT>V@J=(zvF(J#DLtNNX`d!6#n6K?&$GOT`!U2U zh2-NrE>9pn+DZ>B?^xKkf7gyf%Zm#K_RVeIO6PBh^z5qV{PND-ySB|OEiFH>xVRl- z7WOO}R2_EFn;jSVEM%XB!n3&)16FFF1C|S!!e{2(#IA}GcbMYNme^*N)1p)hy@NU| zO|uXiQ)LV~^c=j@z3Qz59-XjqoXTDwbLBC|OsqJO@&T%6bWYNt@!bfpN zp2O84W>q5)w}hx@{D;AuOSr>yF3*X3d01SazW-}HpA z-O#e&Vs~xVm%fZ#-|0pjv*UC-uBd8Rw&*yP6*`e*RuK8VDFff~9WxAVzvFj1q0L3d zwF9g;bUf3tZJ6dAOcT03v(#^4^$%%vRsBAbsp$`Auad#T4YFq2x=o+`_Q|>7k5hlI cZG1J{M0P%1Tv=uOZDxuaQUo0TgfZB>(^b literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5d295b9e6c0b9a76ddb59024bd9a24e9d0e9620f GIT binary patch literal 6253 zcmeHLONbmr7_P}o9vXy|1bm>-;vpMI&G!4DjAAwqgC-lYd3sr@tE*>v)6?B__awXG z!GjksB8Uf3(1QmddJq&3dI%^nAc6-yc@kHM#>D7Bf{?6V%~V3Nxx};GZ1vaQ|JUQM zuexX9yL9IAk8L8aQk;rJQH!dkqR9Dx8czgWqpMS6oW8r;`nBC&`{+VT#WoT$^$DJz zLX*$$1E&aS-3Q!&CNK1D-4B#}`-HbI06S=@;=cnio)l;p-~IzA^Dm=eeESMe=3hZt z3k}yXz1Y|hwkrOu;tv4j zeq|llW9LC2)@x}K?8to~-_jL-Sn)R%p9W&RmRi4ad=hVc0hIHd0Lt-SDK0G?+5dYW z>g_}m-u_Z?q4;^lW5sXOEBz{rZ+|z*Z+QcVddKI4`L>)>d8xbPD?qH@{!8&+fau?` z6_@9Ue8-o3_71;}jNLjiaqC)#;p1rq<0n~PXs45Upp8vEEzV0V7ExX@Ix+kx9|(R@ zlfGX?_9s~~Wa*gwm(imvsV7r(QQTtdvwX4!2(H;#n224S9LcIe@FY$|MA9T1Es-o| zB_G_e6{?KKZdLMbSqw?BTo3Zhiju0HUh7kCzhk&u6N27*Yr`yQ5>C(N!=e;bC89k^ zDh{xrs4#w#laXCXUkY(Ec91QnIU9Gbo@J-R!BWJ@(%SUC>V>L~wiBzXcWwBkcGn&I zruO*mbnUL;BaC2InW;EOvPm7-O`Da@nHr7LI`6Sjo#QcPH3g-4xF~pCAtIJIJlZ7B zK>GMA1<%Wf_Bp2&P8ny3M3EFiqsLg9;-2M{W78>1GJ3kqvp!8(hM~tqS>|OA@52zQ zRDzGnq+Uk6*g=mh?3p`sc;B8A3-fbF4$bb~L3gc*^sGtGT?>1k-?wx2`0<5j=I3`~ z%-n%_jml%LdNZS{oPp>w(08U3(SX$o=zx`x$V5arH?XU!#*0deGOw}Cyrfkv3%!eZ zSeE4>HYTUh=+L!slW$aS$#?0P)uT-G`dF(>I96iSa>fUkJs(wcl$}nph}JAjrEIDU zp0a9iqZlZS1dngy!#b^Kl&}=l_WIdKo*HD%he=K267Li(@+7N8iE3`dQ^DfflY|#Z zPF}=|LySOu3HT4~)QaJmHDh@$4Yh4c!iONB(+YE)xc}ZJMDS3ugMU=NZEHOwTsL(DHN-Hn+ti z-LZVs8``d6T9)ul9m|A{$4vQ~H~B}jx_a#jo~Nciow-H^kG9CVof|fvz4-ah!=I-9 dS>O6*xS8xdT3uaz_v?>$Y+Og$+pd$x{{@Qg>%0H} literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2f40005799525347fabb12223b3d6f8a4ad98ae8 GIT binary patch literal 6193 zcmeHL&5ImG6tBrlJ~Rp|QTza9z=In}&Gy$!cOTZ^Zaxf{Y{(`i5oD>Zs^00Or+et` zNp^#W96Wds5sU{#ND%QNIS7geJ>;Yi{{&B>Li|WH7m0yn{k@rrBzsZMb~9DK{=HZA z>eZ{78TeiO_}aHkMo*znDLt%oS@Af}x!tk2J$C$;ZsYpFZ(h98Ft*K%O?`^@=g{c= zL*Nu+jR%37(CER~#zR2j+b6tv1=vDM4gVF0`D}=W`OV*eV#NR6SROu`ey(Rnm2PSP*Hy>+ekGZa#YyyU4gm+bp`4Q)D@^JP*x4&ZyWy6gts0x{G#EM7wzvd8urwB3`pxy zTwv>QAp8v)L;MND2Zm1rv0lUE(>gY>Hz*&9cN|Fb-#0u0()c_1s0nYr1H}GY+VJ-b zj}1R>cxw0u6Ta$C&jXE$9d6?waN>@Ct@C#eJxM6Nz*IY{v=BVA|11ToHELiYBI0G*onsH z`6Mnf!81P(X%NdF$;zRYNt|fK(j*(Luq+pa?5|68A|-apLUxMbfDKoxexA81DXZyw zHOKAiCem6c#Qk?SL`aipdOjZv3tg64?MYI7Kn!$=*%KdGb|uTS>A!6U#cG<1@!;|W zaY`R7beycLcke4-E-SU2xgD$HLH+bPy|D`Fjr(rL>(~LzFm{YIN@ulYd=%p$e8Fk+ zIIZ$oF{*OB#-ie&JdaLjS(UKp6$zC!$up2;d z=6CPlyVhA|-O0#ZOM743H#dLm*wXWhi@Pyr;lN^>)4`UrGo!MYLAhs8*_lGCzNifF zz9^K=wBl0Q*i~8K4)S4hyQTFd7gG$38<9qm^N=vR1ks{mKLenM29T`%ez>&yFJ3xBSqGT&b+C8zAGz>)DR$>cFwEOw{#-L%(B_Ka;gJ z`VD4N)1S@UV*Mu@Y{T59t(SiJ;`_mOQ#Us@z8P#~dtWMVteyY*lO3Bku;w$j+0%ak DFu&K1 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5c81d92f1a7d24eb2a76f6f88ade0223b5c940a2 GIT binary patch literal 6536 zcmeI0Yitx%6vuDd#nwfj1{B01E02TC6oD8G7?BVP36WRCAkmOOKt&@asI5>yRH!J_bGnyD`QqcNGs)fm{N|i{ z?!Bk8+vMM}`P|7oLUIU6u~{xlW>`8C=7~gDE|J1A`L|c6+b6v8eiPJgzw!ww+zxl8 zFz|j8Xdx`!1@sOWctLK?-JrM~I&P0KWF6BdMJFC48ejE!8|z2E9deOImdWlSGJax2EwfA2uMC(_o|wgMUfo(3Ve_$J`O z%yD59z`zTA5A;dUH^~*;EExS1jQ=F~9>M6N+zF$n;1=WkWb7brj z#(N0vCE^1>q3417M0|+g-h!VNJO~udloJERdSxP>5hvQpi;zN7xAfr*9xuz#r*YxabH;fUBS~pF}_9cbisQCyP&xLeS(?bgMzu>!&&aW z3KZ)f5sY~_|Cr#@pt#;wg1-la{M;hJ&DnTvNbng@n4e3Lqqq5$1pG=OlSyD-hLY5o zNo!b$CsHBK+(e3ov*u>lsb$X05PFq7(RL2tHH(;6SYJt(ZKxGN+RthFnpHC*UyqY zct|!3Q?m@!QnH7`1B!j27TjtdcidMAHi-}?=_N?bb6%2IVqQUghKEgJsH!G8oJC|X zSY0YRRMH)lb5&Bf&UMA$wrp7nH+7~cvPm@qZgpyL#gHPdl&w}^xt8I`20UjLb6jrg zGB~-C%_ZBW)Q0}*Ty?pmYRt9);Sb^Jnr#|fwPn@T3HE1L=;qb&5Wy)dmG%lqy9IV^rlG4YZGN7Q>Uw+!AYQ8fFC1%pqd60iOWW!FpRKdU}b7aI4-(98WVubsP}bsF5OrvIYWz4Ty1{+|4UCq75fAo2C$ZNyX{dP><8rySv*HhJ>ud8jC zzHH&t!i(RXoih5@{1J_vU)CEde^1UkcXd%|msy7<7xkP_|6s#{-NhTLj~uwP^xnA} z_cy7_l}}qq^(R{@d(T_8A!p*o9s1}mn!`gMSa`MDndr8Me*EC@8fE;^+51o5zim?a JZx!(0<1eSV3P1n= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bc8266e1f4f0e6487a47cbab2037e4821f8ddf1e GIT binary patch literal 6229 zcmeHL%ZnUE9InYs9vXy|2tF_{;K2=S&GvJ;=P*jLc^J@a$YxFAWvQ;mPA5IxZFf(y z8$2j@7R9Te2M>Y@3W|!Ti$a8me}NZ8h4@I+iv%GVznZB8v$^DMH(UM9_xsi3S6|i4 z!gu-YE8jJVehPV3$h?weMTeuLimBbPs69#D`D^Rut}nfNsbON<37Po-pC3chpWg(| z5Yo5@xD8D|=-ap#sQI2LZ(ahn&=SLc2Vy+Q(J;RG2T`rDF zrExW#rbA9QKFX)7pi(>w3os5*+;UpxlE*5Rf+TS|S|e%33O=}PD^shnQx?2a+QfE7ZfQc%uq?5eDAOKDzY6}Fibw5)WYcQ6l2(=5Wq^fYZc?0R_Vd(>YG zyL7^;Q7ZeZSgVLRR$}FP$_JP|8+43tKKt6TW6N=hnX zmY~}HYC6)V2AQ*ATv1ivHqktb(@GYo=0-e`Ox>C!JdZPS9#@9gJ#+u@UDPts$#;Tc zYI9FUm7*bGp2Y*dOe>b$8wPe)2-lAsC+yih*AHYMl^=LLW!aY3 zvwSg`Y=`3t%X6H_Q<6EM3Ibb(ffIOk6uE&F^eh~4@P`?l@e`0djlhd<2xv$gTfa3?wRba{3B`q%HzZQnwg Jk6tH_{0oiS>HYu! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4b1d7f3bfc796ddf872cdc58e7807933ee347127 GIT binary patch literal 6189 zcmeHLTZZuAb?nr@QG( zb~pIogAYE5B3?jL5Z@#Zf}*Go#s_`yCkO`dLcAm@hy)>7zv`(C$;OAg+s&Rn{hd>F zPSsc4v+!Mb@8UO2BCkxG@W&6weO zde02F<7&DvbXRz~&wbZ4bcdOY2Ocvp0a-M}vR~Qz+PP1@J#)6y&i!)C|-N)C|-N)C|-N)C|-N)C|-N)C|-N)C~OJ8K^Xojg5^LK?F1nLaxzG+K@Mn zBlIu~x<+drUXJl$6EJy6-9zdmQa9PA@Xre0sqilf->b0HQEq|lQTT$w4=Q{SUg|2F zx{K6VWc?H-`~3+o^J@zK4KMRj?~wUR@UlL1muP?i@Ruqt_k;Soe}G5-<`)Y82ru^m ztMc;vkZ-|AW9C2gIRbr-=wz|@}b}-+hrUU zZK?g`9YRhtK1#=f&>7dv3`9UQT8i^j@GuC4Pogj`*GQbOj1O;IS|uBH@{D(~bVSni zVwl9HALhl>*2PJcao>#v5UohK6+~S%)QUoHY9<+_naFeD&xMgV%tj(dg>gG}MryzuuCv!it^Xr5N5S81PCb?WNqMG@|CLOHe@u`s5qSrQLu#9~Z6CbBHa`Z&%Jy5xeFSy-$ip6;hhD{~7+ zpO~LJzOuNmbadw6emc7)(l^H=XIBnAK7U|ld3oiL#l?e|vv7E^P30ly{pm8#ra|{K zn4QjqKV*e+bjUJa#KNbX>)2IZ;N7EXmK4}#lF_`73%!XtERGWo8ziYTXk z$Rez^KZr|tYM?n8g#`^VyiGJs!nhC_R@JUIUX!UJS zl&-!WGEo)*bqKRGZu^$S4QR_fS7(9icDd(Rj?Q|Ht@nK2?0SabxVj7JS=b`z+Ljw= zx@Ngr*RP6A7^bgThT&O(V1^Uewk{mour1y5Ok1;E%`>^Lbxj8=_N<<6n5J+H4Q)Kg z*@pZL8~-EO*pR=!WOC}m>C0rer$M$I*s<&LUqAhR^xfn?+Z$hxc9BC*=9f0!{_6ex NJGYVM{a46+{{n= 7.0 + """Updating revdeptest-foo to 1.0 satisfies all existing requirements.""" packages = [ MockPackage( - name='python3-pytest', - version='7.0.0', - release='1.fc45', + name='revdeptest-foo', + version='0.9', + release='1', arch='noarch', - source_name='pytest', + source_name='revdeptest-foo', provides=[ - 'python3-pytest', - 'python3dist(pytest) = 7.0.0', + 'revdeptest-foo', + 'python3dist(revdeptest-foo) = 0.9', ] ), MockPackage( - name='python3-tox', - version='4.0.0', - release='1.fc45', + name='consumer', + version='1.0', + release='1', arch='noarch', - source_name='tox', + source_name='consumer', requires=[ - 'python3dist(pytest) >= 6.0', + 'python3dist(revdeptest-foo) >= 0.5', ] ), ] - base = MockBase(packages=packages) - checker = FedoraRevDepChecker(verbose=False, base=base) - - mock_header = MockRPMHeader( - name='python3-pytest', - version='7.1', - release='1.fc45', - arch='noarch', - sourcerpm='pytest-7.1-1.fc45.src.rpm', - provides_names=[ - 'python3-pytest', - 'python3dist(pytest)', - ], - provides_versions=[ - '7.1-1.fc45', - '7.1', - ], - provides_flags=[8, 8] - ) - - mock_ts = Mock() - mock_ts.hdrFromFdno = Mock(return_value=mock_header) + checker = FedoraRevDepChecker(verbose=False, base=MockBase(packages=packages)) - with rpm_patches(mock_ts): + result = checker.check_rpm_files([str(RPM_DIR / 'revdeptest-foo-1.0-1.noarch.rpm')]) - result = checker.check_rpm_files(['/tmp/pytest.rpm']) - - assert result['srpm_name'] == 'pytest' - assert result['new_version'] == '7.1-1.fc45' - assert len(result['conflicts']) == 0 + assert result['srpm_name'] == 'revdeptest-foo' + assert len(result['conflicts']) == 0 def test_check_rpm_files_with_conflicts(self): - """Test checking RPM files that cause conflicts.""" - # Create a mock base with packages that depend on jupyterlab < 4.7 + """Updating revdeptest-foo to 1.0 breaks a package requiring < 1.0.""" packages = [ MockPackage( - name='python3-jupyterlab', - version='4.6.0', - release='1.fc45', + name='revdeptest-foo', + version='0.9', + release='1', arch='noarch', - source_name='jupyterlab', + source_name='revdeptest-foo', provides=[ - 'python3-jupyterlab', - 'python3dist(jupyterlab) = 4.6.0', + 'revdeptest-foo', + 'python3dist(revdeptest-foo) = 0.9', ] ), MockPackage( - name='python3-jupyter-server', - version='2.0.0', - release='1.fc45', + name='old-consumer', + version='1.0', + release='1', arch='noarch', - source_name='jupyter-server', + source_name='old-consumer', requires=[ - 'python3dist(jupyterlab) < 4.7', + 'python3dist(revdeptest-foo) < 1.0', ] ), ] - base = MockBase(packages=packages) - checker = FedoraRevDepChecker(verbose=False, base=base) - - # Mock RPM for jupyterlab 4.7.0 - mock_header = MockRPMHeader( - name='python3-jupyterlab', - version='4.7.0', - release='1.fc45', - arch='noarch', - sourcerpm='jupyterlab-4.7.0-1.fc45.src.rpm', - provides_names=[ - 'python3-jupyterlab', - 'python3dist(jupyterlab)', - ], - provides_versions=[ - '4.7.0-1.fc45', - '4.7.0', - ], - provides_flags=[8, 8] - ) + checker = FedoraRevDepChecker(verbose=False, base=MockBase(packages=packages)) - mock_ts = Mock() - mock_ts.hdrFromFdno = Mock(return_value=mock_header) + result = checker.check_rpm_files([str(RPM_DIR / 'revdeptest-foo-1.0-1.noarch.rpm')]) - with rpm_patches(mock_ts): - - result = checker.check_rpm_files(['/tmp/jupyterlab.rpm']) - - assert result['srpm_name'] == 'jupyterlab' - assert len(result['conflicts']) == 1 - conflict = result['conflicts'][0] - assert conflict['rdep_source'] == 'jupyter-server' - assert conflict['provide_name'] == 'python3dist(jupyterlab)' - assert 'python3dist(jupyterlab) < 4.7' in conflict['failed_constraint'] + 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): - """Test checking RPM files with epochs.""" - # Create a mock base with packages requiring sphinx >= 1:8.0.0 + """Updating revdeptest-epoch (Epoch:1) to 9.1.0 satisfies >= 1:8.0.0.""" packages = [ MockPackage( - name='python3-sphinx', + name='revdeptest-epoch', version='8.0.0', - release='1.fc45', + release='1', arch='noarch', - source_name='python-sphinx', # Must match SOURCERPM + source_name='revdeptest-epoch', epoch='1', provides=[ - 'python3-sphinx', - 'python3-sphinx = 1:8.0.0-1.fc45', - 'python3dist(sphinx) = 8.0.0', + 'revdeptest-epoch', + 'revdeptest-epoch = 1:8.0.0-1', + 'python3dist(revdeptest-epoch) = 8.0.0', ] ), MockPackage( - name='python3-docs', - version='1.0.0', - release='1.fc45', + name='consumer', + version='1.0', + release='1', arch='noarch', - source_name='python-docs', + source_name='consumer', requires=[ - 'python3-sphinx >= 1:8.0.0', + 'revdeptest-epoch >= 1:8.0.0', ] ), ] - base = MockBase(packages=packages) - checker = FedoraRevDepChecker(verbose=False, base=base) - - # Mock RPM for sphinx 1:9.1.0 - mock_header = MockRPMHeader( - name='python3-sphinx', - version='9.1.0', - release='1.fc45', - arch='noarch', - epoch=1, - sourcerpm='python-sphinx-9.1.0-1.fc45.src.rpm', - provides_names=[ - 'python3-sphinx', - 'python3dist(sphinx)', - ], - provides_versions=[ - '9.1.0-1.fc45', - '9.1.0', - ], - provides_flags=[8, 8] - ) + checker = FedoraRevDepChecker(verbose=False, base=MockBase(packages=packages)) - mock_ts = Mock() - mock_ts.hdrFromFdno = Mock(return_value=mock_header) + result = checker.check_rpm_files([str(RPM_DIR / 'revdeptest-epoch-9.1.0-1.noarch.rpm')]) - with rpm_patches(mock_ts): - - result = checker.check_rpm_files(['/tmp/sphinx.rpm']) - - assert result['srpm_name'] == 'python-sphinx' - # Should have no conflicts (1:9.1.0 >= 1:8.0.0) - assert len(result['conflicts']) == 0 + assert result['srpm_name'] == 'revdeptest-epoch' + assert len(result['conflicts']) == 0 def test_check_rpm_files_skips_same_srpm(self): - """Test that packages from same SRPM are skipped.""" - # Create a mock base where packages from the same SRPM depend on each other + """Packages from the same SRPM are not flagged as conflicts.""" packages = [ MockPackage( - name='micropipenv', - version='1.10.0', - release='1.fc45', + name='revdeptest-multi', + version='0.9', + release='1', arch='noarch', - source_name='micropipenv', + source_name='revdeptest-multi', provides=[ - 'micropipenv', - 'micropipenv = 1.10.0-1.fc45', + 'revdeptest-multi', + 'python3dist(revdeptest-multi) = 0.9', ] ), MockPackage( - name='micropipenv+toml', - version='1.10.0', - release='1.fc45', + name='revdeptest-multi-sub', + version='0.9', + release='1', arch='noarch', - source_name='micropipenv', + source_name='revdeptest-multi', requires=[ - 'micropipenv = 1.10.0', + 'revdeptest-multi = 0.9', ] ), ] - base = MockBase(packages=packages) - checker = FedoraRevDepChecker(verbose=False, base=base) - - # Mock RPM for micropipenv 1.11.0 - mock_header = MockRPMHeader( - name='micropipenv', - version='1.11.0', - release='1.fc45', - arch='noarch', - sourcerpm='micropipenv-1.11.0-1.fc45.src.rpm', - provides_names=[ - 'micropipenv', - ], - provides_versions=[ - '1.11.0-1.fc45', - ], - provides_flags=[8] - ) + checker = FedoraRevDepChecker(verbose=False, base=MockBase(packages=packages)) - mock_ts = Mock() - mock_ts.hdrFromFdno = Mock(return_value=mock_header) + result = checker.check_rpm_files([str(RPM_DIR / 'revdeptest-multi-1.0-1.noarch.rpm')]) - with rpm_patches(mock_ts): - - result = checker.check_rpm_files(['/tmp/micropipenv.rpm']) - - # micropipenv+toml should be skipped (same SRPM) - assert len(result['conflicts']) == 0 + assert len(result['conflicts']) == 0 def test_check_rpm_files_already_broken(self): - """Test detection of already-broken packages.""" - # Create a mock base where a package already fails with current version + """A conflict that exists before the update is marked already_broken.""" packages = [ MockPackage( - name='library', - version='4.0.0', - release='1.fc45', + name='revdeptest-foo', + version='0.9', + release='1', arch='noarch', - source_name='library', + source_name='revdeptest-foo', provides=[ - 'library', - 'python3dist(library) = 4.0.0', + 'revdeptest-foo', + 'python3dist(revdeptest-foo) = 0.9', ] ), MockPackage( - name='python3-old-package', - version='1.0.0', - release='1.fc45', + name='old-consumer', + version='1.0', + release='1', arch='noarch', - source_name='old-package', + source_name='old-consumer', requires=[ - 'python3dist(library) < 3.0', # Already broken with 4.0.0 + 'python3dist(revdeptest-foo) < 0.5', # already broken with 0.9 ] ), ] - base = MockBase(packages=packages) - checker = FedoraRevDepChecker(verbose=False, base=base) - - # Mock RPM for library 5.0.0 - mock_header = MockRPMHeader( - name='library', - version='5.0.0', - release='1.fc45', - arch='noarch', - sourcerpm='library-5.0.0-1.fc45.src.rpm', - provides_names=[ - 'library', - 'python3dist(library)', - ], - provides_versions=[ - '5.0.0-1.fc45', - '5.0.0', - ], - provides_flags=[8, 8] - ) - - mock_ts = Mock() - mock_ts.hdrFromFdno = Mock(return_value=mock_header) - - with rpm_patches(mock_ts): + checker = FedoraRevDepChecker(verbose=False, base=MockBase(packages=packages)) - result = checker.check_rpm_files(['/tmp/library.rpm']) + 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(library) < 3.0' in conflict['failed_constraint'] + 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 index 56c0fa0..371a32c 100644 --- a/tests/unit/test_rpm_reading.py +++ b/tests/unit/test_rpm_reading.py @@ -1,267 +1,101 @@ """ Unit tests for RPM file reading functionality. -Tests the read_rpm_provides() method using mocked RPM headers (no real RPM files). +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 contextlib import contextmanager -from unittest.mock import Mock, mock_open, patch +from pathlib import Path from fedora_revdep_check import FedoraRevDepChecker - -class MockRPMHeader: - """Mock RPM header for testing.""" - - def __init__(self, name, version, release, arch, epoch=None, sourcerpm=None, - sourcepackage=0, provides_names=None, provides_versions=None, - provides_flags=None): - self.data = { - 1000: name, - 1001: version, - 1002: release, - 1022: arch, - 1003: epoch, - 1044: sourcerpm, - 1106: sourcepackage, - 1047: provides_names, - 1113: provides_flags, - 1048: provides_versions, - } - - def __getitem__(self, key): - return self.data.get(key) - - -@contextmanager -def rpm_patches(mock_ts): - """Context manager providing all rpm module patches needed for tests.""" - with (patch('rpm.TransactionSet', return_value=mock_ts), - patch('rpm._RPMVSF_NOSIGNATURES', 0), - patch('rpm._RPMVSF_NODIGESTS', 0), - patch('rpm.RPMTAG_NAME', 1000), - patch('rpm.RPMTAG_VERSION', 1001), - patch('rpm.RPMTAG_RELEASE', 1002), - patch('rpm.RPMTAG_ARCH', 1022), - patch('rpm.RPMTAG_EPOCH', 1003), - patch('rpm.RPMTAG_SOURCERPM', 1044), - patch('rpm.RPMTAG_SOURCEPACKAGE', 1106), - patch('rpm.RPMTAG_PROVIDENAME', 1047), - patch('rpm.RPMTAG_PROVIDEFLAGS', 1113), - patch('rpm.RPMTAG_PROVIDEVERSION', 1048), - patch('rpm.RPMSENSE_EQUAL', 8), - patch('rpm.RPMSENSE_GREATER', 4), - patch('rpm.RPMSENSE_LESS', 2), - patch('os.path.exists', return_value=True), - patch('builtins.open', mock_open())): - yield +RPM_DIR = Path(__file__).parent.parent / 'fixtures' / 'rpms' class TestReadRPMProvides: - """Test read_rpm_provides() method.""" + """Test read_rpm_provides() against real RPM files.""" @pytest.fixture def checker(self, mock_dnf_base): - """Create checker instance with mocked DNF base.""" return FedoraRevDepChecker(verbose=False, base=mock_dnf_base) def test_read_single_rpm_file(self, checker): - """Test reading a single RPM file.""" - mock_header = MockRPMHeader( - name='python3-sphinx', - version='9.1.0', - release='1.fc45', - arch='noarch', - epoch=1, - sourcerpm='python-sphinx-9.1.0-1.fc45.src.rpm', - provides_names=[ - 'python3-sphinx', - 'python3dist(sphinx)', - ], - provides_versions=[ - '9.1.0-1.fc45', - '9.1.0', - ], - provides_flags=[ - 8, # RPMSENSE_EQUAL - 8, # RPMSENSE_EQUAL - ] - ) + """Read a single RPM with Epoch: 1 and verify header fields.""" + rpm_file = str(RPM_DIR / 'revdeptest-epoch-9.1.0-1.noarch.rpm') - mock_ts = Mock() - mock_ts.hdrFromFdno = Mock(return_value=mock_header) + result = checker.read_rpm_provides([rpm_file]) - with rpm_patches(mock_ts): + 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 - result = checker.read_rpm_provides(['/tmp/test.rpm']) - - assert result['srpm_name'] == 'python-sphinx' - assert 'python3-sphinx' in result['provides'] - assert 'python3dist(sphinx)' in result['provides'] - assert len(result['rpm_info']) == 1 - - rpm_info = result['rpm_info']['/tmp/test.rpm'] - assert rpm_info['name'] == 'python3-sphinx' - assert rpm_info['version'] == '9.1.0' - assert rpm_info['release'] == '1.fc45' - assert rpm_info['arch'] == 'noarch' - assert rpm_info['epoch'] == 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): - """Test reading multiple RPM files from same SRPM.""" - mock_header1 = MockRPMHeader( - name='python3-sphinx', - version='9.1.0', - release='1.fc45', - arch='noarch', - epoch=1, - sourcerpm='python-sphinx-9.1.0-1.fc45.src.rpm', - provides_names=['python3-sphinx', 'python3dist(sphinx)'], - provides_versions=['9.1.0-1.fc45', '9.1.0'], - provides_flags=[8, 8] - ) - - mock_header2 = MockRPMHeader( - name='python3-sphinx-latex', - version='9.1.0', - release='1.fc45', - arch='noarch', - epoch=1, - sourcerpm='python-sphinx-9.1.0-1.fc45.src.rpm', - provides_names=['python3-sphinx-latex'], - provides_versions=['9.1.0-1.fc45'], - provides_flags=[8] - ) - - mock_ts = Mock() - mock_ts.hdrFromFdno = Mock(side_effect=[mock_header1, mock_header2]) - - with rpm_patches(mock_ts): + """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(['/tmp/test1.rpm', '/tmp/test2.rpm']) + result = checker.read_rpm_provides([rpm_main, rpm_sub]) - assert result['srpm_name'] == 'python-sphinx' - assert 'python3-sphinx' in result['provides'] - assert 'python3dist(sphinx)' in result['provides'] - assert 'python3-sphinx-latex' in result['provides'] - assert len(result['rpm_info']) == 2 + 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): - """Test that source RPMs are skipped for provides. + """Source RPMs must be skipped even though their arch tag is 'noarch'. - Real SRPMs report arch='noarch' (not 'src'), so RPMTAG_SOURCEPACKAGE - is the correct way to identify them. + 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. """ - mock_header = MockRPMHeader( - name='python-sphinx', - version='9.1.0', - release='1.fc45', - arch='noarch', # real SRPMs report noarch, not 'src' - sourcerpm=None, - sourcepackage=1, # this is what identifies a source RPM - provides_names=['python-sphinx'], - provides_versions=['9.1.0-1.fc45'], - provides_flags=[8] - ) + srpm = str(RPM_DIR / 'revdeptest-foo-1.0-1.src.rpm') - mock_ts = Mock() - mock_ts.hdrFromFdno = Mock(return_value=mock_header) + result = checker.read_rpm_provides([srpm]) - with rpm_patches(mock_ts): - - result = checker.read_rpm_provides(['/tmp/test.src.rpm']) - - assert result['srpm_name'] == 'python-sphinx' - # Source RPM provides should be skipped - assert len(result['provides']) == 0 - assert len(result['rpm_info']) == 0 + assert result['srpm_name'] == 'revdeptest-foo' + assert len(result['provides']) == 0 + assert len(result['rpm_info']) == 0 def test_skip_bundled_provides(self, checker): - """Test that bundled provides are filtered out.""" - mock_header = MockRPMHeader( - name='myapp', - version='1.0.0', - release='1.fc45', - arch='noarch', - sourcerpm='myapp-1.0.0-1.fc45.src.rpm', - provides_names=[ - 'myapp', - 'bundled(libfoo)', - 'bundled(libbar)', - ], - provides_versions=['1.0.0', '2.0', '3.0'], - provides_flags=[8, 8, 8] - ) - - mock_ts = Mock() - mock_ts.hdrFromFdno = Mock(return_value=mock_header) + """Provides that start with 'bundled(' must be filtered out.""" + rpm_file = str(RPM_DIR / 'revdeptest-bundled-1.0-1.noarch.rpm') - with rpm_patches(mock_ts): + result = checker.read_rpm_provides([rpm_file]) - result = checker.read_rpm_provides(['/tmp/myapp.rpm']) - - assert 'myapp' in result['provides'] - assert 'bundled(libfoo)' not in result['provides'] - assert 'bundled(libbar)' not in result['provides'] + 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): - """Test that mixing RPMs from different SRPMs raises error.""" - mock_header1 = MockRPMHeader( - name='python3-sphinx', - version='9.1.0', - release='1.fc45', - arch='noarch', - sourcerpm='python-sphinx-9.1.0-1.fc45.src.rpm', - provides_names=['python3-sphinx'], - provides_versions=['9.1.0'], - provides_flags=[8] - ) - - mock_header2 = MockRPMHeader( - name='python3-requests', - version='2.32.0', - release='1.fc45', - arch='noarch', - sourcerpm='python-requests-2.32.0-1.fc45.src.rpm', - provides_names=['python3-requests'], - provides_versions=['2.32.0'], - provides_flags=[8] - ) + """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') - mock_ts = Mock() - mock_ts.hdrFromFdno = Mock(side_effect=[mock_header1, mock_header2]) - - with rpm_patches(mock_ts): - - with pytest.raises(ValueError, match="multiple source packages"): - checker.read_rpm_provides(['/tmp/test1.rpm', '/tmp/test2.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): - """Test error handling for missing RPM file.""" + """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): - """Test RPM file without epoch (epoch=None).""" - mock_header = MockRPMHeader( - name='mypackage', - version='1.0.0', - release='1.fc45', - arch='noarch', - epoch=None, - sourcerpm='mypackage-1.0.0-1.fc45.src.rpm', - provides_names=['mypackage'], - provides_versions=['1.0.0'], - provides_flags=[8] - ) - - mock_ts = Mock() - mock_ts.hdrFromFdno = Mock(return_value=mock_header) - - with rpm_patches(mock_ts): + """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(['/tmp/test.rpm']) + result = checker.read_rpm_provides([rpm_file]) - rpm_info = result['rpm_info']['/tmp/test.rpm'] - assert rpm_info['epoch'] == 0 - assert rpm_info['evr'] == '1.0.0-1.fc45' + info = result['rpm_info'][rpm_file] + assert info['epoch'] == 0 + assert info['evr'] == '1.0-1'