ladybird/Meta/inspect-vcpkg-ports.py
Timothy Flynn 61a53553ab Meta: Add a script to inspect installed vcpkg ports
This script inspects the actually-used versions of vcpkg ports in the
Build/vcpkg directory. This is not just the ports listed in vcpkg.json,
but the ports that were actually installed (and their dependencies). It
prints the installed versions, chosen features, and more info for each
port to either stdout as a table or to a file as JSON.

It also contains a mode to diff two JSON exports. This makes it handy to
know what changed when e.g. updating the vcpkg baseline.
2026-06-19 08:12:42 -04:00

757 lines
24 KiB
Python
Executable file

#!/usr/bin/env python3
# Copyright (c) 2026-present, the Ladybird developers.
#
# SPDX-License-Identifier: BSD-2-Clause
import argparse
import json
import os
import pathlib
import subprocess
import sys
from typing import Any
from typing import Optional
META_SOURCE_DIR = pathlib.Path(__file__).resolve().parent
LADYBIRD_SOURCE_DIR = META_SOURCE_DIR.parent
def relative_to_source_tree(path: pathlib.Path, source_root: pathlib.Path = LADYBIRD_SOURCE_DIR) -> str:
try:
return str(path.resolve().relative_to(source_root))
except ValueError:
return str(path)
def read_json(path: pathlib.Path) -> Optional[Any]:
try:
with open(path, "r", encoding="utf-8") as file:
return json.load(file)
except FileNotFoundError:
return None
def git_output(repository: pathlib.Path, *args: str) -> Optional[str]:
if not repository.is_dir():
return None
result = subprocess.run(
["git", "-C", str(repository), *args],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
if result.returncode != 0:
return None
output = result.stdout.strip()
return output if output else None
def parse_control_paragraphs(path: pathlib.Path) -> list[dict[str, str]]:
paragraphs: list[dict[str, str]] = []
current_paragraph: dict[str, str] = {}
current_field: Optional[str] = None
with open(path, "r", encoding="utf-8") as file:
for raw_line in file:
line = raw_line.rstrip("\n")
if not line:
if current_paragraph:
paragraphs.append(current_paragraph)
current_paragraph = {}
current_field = None
continue
if line[0].isspace():
if current_field is not None:
current_paragraph[current_field] += "\n" + line.strip()
continue
field, separator, value = line.partition(":")
if not separator:
continue
current_field = field
current_paragraph[current_field] = value.strip()
if current_paragraph:
paragraphs.append(current_paragraph)
return paragraphs
def split_control_list(value: Optional[str]) -> list[str]:
if not value:
return []
entries: list[str] = []
current_entry: list[str] = []
nesting_depth = 0
for character in value:
if character in "([":
nesting_depth += 1
elif character in ")]" and nesting_depth > 0:
nesting_depth -= 1
elif character == "," and nesting_depth == 0:
entry = "".join(current_entry).strip()
if entry:
entries.append(entry)
current_entry = []
continue
current_entry.append(character)
entry = "".join(current_entry).strip()
if entry:
entries.append(entry)
return entries
def dependency_name(dependency: Any) -> Optional[str]:
if isinstance(dependency, str):
return dependency
if isinstance(dependency, dict):
name = dependency.get("name")
if isinstance(name, str):
return name
return None
def collect_manifest_dependency_names(manifest_path: pathlib.Path) -> tuple[set[str], set[str], Optional[str]]:
manifest = read_json(manifest_path)
if not isinstance(manifest, dict):
return set(), set(), None
direct_dependencies = {
name
for name in (dependency_name(dependency) for dependency in manifest.get("dependencies", []))
if name is not None
}
feature_dependencies: set[str] = set()
for feature in manifest.get("features", {}).values():
if not isinstance(feature, dict):
continue
feature_dependencies.update(
name
for name in (dependency_name(dependency) for dependency in feature.get("dependencies", []))
if name is not None
)
builtin_baseline = manifest.get("builtin-baseline")
if not isinstance(builtin_baseline, str):
builtin_baseline = None
return direct_dependencies, feature_dependencies, builtin_baseline
def exact_version(version: Optional[str], port_version: Optional[str]) -> Optional[str]:
if not version:
return None
if port_version and port_version != "0":
return f"{version}#{port_version}"
return version
def port_version_value(port_version: Optional[str]) -> int:
if not port_version:
return 0
try:
return int(port_version)
except ValueError:
return 0
def parse_installed_ports(installed_root: pathlib.Path) -> list[dict[str, Any]]:
status_path = installed_root / "vcpkg" / "status"
records: dict[tuple[str, str], dict[str, Any]] = {}
for paragraph in parse_control_paragraphs(status_path):
if paragraph.get("Status") != "install ok installed":
continue
name = paragraph.get("Package")
triplet = paragraph.get("Architecture")
if not name or not triplet:
continue
key = (name, triplet)
record = records.setdefault(
key,
{
"name": name,
"triplet": triplet,
"version": None,
"port_version": 0,
"exact_version": None,
"abi": None,
"features": set(),
"dependencies": set(),
"base_dependencies": [],
"feature_dependencies": {},
"description": None,
},
)
dependencies = split_control_list(paragraph.get("Depends"))
feature = paragraph.get("Feature")
if feature:
record["features"].add(feature)
record["feature_dependencies"][feature] = dependencies
record["dependencies"].update(dependencies)
continue
version = paragraph.get("Version")
port_version = paragraph.get("Port-Version")
record["version"] = version
record["port_version"] = port_version_value(port_version)
record["exact_version"] = exact_version(version, port_version)
record["abi"] = paragraph.get("Abi")
record["base_dependencies"] = dependencies
record["dependencies"].update(dependencies)
record["description"] = paragraph.get("Description")
ports: list[dict[str, Any]] = []
for record in records.values():
record["features"] = sorted(record["features"])
record["dependencies"] = sorted(record["dependencies"])
record["feature_dependencies"] = {
feature: dependencies for feature, dependencies in sorted(record["feature_dependencies"].items())
}
ports.append(record)
return sorted(ports, key=lambda port: (port["triplet"], port["name"]))
def parse_abi_info_file(path: pathlib.Path) -> Optional[dict[str, str]]:
if not path.is_file():
return None
entries: dict[str, str] = {}
with open(path, "r", encoding="utf-8") as file:
for raw_line in file:
line = raw_line.strip()
if not line:
continue
key, separator, value = line.partition(" ")
if separator:
entries[key] = value
else:
entries[key] = ""
return entries
def abi_info_for_port(
source_root: pathlib.Path, vcpkg_root: pathlib.Path, name: str, triplet: str
) -> Optional[dict[str, Any]]:
path = vcpkg_root / "buildtrees" / name / f"{triplet}.vcpkg_abi_info.txt"
entries = parse_abi_info_file(path)
if entries is None:
return None
return {
"path": relative_to_source_tree(path, source_root),
"entries": entries,
"features": entries.get("features", "").split(),
"portfile_sha256": entries.get("portfile.cmake"),
"port_manifest_sha256": entries.get("vcpkg.json"),
"ports_cmake_sha256": entries.get("ports.cmake"),
"triplet_abi": entries.get("triplet_abi"),
}
def checksum_values(package: dict[str, Any]) -> list[dict[str, str]]:
checksums = package.get("checksums", [])
if not isinstance(checksums, list):
return []
result: list[dict[str, str]] = []
for checksum in checksums:
if not isinstance(checksum, dict):
continue
algorithm = checksum.get("algorithm")
value = checksum.get("checksumValue")
if isinstance(algorithm, str) and isinstance(value, str):
result.append({"algorithm": algorithm, "value": value})
return result
def spdx_info_for_port(
source_root: pathlib.Path, installed_root: pathlib.Path, name: str, triplet: str
) -> Optional[dict[str, Any]]:
path = installed_root / triplet / "share" / name / "vcpkg.spdx.json"
spdx = read_json(path)
if not isinstance(spdx, dict):
return None
packages = spdx.get("packages", [])
if not isinstance(packages, list):
packages = []
package_by_id = {
package.get("SPDXID"): package for package in packages if isinstance(package, dict) and package.get("SPDXID")
}
def package_details(spdx_id: str) -> Optional[dict[str, Any]]:
package = package_by_id.get(spdx_id)
if not isinstance(package, dict):
return None
return {
"name": package.get("name"),
"version": package.get("versionInfo"),
"download_location": package.get("downloadLocation"),
"homepage": package.get("homepage"),
"license": package.get("licenseConcluded"),
"checksums": checksum_values(package),
}
resources: list[dict[str, Any]] = []
for package in packages:
if not isinstance(package, dict):
continue
spdx_id = package.get("SPDXID")
if not isinstance(spdx_id, str) or not spdx_id.startswith("SPDXRef-resource-"):
continue
resources.append(
{
"name": package.get("name"),
"download_location": package.get("downloadLocation"),
"checksums": checksum_values(package),
}
)
creation_info = spdx.get("creationInfo", {})
if not isinstance(creation_info, dict):
creation_info = {}
creators = creation_info.get("creators", [])
if not isinstance(creators, list):
creators = []
return {
"path": relative_to_source_tree(path, source_root),
"name": spdx.get("name"),
"created": creation_info.get("created"),
"creators": creators,
"port": package_details("SPDXRef-port"),
"binary": package_details("SPDXRef-binary"),
"resources": resources,
}
def configured_overlay_ports(source_root: pathlib.Path) -> list[pathlib.Path]:
configuration = read_json(source_root / "vcpkg-configuration.json")
if not isinstance(configuration, dict):
return []
overlay_ports = configuration.get("overlay-ports", [])
if not isinstance(overlay_ports, list):
return []
paths: list[pathlib.Path] = []
for overlay_port in overlay_ports:
if not isinstance(overlay_port, str):
continue
path = pathlib.Path(overlay_port)
if not path.is_absolute():
path = source_root / path
paths.append(path)
return paths
def port_source_info(source_root: pathlib.Path, vcpkg_root: pathlib.Path, name: str) -> dict[str, Optional[str]]:
for overlay_path in configured_overlay_ports(source_root):
if overlay_path.name == name and (overlay_path / "vcpkg.json").is_file():
return {
"kind": "overlay",
"directory": relative_to_source_tree(overlay_path, source_root),
}
nested_overlay_path = overlay_path / name
if (nested_overlay_path / "vcpkg.json").is_file():
return {
"kind": "overlay",
"directory": relative_to_source_tree(nested_overlay_path, source_root),
}
builtin_path = vcpkg_root / "ports" / name
if (builtin_path / "vcpkg.json").is_file():
return {
"kind": "builtin",
"directory": relative_to_source_tree(builtin_path, source_root),
}
return {
"kind": "unknown",
"directory": None,
}
def manifest_path_for_installed_root(source_root: pathlib.Path, installed_root: pathlib.Path) -> pathlib.Path:
manifest_info = read_json(installed_root / "vcpkg" / "manifest-info.json")
if isinstance(manifest_info, dict):
manifest_path = manifest_info.get("manifest-path")
if isinstance(manifest_path, str):
return pathlib.Path(manifest_path)
return source_root / "vcpkg.json"
def discover_installed_roots(
source_root: pathlib.Path, build_dir: pathlib.Path, vcpkg_root: pathlib.Path
) -> list[pathlib.Path]:
candidates: list[pathlib.Path] = []
env_installed_root = os.environ.get("VCPKG_INSTALLED_DIR")
if env_installed_root:
candidates.append(pathlib.Path(env_installed_root))
for status_path in build_dir.glob("*/vcpkg_installed/vcpkg/status"):
candidates.append(status_path.parent.parent)
manifest_installed_root = source_root / "vcpkg_installed"
if (manifest_installed_root / "vcpkg" / "status").is_file():
candidates.append(manifest_installed_root)
classic_installed_root = vcpkg_root / "installed"
if (classic_installed_root / "vcpkg" / "status").is_file():
candidates.append(classic_installed_root)
unique_candidates: list[pathlib.Path] = []
seen: set[pathlib.Path] = set()
for candidate in candidates:
resolved_candidate = candidate.resolve()
if resolved_candidate in seen:
continue
seen.add(resolved_candidate)
if (resolved_candidate / "vcpkg" / "status").is_file():
unique_candidates.append(resolved_candidate)
return sorted(unique_candidates)
def build_report() -> dict[str, Any]:
source_root = LADYBIRD_SOURCE_DIR.resolve()
vcpkg_root = pathlib.Path(os.environ.get("VCPKG_ROOT") or source_root / "Build" / "vcpkg").resolve()
installed_roots = discover_installed_roots(source_root, source_root / "Build", vcpkg_root)
if not installed_roots:
print(
"No vcpkg install trees found. Set VCPKG_INSTALLED_DIR or run a manifest install first.",
file=sys.stderr,
)
sys.exit(1)
vcpkg_revision = git_output(vcpkg_root, "rev-parse", "HEAD")
vcpkg_dirty_status = git_output(vcpkg_root, "status", "--porcelain")
report = {
"source_root": str(source_root),
"vcpkg_root": str(vcpkg_root),
"vcpkg_root_revision": vcpkg_revision,
"vcpkg_root_dirty": bool(vcpkg_dirty_status),
"installed_roots": [],
}
for installed_root in installed_roots:
manifest_path = manifest_path_for_installed_root(source_root, installed_root)
direct_dependencies, feature_dependencies, builtin_baseline = collect_manifest_dependency_names(manifest_path)
ports = []
for port in parse_installed_ports(installed_root):
name = port["name"]
triplet = port["triplet"]
port.update(
{
"declared_in_manifest_dependencies": name in direct_dependencies,
"declared_in_manifest_feature_dependencies": name in feature_dependencies,
"port_source": port_source_info(source_root, vcpkg_root, name),
"abi_info": abi_info_for_port(source_root, vcpkg_root, name, triplet),
"spdx": spdx_info_for_port(source_root, installed_root, name, triplet),
}
)
ports.append(port)
report["installed_roots"].append(
{
"path": str(installed_root),
"manifest_path": str(manifest_path),
"manifest_builtin_baseline": builtin_baseline,
"ports": ports,
"port_count": len(ports),
}
)
return report
def report_has_duplicate_port_keys(report: dict[str, Any]) -> bool:
keys: set[str] = set()
for installed_root in report.get("installed_roots", []):
for port in installed_root.get("ports", []):
name = port.get("name")
triplet = port.get("triplet")
if not name or not triplet:
continue
key = f"{triplet}/{name}"
if key in keys:
return True
keys.add(key)
return False
def report_ports_by_key(report: dict[str, Any], root_qualified: bool) -> dict[str, dict[str, Any]]:
entries_by_key: dict[str, list[dict[str, Any]]] = {}
for installed_root in report.get("installed_roots", []):
installed_root_path = installed_root.get("path", "")
for port in installed_root.get("ports", []):
name = port.get("name")
triplet = port.get("triplet")
if not name or not triplet:
continue
key = f"{triplet}/{name}"
entries_by_key.setdefault(key, []).append(
{
"installed_root": installed_root_path,
"port": port,
}
)
if not root_qualified:
return {key: entries[0] for key, entries in entries_by_key.items()}
root_qualified_entries: dict[str, dict[str, Any]] = {}
for key, entries in entries_by_key.items():
for entry in entries:
root_qualified_key = f"{entry['installed_root']}:{key}"
root_qualified_entries[root_qualified_key] = entry
return root_qualified_entries
def load_json_report(path: str) -> dict[str, Any]:
try:
with open(path, "r", encoding="utf-8") as file:
report = json.load(file)
except FileNotFoundError:
print(f"{path}: file not found", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError as error:
print(f"{path}: invalid JSON: {error}", file=sys.stderr)
sys.exit(1)
if not isinstance(report, dict) or not isinstance(report.get("installed_roots"), list):
print(f"{path}: not a JSON report generated by this script", file=sys.stderr)
sys.exit(1)
return report
def port_version(entry: Optional[dict[str, Any]]) -> Optional[str]:
if entry is None:
return None
version = entry["port"].get("exact_version")
return version if isinstance(version, str) else None
def port_identity(key: str, entry: Optional[dict[str, Any]]) -> dict[str, Optional[str]]:
if entry is None:
return {
"key": key,
"name": key.rsplit("/", 1)[-1],
"triplet": None,
}
port = entry["port"]
return {
"key": key,
"name": port.get("name"),
"triplet": port.get("triplet"),
}
def build_diff(old_path: str, new_path: str) -> dict[str, Any]:
old_report = load_json_report(old_path)
new_report = load_json_report(new_path)
root_qualified = report_has_duplicate_port_keys(old_report) or report_has_duplicate_port_keys(new_report)
old_ports = report_ports_by_key(old_report, root_qualified)
new_ports = report_ports_by_key(new_report, root_qualified)
version_changes = []
for key in sorted(set(old_ports) | set(new_ports)):
old_entry = old_ports.get(key)
new_entry = new_ports.get(key)
old_version = port_version(old_entry)
new_version = port_version(new_entry)
if old_version == new_version:
continue
identity = port_identity(key, new_entry or old_entry)
version_changes.append(
{
**identity,
"old_version": old_version,
"new_version": new_version,
}
)
return {
"old_report": old_path,
"new_report": new_path,
"summary": {
"version_changes": len(version_changes),
},
"version_changes": version_changes,
}
def format_version(value: Any) -> str:
return "-" if value is None else str(value)
def diff_text(diff: dict[str, Any]) -> str:
rows = [
[change["name"], format_version(change["old_version"]), format_version(change["new_version"])]
for change in diff["version_changes"]
]
if not rows:
return "No version changes."
lines = []
headers = ["Port", "Old version", "New version"]
widths = [len(header) for header in headers]
for row in rows:
for index, value in enumerate(row):
widths[index] = max(widths[index], len(value))
lines.append(" ".join(header.ljust(widths[index]) for index, header in enumerate(headers)))
lines.append(" ".join("-" * width for width in widths))
for row in rows:
lines.append(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
return "\n".join(lines)
def table_text(report: dict[str, Any]) -> str:
lines: list[str] = []
source_root = pathlib.Path(report["source_root"])
vcpkg_revision = report.get("vcpkg_root_revision") or "unknown"
dirty_suffix = " dirty" if report.get("vcpkg_root_dirty") else ""
lines.append(
f"VCPKG_ROOT: {relative_to_source_tree(pathlib.Path(report['vcpkg_root']), source_root)} "
f"({vcpkg_revision}{dirty_suffix})"
)
installed_roots = report.get("installed_roots", [])
for installed_root in installed_roots:
lines.append("")
lines.append(f"Installed root: {relative_to_source_tree(pathlib.Path(installed_root['path']), source_root)}")
lines.append(f"Manifest: {relative_to_source_tree(pathlib.Path(installed_root['manifest_path']), source_root)}")
if installed_root.get("manifest_builtin_baseline"):
lines.append(f"Manifest baseline: {installed_root['manifest_builtin_baseline']}")
lines.append(f"Installed ports: {installed_root['port_count']}")
lines.append("")
rows = []
for port in installed_root["ports"]:
spdx = port.get("spdx") or {}
resources = spdx.get("resources") or []
abi = port.get("abi") or ""
abi_info = port.get("abi_info") or {}
rows.append(
[
port["name"],
port["triplet"],
port.get("exact_version") or "-",
",".join(port.get("features") or []) or "-",
abi[:12] if abi else "-",
(port.get("port_source") or {}).get("kind") or "-",
str(len(resources)),
"yes" if abi_info else "no",
]
)
headers = ["Port", "Triplet", "Version", "Features", "ABI", "Source", "Resources", "ABI info"]
widths = [len(header) for header in headers]
for row in rows:
for index, value in enumerate(row):
widths[index] = max(widths[index], len(value))
lines.append(" ".join(header.ljust(widths[index]) for index, header in enumerate(headers)))
lines.append(" ".join("-" * width for width in widths))
for row in rows:
lines.append(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
return "\n".join(lines)
def write_json_output(path: str, payload: dict[str, Any]) -> None:
output_path = pathlib.Path(path)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def write_output(args: argparse.Namespace, report: dict[str, Any]) -> None:
if args.output:
write_json_output(args.output, report)
return
print(table_text(report))
def write_diff_output(args: argparse.Namespace, diff: dict[str, Any]) -> None:
if args.output:
write_json_output(args.output, diff)
return
print(diff_text(diff))
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Report exact versions of installed vcpkg ports from manifest-mode install trees, "
"including transitive ports."
)
)
parser.add_argument(
"--diff",
nargs=2,
metavar=("OLD_JSON", "NEW_JSON"),
help="Compare port versions in two JSON reports previously exported by this script.",
)
parser.add_argument(
"--output",
help="Write JSON output to this path instead of printing a table to stdout.",
)
args = parser.parse_args()
if args.diff:
diff = build_diff(args.diff[0], args.diff[1])
write_diff_output(args, diff)
return
report = build_report()
write_output(args, report)
if __name__ == "__main__":
main()