Skip to content

versioning

napt.versioning.compare

Core version comparison utilities for NAPT.

This module is format-agnostic: it does NOT download or read files. It only parses and compares version strings consistently across sources.

version_key

version_key(version: str) -> tuple

Computes a sortable comparison key for a version string.

Uses semver-like parsing with prerelease ordering; falls back to lexicographic comparison when no numeric prefix is found.

Parameters:

Name Type Description Default
version str

Version string to convert.

required

Returns:

Type Description
tuple

An opaque tuple suitable for use as a sort key.

Note

Equal parsed keys (e.g., "v1.2.3" and "1.2.3") produce the same tuple, so the raw string is not included as a tiebreaker.

Source code in napt/versioning/compare.py
def version_key(version: str) -> tuple:
    """Computes a sortable comparison key for a version string.

    Uses semver-like parsing with prerelease ordering; falls back to
    lexicographic comparison when no numeric prefix is found.

    Args:
        version: Version string to convert.

    Returns:
        An opaque tuple suitable for use as a sort key.

    Note:
        Equal parsed keys (e.g., "v1.2.3" and "1.2.3") produce the same
        tuple, so the raw string is not included as a tiebreaker.

    """
    key = _semver_like_key_robust(version)
    release = key[0]
    if release != (0,):
        return ("semverish", key)

    return ("text", version)

compare

compare(version_a: str, version_b: str) -> int

Compares two version strings and returns their ordering.

Parameters:

Name Type Description Default
version_a str

First version string.

required
version_b str

Second version string.

required

Returns:

Type Description
int

-1 if version_a is older than version_b, 0 if equal, 1 if newer.

Source code in napt/versioning/compare.py
def compare(
    version_a: str,
    version_b: str,
) -> int:
    """Compares two version strings and returns their ordering.

    Args:
        version_a: First version string.
        version_b: Second version string.

    Returns:
        -1 if version_a is older than version_b, 0 if equal, 1 if newer.

    """
    from napt.logging import get_global_logger

    logger = get_global_logger()

    left_key = version_key(version_a)
    right_key = version_key(version_b)
    result = (left_key > right_key) - (left_key < right_key)

    if result < 0:
        logger.verbose("VERSION", f"{version_a!r} is older than {version_b!r}")
    elif result > 0:
        logger.verbose("VERSION", f"{version_a!r} is newer than {version_b!r}")
    else:
        logger.verbose("VERSION", f"{version_a!r} is the same as {version_b!r}")
    return result

is_newer

is_newer(remote: str, current: str | None) -> bool

Determines whether a remote version is newer than the current version.

Parameters:

Name Type Description Default
remote str

Version string to check (e.g., from the download source).

required
current str | None

Currently cached version string, or None if not yet downloaded.

required

Returns:

Type Description
bool

True if remote is newer than current. Always True when current is None.

Source code in napt/versioning/compare.py
def is_newer(
    remote: str,
    current: str | None,
) -> bool:
    """Determines whether a remote version is newer than the current version.

    Args:
        remote: Version string to check (e.g., from the download source).
        current: Currently cached version string, or None if not yet downloaded.

    Returns:
        True if remote is newer than current. Always True when current is None.

    """
    from napt.logging import get_global_logger

    logger = get_global_logger()

    if current is None:
        logger.verbose(
            "VERSION",
            f"No current version. Treat {remote!r} as newer",
        )
        return True

    # compare() already logs the comparison result
    return compare(remote, current) > 0

napt.versioning.msi

MSI metadata extraction for NAPT.

This module extracts metadata from Windows Installer (MSI) database files, including ProductVersion, ProductName, and architecture (from Template).

Backend Priority:

  • Windows: PowerShell COM (Windows Installer COM API, always available)
  • Linux/macOS: msiinfo (from the msitools package, must be installed separately)

Installation Requirements:

  • Windows: No additional packages required (PowerShell is always available).
  • Linux/macOS: Install the msitools package (apt-get install msitools, dnf install msitools, or brew install msitools).
Example

Extract metadata including architecture:

from napt.versioning.msi import extract_msi_metadata

metadata = extract_msi_metadata("chrome.msi")
print(f"{metadata.product_name} {metadata.product_version} ({metadata.architecture})")
# Google Chrome 144.0.7559.110 (x64)

Note

This is pure file introspection; no network calls are made. The PowerShell COM backend reads both the Property table (ProductName, ProductVersion) and Summary Information stream (Template/architecture) in a single database open.

MSIMetadata dataclass

Represents metadata extracted from an MSI file.

Attributes:

Name Type Description
product_name str

ProductName from MSI Property table (display name).

product_version str

ProductVersion from MSI Property table.

architecture Architecture

Installer architecture from MSI Template Summary Information property. Always one of "x86", "x64", or "arm64".

Source code in napt/versioning/msi.py
@dataclass(frozen=True)
class MSIMetadata:
    """Represents metadata extracted from an MSI file.

    Attributes:
        product_name: ProductName from MSI Property table (display name).
        product_version: ProductVersion from MSI Property table.
        architecture: Installer architecture from MSI Template Summary
            Information property. Always one of "x86", "x64", or "arm64".
    """

    product_name: str
    product_version: str
    architecture: Architecture

extract_msi_metadata

extract_msi_metadata(file_path: str | Path) -> MSIMetadata

Extracts ProductName, ProductVersion, and architecture from an MSI file.

Reads the MSI Property table (ProductName, ProductVersion) and Summary Information stream (Template/architecture) in a single database open. On Windows, uses the PowerShell COM API. On Linux/macOS, requires msitools.

Parameters:

Name Type Description Default
file_path str | Path

Path to the MSI file.

required

Returns:

Type Description
MSIMetadata

MSI metadata including product name, version, and architecture.

Raises:

Type Description
PackagingError

If the MSI file does not exist or extraction fails.

ConfigError

If the MSI platform is not supported by Intune.

NotImplementedError

If no extraction backend is available on this system.

Example

Extract MSI metadata:

from pathlib import Path
from napt.versioning.msi import extract_msi_metadata

metadata = extract_msi_metadata(Path("chrome.msi"))
print(f"{metadata.product_name} {metadata.product_version} ({metadata.architecture})")
# Google Chrome 131.0.6778.86 (x64)

Note

ProductName may be empty string if not found in MSI. The build phase validates ProductName and raises ConfigError if empty — it is required for detection script generation.

Source code in napt/versioning/msi.py
def extract_msi_metadata(file_path: str | Path) -> MSIMetadata:
    """Extracts ProductName, ProductVersion, and architecture from an MSI file.

    Reads the MSI Property table (ProductName, ProductVersion) and Summary
    Information stream (Template/architecture) in a single database open.
    On Windows, uses the PowerShell COM API. On Linux/macOS, requires msitools.

    Args:
        file_path: Path to the MSI file.

    Returns:
        MSI metadata including product name, version, and architecture.

    Raises:
        PackagingError: If the MSI file does not exist or extraction fails.
        ConfigError: If the MSI platform is not supported by Intune.
        NotImplementedError: If no extraction backend is available on this system.

    Example:
        Extract MSI metadata:
            ```python
            from pathlib import Path
            from napt.versioning.msi import extract_msi_metadata

            metadata = extract_msi_metadata(Path("chrome.msi"))
            print(f"{metadata.product_name} {metadata.product_version} ({metadata.architecture})")
            # Google Chrome 131.0.6778.86 (x64)
            ```

    Note:
        ProductName may be empty string if not found in MSI. The build phase
        validates ProductName and raises ConfigError if empty — it is required
        for detection script generation.

    """
    from napt.logging import get_global_logger

    logger = get_global_logger()
    msi_path = Path(file_path)
    if not msi_path.exists():
        raise PackagingError(f"MSI not found: {msi_path}")

    logger.verbose("MSI", f"Extracting metadata from: {msi_path.name}")

    # PowerShell COM (Windows only)
    if sys.platform.startswith("win"):
        logger.debug("MSI", "Trying backend: PowerShell COM...")
        escaped_path = str(msi_path).replace("'", "''")
        ps_script = f"""
$installer = New-Object -ComObject WindowsInstaller.Installer
$db = $installer.OpenDatabase('{escaped_path}', 0)
if ($null -eq $db) {{
    Write-Error "Failed to open database"
    exit 1
}}
$view = $db.OpenView("SELECT Property, Value FROM Property WHERE Property = 'ProductName' OR Property = 'ProductVersion'")
$view.Execute()
$props = @{{}}
while ($record = $view.Fetch()) {{
    $props[$record.StringData(1)] = $record.StringData(2)
}}
$view.Close()
if (-not $props['ProductVersion']) {{
    Write-Error "ProductVersion not found"
    exit 1
}}
$sumInfo = $db.SummaryInformation(0)
$template = $sumInfo.Property(7)
$db.Close()
if (-not $template) {{
    Write-Error "Template (Summary Information Property 7) not found"
    exit 1
}}
@($props['ProductName'], $props['ProductVersion'], $template) -join "`n"
"""
        try:
            ps_result = subprocess.run(
                ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
                check=True,
                capture_output=True,
                text=True,
                timeout=10,
            )
            output_lines = ps_result.stdout.splitlines()
            product_name = output_lines[0] if len(output_lines) > 0 else ""
            product_version = output_lines[1] if len(output_lines) > 1 else ""
            template = output_lines[2] if len(output_lines) > 2 else ""

            if not product_version:
                raise PackagingError("ProductVersion not found in MSI Property table.")
            if not template:
                raise PackagingError(
                    "Template not found in MSI Summary Information stream."
                )

            architecture = _architecture_from_template(template)
            logger.verbose(
                "MSI",
                f"[OK] Extracted: {product_name} {product_version} "
                f"({architecture}) (via PowerShell COM)",
            )
            return MSIMetadata(
                product_name=product_name,
                product_version=product_version,
                architecture=architecture,
            )
        except subprocess.CalledProcessError as err:
            stderr_output = err.stderr if err.stderr else "No stderr captured"
            raise PackagingError(
                f"PowerShell MSI query failed (exit {err.returncode}). "
                f"stderr: {stderr_output}"
            ) from err
        except subprocess.TimeoutExpired:
            raise PackagingError("PowerShell MSI query timed out") from None

    # msiinfo (Linux/macOS)
    msiinfo_bin = shutil.which("msiinfo")
    if msiinfo_bin:
        logger.debug("MSI", "Trying backend: msiinfo (msitools)...")
        try:
            property_result = subprocess.run(
                [msiinfo_bin, "export", str(msi_path), "Property"],
                check=True,
                capture_output=True,
                text=True,
            )
            properties: dict[str, str] = {}
            for line in property_result.stdout.splitlines():
                columns = line.strip().split("\t", 1)
                if len(columns) == 2:
                    properties[columns[0]] = columns[1]

            product_version = properties.get("ProductVersion", "")
            if not product_version:
                raise PackagingError("ProductVersion not found in MSI Property output.")

            suminfo_result = subprocess.run(
                [msiinfo_bin, "suminfo", str(msi_path)],
                check=True,
                capture_output=True,
                text=True,
            )
            template: str | None = None
            for line in suminfo_result.stdout.splitlines():
                if line.startswith("Template:"):
                    template = line.split(":", 1)[1].strip()
                    break

            if template is None:
                raise PackagingError(
                    "Template not found in MSI Summary Information stream."
                )

            architecture = _architecture_from_template(template)
            product_name = properties.get("ProductName", "")
            logger.verbose(
                "MSI",
                f"[OK] Extracted: {product_name} {product_version} "
                f"({architecture}) (via msiinfo)",
            )
            return MSIMetadata(
                product_name=product_name,
                product_version=product_version,
                architecture=architecture,
            )
        except subprocess.CalledProcessError as err:
            raise PackagingError(f"msiinfo failed: {err}") from err

    raise NotImplementedError(
        "MSI metadata extraction is not available on this host. "
        "On Windows, ensure PowerShell is available. "
        "On Linux/macOS, install 'msitools'."
    )