Skip to content

versioning

napt.versioning.keys

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 (MSI, EXE, generic strings).

DiscoveredVersion dataclass

Represents a version string discovered from a source.

Attributes:

Name Type Description
version str

Raw version string (e.g., "140.0.7339.128").

source str

Where it came from (e.g., "regex_in_url", "msi").

Source code in napt/versioning/keys.py
@dataclass(frozen=True)
class DiscoveredVersion:
    """Represents a version string discovered from a source.

    Attributes:
        version: Raw version string (e.g., "140.0.7339.128").
        source: Where it came from (e.g., "regex_in_url", "msi").

    """

    version: str
    source: str

VersionInfo dataclass

Represents version metadata obtained without downloading the installer.

Used by version-first strategies (web_scrape, api_github, api_json) that can determine version and download URL without fetching the installer.

Attributes:

Name Type Description
version str

Raw version string (e.g., "140.0.7339.128").

download_url str

URL to download the installer.

source str

Strategy name for logging (e.g., "web_scrape", "api_github").

Source code in napt/versioning/keys.py
@dataclass(frozen=True)
class VersionInfo:
    """Represents version metadata obtained without downloading the installer.

    Used by version-first strategies (web_scrape, api_github, api_json)
    that can determine version and download URL without fetching the installer.

    Attributes:
        version: Raw version string (e.g., "140.0.7339.128").
        download_url: URL to download the installer.
        source: Strategy name for logging (e.g., "web_scrape", "api_github").

    """

    version: str
    download_url: str
    source: str

version_key_any

version_key_any(s: str, *, source: SourceHint = 'string') -> tuple

Compute a comparable key for any version string.

  • MSI/EXE: purely numeric (truncated to 3/4 parts).
  • Generic string: semver-like robust key; if no numeric prefix, fallback to ("text", raw).
Source code in napt/versioning/keys.py
def version_key_any(s: str, *, source: SourceHint = "string") -> tuple:
    """Compute a comparable key for any version string.

    - MSI/EXE: purely numeric (truncated to 3/4 parts).
    - Generic string: semver-like robust key; if no numeric prefix,
        fallback to ("text", raw).
    """
    if source in ("msi", "exe"):
        nums = _clip_for_source(_ints_from_text(s), source)
        return ("num", nums)

    key = _semver_like_key_robust(s)
    release = key[0]
    if release != (0,):
        # IMPORTANT: We do NOT include the raw string as a tiebreaker.
        # This makes "v1.2.3" == "1.2.3" when the parsed keys are equal.
        return ("semverish", key)

    return ("text", s)

compare_any

compare_any(a: str, b: str, *, source: SourceHint = 'string') -> int

Compare two versions with a source hint. Returns -1 if a < b, 0 if equal, 1 if a > b.

Source code in napt/versioning/keys.py
def compare_any(
    a: str,
    b: str,
    *,
    source: SourceHint = "string",
) -> int:
    """Compare two versions with a source hint.
    Returns -1 if a < b, 0 if equal, 1 if a > b.
    """
    from napt.logging import get_global_logger

    logger = get_global_logger()

    if source in ("msi", "exe"):
        try:
            aa = _clip_for_source(_ints_from_text(a), source)
            bb = _clip_for_source(_ints_from_text(b), source)
            aa, bb = _pad_equal(aa, bb)
            result = (aa > bb) - (aa < bb)
        except ValueError:
            # If vendor sneaks letters into numeric fields, fallback to generic parsing.
            ka = version_key_any(a, source="string")
            kb = version_key_any(b, source="string")
            result = (ka > kb) - (ka < kb)
    else:
        ka = version_key_any(a, source="string")
        kb = version_key_any(b, source="string")
        result = (ka > kb) - (ka < kb)

    # Log comparison result if verbose mode is enabled
    if result < 0:
        logger.verbose("VERSION", f"{a!r} is older than {b!r} (source={source})")
    elif result > 0:
        logger.verbose("VERSION", f"{a!r} is newer than {b!r} (source={source})")
    else:
        logger.verbose("VERSION", f"{a!r} is the same as {b!r} (source={source})")
    return result

is_newer_any

is_newer_any(remote: str, current: str | None, *, source: SourceHint = 'string') -> bool

Decide if 'remote' should be considered newer than 'current'. Returns True iff remote > current under the given source semantics.

Source code in napt/versioning/keys.py
def is_newer_any(
    remote: str,
    current: str | None,
    *,
    source: SourceHint = "string",
) -> bool:
    """Decide if 'remote' should be considered newer than 'current'.
    Returns True iff remote > current under the given source semantics.
    """
    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 (source={source})",
        )
        return True

    cmpv = compare_any(remote, current, source=source)
    # Note: compare_any() already logs the comparison result, so we don't need to log again here
    return cmpv > 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:

On Windows:

  1. PowerShell COM (Windows Installer COM API, always available)

On Linux/macOS:

  1. msiinfo (from msitools package, must be installed separately)

Installation Requirements:

Windows:

  • No additional packages required (PowerShell is always available)

Linux/macOS:

  • Install msitools package:
    • Debian/Ubuntu: sudo apt-get install msitools
    • RHEL/Fedora: sudo dnf install msitools
    • macOS: brew install msitools
Example

Extract version from MSI:

from pathlib import Path
from napt.versioning.msi import version_from_msi_product_version
discovered = version_from_msi_product_version("chrome.msi")
print(f"{discovered.version} from {discovered.source}")
# 141.0.7390.123 from msi

Extract full 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

version_from_msi_product_version

version_from_msi_product_version(file_path: str | Path) -> DiscoveredVersion

Extract ProductVersion from an MSI file.

Uses cross-platform backends to read the MSI Property table. 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
DiscoveredVersion

Discovered version with source information.

Raises:

Type Description
PackagingError

If the MSI file doesn't exist or version extraction fails.

NotImplementedError

If no extraction backend is available on this system.

Source code in napt/versioning/msi.py
def version_from_msi_product_version(
    file_path: str | Path,
) -> DiscoveredVersion:
    """Extract ProductVersion from an MSI file.

    Uses cross-platform backends to read the MSI Property table.
    On Windows, uses the PowerShell COM API. On Linux/macOS, requires msitools.

    Args:
        file_path: Path to the MSI file.

    Returns:
        Discovered version with source information.

    Raises:
        PackagingError: If the MSI file doesn't exist or version extraction fails.
        NotImplementedError: If no extraction backend is available on this system.

    """
    from napt.logging import get_global_logger

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

    logger.verbose("VERSION", "Strategy: msi")
    logger.verbose("VERSION", f"Extracting version from: {p.name}")

    # Try PowerShell with Windows Installer COM on Windows
    if sys.platform.startswith("win"):
        logger.debug("VERSION", "Trying backend: PowerShell COM...")
        try:
            escaped_path = str(p).replace("'", "''")
            ps_script = f"""
$installer = New-Object -ComObject WindowsInstaller.Installer
$db = $installer.OpenDatabase('{escaped_path}', 0)
$view = $db.OpenView("SELECT Value FROM Property WHERE Property='ProductVersion'")
$view.Execute()
$record = $view.Fetch()
if ($record) {{
    $record.StringData(1)
}} else {{
    Write-Error "ProductVersion not found"
    exit 1
}}
"""
            result = subprocess.run(
                ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
                check=True,
                capture_output=True,
                text=True,
                timeout=10,
            )
            version = result.stdout.strip()
            if version:
                logger.verbose(
                    "VERSION", f"Success! Extracted: {version} (via PowerShell COM)"
                )
                return DiscoveredVersion(version=version, source="msi")
        except subprocess.CalledProcessError as err:
            raise PackagingError(f"PowerShell MSI query failed: {err}") from err
        except subprocess.TimeoutExpired:
            raise PackagingError("PowerShell MSI query timed out") from None

    # Try msiinfo on Linux/macOS
    msiinfo_bin = shutil.which("msiinfo")
    if msiinfo_bin:
        logger.debug("VERSION", "Trying backend: msiinfo (msitools)...")
        try:
            result = subprocess.run(
                [msiinfo_bin, "export", str(p), "Property"],
                check=True,
                capture_output=True,
                text=True,
            )
            version_str: str | None = None
            for line in result.stdout.splitlines():
                parts = line.strip().split("\t", 1)  # "Property<TAB>Value"
                if len(parts) == 2 and parts[0] == "ProductVersion":
                    version_str = parts[1]
                    break
            if not version_str:
                raise PackagingError("ProductVersion not found in MSI Property output.")
            logger.verbose(
                "VERSION", f"Success! Extracted: {version_str} (via msiinfo)"
            )
            return DiscoveredVersion(version=version_str, source="msi")
        except subprocess.CalledProcessError as err:
            raise PackagingError(f"msiinfo failed: {err}") from err

    logger.debug("VERSION", "No MSI extraction backend available on this system")
    raise NotImplementedError(
        "MSI version extraction is not available on this host. "
        "On Windows, ensure PowerShell is available. "
        "On Linux/macOS, install 'msitools'."
    )

extract_msi_metadata

extract_msi_metadata(file_path: str | Path) -> MSIMetadata

Extract ProductName, ProductVersion, and architecture from 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 doesn't exist or metadata 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:
    """Extract ProductName, ProductVersion, and architecture from 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 doesn't exist or metadata 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()
    p = Path(file_path)
    if not p.exists():
        raise PackagingError(f"MSI not found: {p}")

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

    # PowerShell COM (Windows only)
    if sys.platform.startswith("win"):
        logger.debug("MSI", "Trying backend: PowerShell COM...")
        escaped_path = str(p).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:
            result = subprocess.run(
                ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
                check=True,
                capture_output=True,
                text=True,
                timeout=10,
            )
            lines = result.stdout.splitlines()
            product_name = lines[0] if len(lines) > 0 else ""
            product_version = lines[1] if len(lines) > 1 else ""
            template = lines[2] if len(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:
            prop_result = subprocess.run(
                [msiinfo_bin, "export", str(p), "Property"],
                check=True,
                capture_output=True,
                text=True,
            )
            props: dict[str, str] = {}
            for line in prop_result.stdout.splitlines():
                parts = line.strip().split("\t", 1)
                if len(parts) == 2:
                    props[parts[0]] = parts[1]

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

            suminfo_result = subprocess.run(
                [msiinfo_bin, "suminfo", str(p)],
                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 = props.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'."
    )

architecture_from_template

architecture_from_template(template: str) -> Architecture

Parse MSI Template property into NAPT architecture value.

The Template property format is platform;language_id (semicolon, then optional language codes). Examples: "x64;1033", "Intel;1033,1041", ";1033" (empty platform defaults to Intel).

Parameters:

Name Type Description Default
template str

Raw Template property string from MSI Summary Information.

required

Returns:

Type Description
Architecture

Architecture value: "x86", "x64", or "arm64".

Raises:

Type Description
ConfigError

If the platform is not supported by Intune (Itanium, ARM32).

Example

Parse template strings:

arch = architecture_from_template("x64;1033")
# Returns: "x64"

arch = architecture_from_template("Intel;1033")
# Returns: "x86"

arch = architecture_from_template(";1033")
# Returns: "x86" (empty defaults to Intel)

Source code in napt/versioning/msi.py
def architecture_from_template(template: str) -> Architecture:
    """Parse MSI Template property into NAPT architecture value.

    The Template property format is platform;language_id (semicolon, then
    optional language codes). Examples: "x64;1033", "Intel;1033,1041",
    ";1033" (empty platform defaults to Intel).

    Args:
        template: Raw Template property string from MSI Summary Information.

    Returns:
        Architecture value: "x86", "x64", or "arm64".

    Raises:
        ConfigError: If the platform is not supported by Intune (Itanium, ARM32).

    Example:
        Parse template strings:
            ```python
            arch = architecture_from_template("x64;1033")
            # Returns: "x64"

            arch = architecture_from_template("Intel;1033")
            # Returns: "x86"

            arch = architecture_from_template(";1033")
            # Returns: "x86" (empty defaults to Intel)
            ```

    """
    # Split on semicolon and take only the first token (platform)
    # Discard remaining tokens (language codes like 1033)
    platform = template.split(";")[0].strip().lower()

    # Empty platform defaults to Intel (x86) per Microsoft docs
    if not platform:
        return "x86"

    # Check for unsupported platforms first
    if platform in _UNSUPPORTED_PLATFORMS:
        raise ConfigError(
            f"MSI platform '{platform}' is not supported. "
            f"{_UNSUPPORTED_PLATFORMS[platform]}."
        )

    # Map to NAPT architecture
    arch = _TEMPLATE_TO_ARCH.get(platform)
    if arch is None:
        raise ConfigError(
            f"Unknown MSI platform '{platform}' in Template property. "
            f"Expected one of: Intel, x64, AMD64, Arm64."
        )

    return arch  # type: ignore[return-value]