Skip to content

whl2conda.api.compare

Semantic comparison of conda packages.

This module compares two conda packages - typically one generated by whl2conda from a PyPI wheel and a reference package built from a recipe (e.g. by conda-build or rattler-build for conda-forge) - and reports their differences, classifying each as expected (benign differences inherent to the different build processes), notable, or unexpected errors.

Classes

CompareOptions dataclass

Options controlling conda package comparison.

Source code in src/whl2conda/api/compare.py
@dataclass(slots=True)
class CompareOptions:
    """Options controlling conda package comparison."""

    strict: bool = False
    """Promote NOTICE differences to ERROR."""

    ignore: set[DiffCategory] = field(default_factory=set)
    """Categories demoted to EXPECTED severity."""

    extra_run_exports: set[str] = field(default_factory=set)
    """Additional dependency names treated as benign run-exports."""

    renames: dict[str, str] | None = None
    """pypi to conda package rename map.

    Defaults to the standard rename table from [load_std_renames][whl2conda.api.stdrename.].
    """

Attributes

extra_run_exports class-attribute instance-attribute
extra_run_exports: set[str] = field(default_factory=set)

Additional dependency names treated as benign run-exports.

ignore class-attribute instance-attribute
ignore: set[DiffCategory] = field(default_factory=set)

Categories demoted to EXPECTED severity.

renames class-attribute instance-attribute
renames: dict[str, str] | None = None

pypi to conda package rename map.

Defaults to the standard rename table from load_std_renames.

strict class-attribute instance-attribute
strict: bool = False

Promote NOTICE differences to ERROR.

ComparisonResult dataclass

Result of comparing two conda packages.

Source code in src/whl2conda/api/compare.py
@dataclass(slots=True)
class ComparisonResult:
    """Result of comparing two conda packages."""

    package1: Path
    """First package (the package under test)."""

    package2: Path
    """Second package (the reference package)."""

    differences: list[Difference]
    """All differences found, including expected ones."""

    @property
    def errors(self) -> list[Difference]:
        """Differences with ERROR severity."""
        return [d for d in self.differences if d.severity >= Severity.ERROR]

    @property
    def ok(self) -> bool:
        """True if no ERROR severity differences were found."""
        return not self.errors

    def report(self, *, min_severity: Severity = Severity.NOTICE) -> str:
        """Generate a human readable report.

        Args:
            min_severity: only include differences of at least this severity.

        Returns:
            Multi-line report string.
        """
        lines = [f"Comparing {self.package1}", f"   against {self.package2}", ""]
        shown = [d for d in self.differences if d.severity >= min_severity]
        suppressed = len(self.differences) - len(shown)
        for d in sorted(shown, key=lambda d: (-d.severity, d.category.value, d.key)):
            lines.append(str(d))
            if d.left is not None:
                lines.append(f"    package1: {d.left}")
            if d.right is not None:
                lines.append(f"    package2: {d.right}")
        n_errors = len(self.errors)
        n_notices = sum(1 for d in self.differences if d.severity == Severity.NOTICE)
        lines.append("")
        summary = f"{n_errors} errors, {n_notices} notices"
        if suppressed:
            summary += f" ({suppressed} differences suppressed)"
        lines.append(summary)
        return "\n".join(lines)

    def to_json(self) -> dict[str, Any]:
        """JSON-compatible dictionary representation."""

        def _safe(val: Any) -> Any:
            if val is None or isinstance(val, (bool, int, float, str)):
                return val
            if isinstance(val, (list, tuple, set, frozenset)):
                return sorted(str(v) for v in val)
            return str(val)

        return {
            "package1": str(self.package1),
            "package2": str(self.package2),
            "ok": self.ok,
            "differences": [
                {
                    "category": d.category.value,
                    "severity": d.severity.name,
                    "key": d.key,
                    "description": d.description,
                    "left": _safe(d.left),
                    "right": _safe(d.right),
                }
                for d in self.differences
            ],
        }

Attributes

differences instance-attribute
differences: list[Difference]

All differences found, including expected ones.

errors property
errors: list[Difference]

Differences with ERROR severity.

ok property
ok: bool

True if no ERROR severity differences were found.

package1 instance-attribute
package1: Path

First package (the package under test).

package2 instance-attribute
package2: Path

Second package (the reference package).

Functions

report
report(*, min_severity: Severity = Severity.NOTICE) -> str

Generate a human readable report.

PARAMETER DESCRIPTION
min_severity

only include differences of at least this severity.

TYPE: Severity DEFAULT: NOTICE

RETURNS DESCRIPTION
str

Multi-line report string.

Source code in src/whl2conda/api/compare.py
def report(self, *, min_severity: Severity = Severity.NOTICE) -> str:
    """Generate a human readable report.

    Args:
        min_severity: only include differences of at least this severity.

    Returns:
        Multi-line report string.
    """
    lines = [f"Comparing {self.package1}", f"   against {self.package2}", ""]
    shown = [d for d in self.differences if d.severity >= min_severity]
    suppressed = len(self.differences) - len(shown)
    for d in sorted(shown, key=lambda d: (-d.severity, d.category.value, d.key)):
        lines.append(str(d))
        if d.left is not None:
            lines.append(f"    package1: {d.left}")
        if d.right is not None:
            lines.append(f"    package2: {d.right}")
    n_errors = len(self.errors)
    n_notices = sum(1 for d in self.differences if d.severity == Severity.NOTICE)
    lines.append("")
    summary = f"{n_errors} errors, {n_notices} notices"
    if suppressed:
        summary += f" ({suppressed} differences suppressed)"
    lines.append(summary)
    return "\n".join(lines)
to_json
to_json() -> dict[str, Any]

JSON-compatible dictionary representation.

Source code in src/whl2conda/api/compare.py
def to_json(self) -> dict[str, Any]:
    """JSON-compatible dictionary representation."""

    def _safe(val: Any) -> Any:
        if val is None or isinstance(val, (bool, int, float, str)):
            return val
        if isinstance(val, (list, tuple, set, frozenset)):
            return sorted(str(v) for v in val)
        return str(val)

    return {
        "package1": str(self.package1),
        "package2": str(self.package2),
        "ok": self.ok,
        "differences": [
            {
                "category": d.category.value,
                "severity": d.severity.name,
                "key": d.key,
                "description": d.description,
                "left": _safe(d.left),
                "right": _safe(d.right),
            }
            for d in self.differences
        ],
    }

DiffCategory

Bases: Enum

Category of a difference between two conda packages.

Source code in src/whl2conda/api/compare.py
class DiffCategory(enum.Enum):
    """Category of a difference between two conda packages."""

    # index.json / metadata
    PACKAGE_NAME = "package-name"
    PACKAGE_VERSION = "package-version"
    BUILD_STRING = "build-string"
    BUILD_NUMBER = "build-number"
    NOARCH_VS_PLATFORM = "noarch-vs-platform"
    LICENSE = "license"
    ABOUT_FIELD = "about-field"
    TIMESTAMP = "timestamp"
    # dependencies
    DEP_MISSING = "dep-missing"
    DEP_EXTRA = "dep-extra"
    DEP_RUN_EXPORT = "dep-run-export"
    DEP_VERSION = "dep-version"
    DEP_UNRENAMED = "dep-unrenamed"
    PYTHON_PIN = "python-pin"
    # files
    FILE_MISSING = "file-missing"
    FILE_EXTRA = "file-extra"
    FILE_CONTENT = "file-content"
    BINARY_CONTENT = "binary-content"
    PYCACHE = "pycache"
    ENTRY_POINT = "entry-point"
    # dist-info
    DIST_INFO_METADATA = "dist-info-metadata"
    DIST_INFO_OTHER = "dist-info-other"
    # info/ bookkeeping
    INFO_EXTRA_FILE = "info-extra-file"

    def __str__(self) -> str:
        return self.value

Difference dataclass

A single difference between two conda packages.

Source code in src/whl2conda/api/compare.py
@dataclass(frozen=True, slots=True)
class Difference:
    """A single difference between two conda packages."""

    category: DiffCategory
    """Category of the difference."""

    severity: Severity
    """Severity of the difference."""

    key: str
    """Locus of the difference, e.g. `index.json:depends` or a file path."""

    description: str
    """Human readable one line description."""

    left: Any = None
    """Relevant value from the first package, if any."""

    right: Any = None
    """Relevant value from the second (reference) package, if any."""

    def __str__(self) -> str:
        return f"{self.severity.name} {self.category} {self.key}: {self.description}"

Attributes

category instance-attribute
category: DiffCategory

Category of the difference.

description instance-attribute
description: str

Human readable one line description.

key instance-attribute
key: str

Locus of the difference, e.g. index.json:depends or a file path.

left class-attribute instance-attribute
left: Any = None

Relevant value from the first package, if any.

right class-attribute instance-attribute
right: Any = None

Relevant value from the second (reference) package, if any.

severity instance-attribute
severity: Severity

Severity of the difference.

Severity

Bases: IntEnum

Severity of a difference between two conda packages.

Source code in src/whl2conda/api/compare.py
class Severity(enum.IntEnum):
    """Severity of a difference between two conda packages."""

    EXPECTED = 0
    """Benign difference inherent to the different build processes."""

    NOTICE = 1
    """Notable difference that is not necessarily a problem."""

    ERROR = 2
    """Unexpected difference that likely indicates a problem."""

Attributes

ERROR class-attribute instance-attribute
ERROR = 2

Unexpected difference that likely indicates a problem.

EXPECTED class-attribute instance-attribute
EXPECTED = 0

Benign difference inherent to the different build processes.

NOTICE class-attribute instance-attribute
NOTICE = 1

Notable difference that is not necessarily a problem.

Functions

compare_conda_packages

compare_conda_packages(
    package1: Path,
    package2: Path,
    *,
    options: CompareOptions | None = None
) -> ComparisonResult

Semantically compare two conda packages.

The comparison is directional: package1 is the package under test (e.g. generated by whl2conda) and package2 is the reference package (e.g. from conda-forge). Differences that are inherent to the different build processes are reported with EXPECTED severity; differences that likely indicate a conversion problem are reported as errors.

PARAMETER DESCRIPTION
package1

.conda/.tar.bz2 file or extracted package directory for the package under test.

TYPE: Path

package2

.conda/.tar.bz2 file or extracted package directory for the reference package.

TYPE: Path

options

comparison options.

TYPE: CompareOptions | None DEFAULT: None

RETURNS DESCRIPTION
ComparisonResult

Comparison result with the list of differences found.

Source code in src/whl2conda/api/compare.py
def compare_conda_packages(
    package1: Path,
    package2: Path,
    *,
    options: CompareOptions | None = None,
) -> ComparisonResult:
    """Semantically compare two conda packages.

    The comparison is directional: `package1` is the package under
    test (e.g. generated by whl2conda) and `package2` is the reference
    package (e.g. from conda-forge). Differences that are inherent to
    the different build processes are reported with EXPECTED severity;
    differences that likely indicate a conversion problem are reported
    as errors.

    Args:
        package1: `.conda`/`.tar.bz2` file or extracted package directory
            for the package under test.
        package2: `.conda`/`.tar.bz2` file or extracted package directory
            for the reference package.
        options: comparison options.

    Returns:
        Comparison result with the list of differences found.
    """
    options = options or CompareOptions()
    with tempfile.TemporaryDirectory(prefix="whl2conda-compare-") as tmpdir:
        tmp_path = Path(tmpdir)
        pkg1 = _ExtractedPackage(_extract_if_needed(package1, tmp_path / "pkg1"))
        pkg2 = _ExtractedPackage(_extract_if_needed(package2, tmp_path / "pkg2"))
        comparer = _PackageComparer(pkg1, pkg2, options)
        differences = comparer.compare()
    return ComparisonResult(package1, package2, differences)