Skip to content

versioning

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).
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

meta = extract_msi_metadata(Path("chrome.msi"))
print(f"{meta.product_name} {meta.product_version} ({meta.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, because 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

            meta = extract_msi_metadata(Path("chrome.msi"))
            print(f"{meta.product_name} {meta.product_version} ({meta.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, because 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...")
        quoted_path = ps_single_quote(str(msi_path))
        query = (
            "SELECT Property, Value FROM Property "
            "WHERE Property = 'ProductName' OR Property = 'ProductVersion'"
        )
        # PowerShell writes captured stdout in the console's OEM code page
        # (cp437 on English Windows), which Python would decode as the locale
        # code page (cp1252), mangling every non-ASCII character of a product
        # name. Changing the console's code page from inside the script would
        # fix that but leaks into the user's terminal for the rest of the
        # session, so the values go through a UTF-8 file instead. Its path
        # travels in an environment variable, keeping it out of the script.
        with tempfile.NamedTemporaryFile(
            prefix="napt-msi-", suffix=".txt", delete=False
        ) as handle:
            out_path = Path(handle.name)
        ps_script = f"""
$installer = New-Object -ComObject WindowsInstaller.Installer
$db = $installer.OpenDatabase({quoted_path}, 0)
if ($null -eq $db) {{
    Write-Error "Failed to open database"
    exit 1
}}
$view = $db.OpenView("{query}")
$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
}}
$utf8 = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllLines(
    $env:NAPT_MSI_OUT,
    [string[]]@($props['ProductName'], $props['ProductVersion'], $template),
    $utf8
)
"""
        try:
            subprocess.run(
                ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
                check=True,
                capture_output=True,
                text=True,
                errors="replace",
                timeout=10,
                env={**os.environ, "NAPT_MSI_OUT": str(out_path)},
            )
            output_lines = out_path.read_text(encoding="utf-8-sig").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
        finally:
            out_path.unlink(missing_ok=True)

    # 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'."
    )

napt.versioning.ordering

Version ordering as managed devices see it.

Whether a release installs over another is decided on the device by Compare-VersionString in napt/build/templates/_shared_functions.ps1, which the detection and requirements scripts call. This module mirrors that function, so that when NAPT calls a release a downgrade it means the same thing the device will: devices on the other version will not take it.

The mirror is deliberately no smarter than the original. Each . or - separated segment contributes its leading digits, a segment without leading digits counts as 0, and missing trailing segments count as 0. A v prefix or a prerelease tag is therefore not understood on either side. tests/test_version_ordering.py runs one table of cases through both implementations to keep them in step.

version_parts

version_parts(version: str) -> list[int]

Parses a version string into the numbers a device compares.

Parameters:

Name Type Description Default
version str

Version string, for example "140.0.7339.128".

required

Returns:

Type Description
list[int]

One integer per segment: its leading digits, or 0 when it has none.

Source code in napt/versioning/ordering.py
def version_parts(version: str) -> list[int]:
    """Parses a version string into the numbers a device compares.

    Args:
        version: Version string, for example ``"140.0.7339.128"``.

    Returns:
        One integer per segment: its leading digits, or 0 when it has none.

    """
    parts: list[int] = []
    for segment in _SEGMENT_SEPARATORS.split(version):
        match = _LEADING_DIGITS.match(segment)
        parts.append(int(match.group()) if match else 0)
    return parts

compare_versions

compare_versions(left: str, right: str) -> int

Compares two version strings the way a managed device does.

Parameters:

Name Type Description Default
left str

First version string.

required
right str

Second version string.

required

Returns:

Type Description
int

-1 when left is lower, 0 when they are equal, and 1 when left is higher.

Source code in napt/versioning/ordering.py
def compare_versions(left: str, right: str) -> int:
    """Compares two version strings the way a managed device does.

    Args:
        left: First version string.
        right: Second version string.

    Returns:
        -1 when ``left`` is lower, 0 when they are equal, and 1 when
            ``left`` is higher.

    """
    left_parts = version_parts(left)
    right_parts = version_parts(right)
    length = max(len(left_parts), len(right_parts))
    left_parts += [0] * (length - len(left_parts))
    right_parts += [0] * (length - len(right_parts))
    return (left_parts > right_parts) - (left_parts < right_parts)

is_downgrade

is_downgrade(candidate: str, current: str | None) -> bool

Reports whether a release is lower than the one it would replace.

Parameters:

Name Type Description Default
candidate str

Version of the release being considered.

required
current str | None

Version it would replace, or None when there is none.

required

Returns:

Type Description
bool

True when devices on current would not take candidate.

Source code in napt/versioning/ordering.py
def is_downgrade(candidate: str, current: str | None) -> bool:
    """Reports whether a release is lower than the one it would replace.

    Args:
        candidate: Version of the release being considered.
        current: Version it would replace, or None when there is none.

    Returns:
        True when devices on ``current`` would not take ``candidate``.

    """
    return current is not None and compare_versions(candidate, current) < 0