1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334
|
#!/usr/bin/env python3
"""Update debian/control build dependencies from debcargo output."""
import argparse
import json
import logging
import subprocess
import sys
from pathlib import Path
from typing import List, Set, Tuple
from debian.deb822 import PkgRelation
from debmutate.control import ControlEditor, PkgRelationFieldEditor, ensure_relation
from debmutate.debcargo import debcargo_binary_name
def discover_workspace_crates() -> Tuple[List[Path], Set[str]]:
"""Discover all Cargo.toml files for workspace crates.
Uses `cargo metadata` to find all workspace member crates.
Returns:
Tuple of:
- List of paths to Cargo.toml files for workspace crates
- Set of crate names in the workspace
Raises:
subprocess.CalledProcessError: If cargo metadata command fails
"""
result = subprocess.run(
["cargo", "metadata", "--no-deps", "--format-version", "1"],
capture_output=True,
text=True,
check=True,
)
metadata = json.loads(result.stdout)
# Get workspace member IDs
workspace_member_ids = set(metadata["workspace_members"])
# Find the manifest paths and crate names for workspace members
manifest_paths = []
crate_names = set()
for package in metadata["packages"]:
if package["id"] in workspace_member_ids:
manifest_path = Path(package["manifest_path"])
manifest_paths.append(manifest_path)
crate_names.add(package["name"])
return manifest_paths, crate_names
def get_debcargo_dependencies(
cargo_toml_path: Path,
features: List[str] | None = None,
all_features: bool = False,
no_default_features: bool = False,
allow_prerelease_deps: bool = False,
include_dev_dependencies: bool = False,
) -> List[str]:
"""Run debcargo deb-dependencies and parse the output.
Args:
cargo_toml_path: Path to the Cargo.toml file
features: Features to include in dependencies
all_features: Include all features in dependencies
no_default_features: Do not include default feature in dependencies
allow_prerelease_deps: Allow prerelease versions of dependencies
include_dev_dependencies: Include dev dependencies
Returns:
List of dependency strings
Raises:
subprocess.CalledProcessError: If debcargo command fails
"""
cmd = ["debcargo", "deb-dependencies"]
if features:
for feature in features:
cmd.extend(["--features", feature])
if all_features:
cmd.append("--all-features")
if no_default_features:
cmd.append("--no-default-features")
if allow_prerelease_deps:
cmd.append("--allow-prerelease-deps")
if include_dev_dependencies:
cmd.append("--include-dev-dependencies")
cmd.append(str(cargo_toml_path))
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True,
)
# The output is comma-separated dependencies
deps = [dep.strip() for dep in result.stdout.strip().split(",")]
return [dep for dep in deps if dep]
def filter_local_crate_dependencies(
deps: List[str], local_crate_names: Set[str]
) -> List[str]:
"""Filter out dependencies that correspond to local workspace crates.
Args:
deps: List of dependency strings
local_crate_names: Set of crate names that are workspace members
Returns:
Filtered list of dependencies with local crates excluded
"""
# Convert local crate names to their Debian package name prefixes
# We check prefixes because feature variants have the form:
# librust-foo-dev (base) or librust-foo+feature-dev (with features)
local_package_prefixes = {
debcargo_binary_name(name)[:-4] # Remove "-dev" suffix to get prefix
for name in local_crate_names
}
filtered_deps = []
for dep in deps:
# Parse the dependency to get the package name
parsed = PkgRelation.parse_relations(dep)
# Check if any of the alternatives is a local package or feature variant
is_local = False
for alternatives in parsed:
for rel in alternatives:
pkg_name = rel["name"]
# Check if package name starts with any local package prefix
# and ends with "-dev" (to avoid false positives)
if pkg_name.endswith("-dev"):
pkg_prefix = pkg_name[:-4] # Remove "-dev"
# Check if it matches a local crate (base, with features, or with version)
# Format: librust-cratename, librust-cratename+feature,
# librust-cratename-0.1, librust-cratename-0.1+feature, etc.
for local_prefix in local_package_prefixes:
if (
pkg_prefix == local_prefix
or pkg_prefix.startswith(local_prefix + "+")
or pkg_prefix.startswith(local_prefix + "-")
):
is_local = True
logging.info(f" Excluding local crate: {pkg_name}")
break
if is_local:
break
if is_local:
break
if not is_local:
filtered_deps.append(dep)
return filtered_deps
def update_build_dependencies(
control_path: str, new_deps: List[str], drop_unreferenced: bool = False
) -> None:
"""Update Build-Depends field in debian/control.
Args:
control_path: Path to the debian/control file
new_deps: List of new dependency strings to add/update
drop_unreferenced: If True, remove librust-*-dev dependencies not in new_deps
"""
with ControlEditor(control_path) as editor:
source = editor.source
# Get package names from new_deps for comparison
new_pkg_names = set()
for dep in new_deps:
parsed = PkgRelation.parse_relations(dep)
for alternatives in parsed:
for rel in alternatives:
new_pkg_names.add(rel["name"])
# If dropping unreferenced, remove librust-*-dev packages not in new_deps
dropped_count = 0
if drop_unreferenced:
# Get existing Build-Depends
existing_deps = source.get("Build-Depends", "")
parsed_existing = (
PkgRelation.parse_relations(existing_deps) if existing_deps else []
)
# Find librust-*-dev packages to drop
packages_to_drop = []
for alternatives in parsed_existing:
for rel in alternatives:
pkg_name = rel["name"]
# Check if it's a librust-*-dev package not in new dependencies
if pkg_name.startswith("librust-") and pkg_name.endswith("-dev"):
if pkg_name not in new_pkg_names:
packages_to_drop.append(pkg_name)
# Drop unreferenced packages using PkgRelationFieldEditor
if packages_to_drop:
with PkgRelationFieldEditor(source, "Build-Depends") as build_deps:
for pkg_name in packages_to_drop:
build_deps.drop_relation(pkg_name)
dropped_count += 1
logging.info(f" Dropping: {pkg_name}")
# Get existing Build-Depends (after potential drops)
existing_deps = source.get("Build-Depends", "")
updated_deps = existing_deps
# Use ensure_relation for each dependency
added_count = 0
for dep in new_deps:
# Store the original to detect changes
before = updated_deps
updated_deps = ensure_relation(updated_deps, dep)
# Check if the relation was added
if updated_deps != before:
added_count += 1
logging.info(f" Adding: {dep}")
# Update the Build-Depends field
source["Build-Depends"] = updated_deps
if dropped_count > 0:
logging.info(
f"\nDropped {dropped_count} unreferenced librust-*-dev dependencies"
)
logging.info(f"Added {added_count} new build dependencies")
def main() -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Update debian/control build dependencies from debcargo output."
)
parser.add_argument(
"--drop-unreferenced",
action="store_true",
help="Drop librust-*-dev dependencies that are not referenced by any workspace crate",
)
parser.add_argument(
"--exclude-local-crates",
action="store_true",
default=True,
help="Exclude dependencies for crates that are present as workspace members (default)",
)
parser.add_argument(
"--include-local-crates",
action="store_false",
dest="exclude_local_crates",
help="Include dependencies for crates that are present as workspace members",
)
parser.add_argument(
"--features",
action="append",
help="Features to include in dependencies (can be specified multiple times)",
)
parser.add_argument(
"--all-features",
action="store_true",
help="Include all features in dependencies",
)
parser.add_argument(
"--no-default-features",
action="store_true",
help="Do not include default feature in dependencies",
)
parser.add_argument(
"--allow-prerelease-deps",
action="store_true",
help="Allow prerelease versions of dependencies",
)
parser.add_argument(
"--include-dev-dependencies",
action="store_true",
help="Include dev dependencies",
)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(message)s")
control_path = "debian/control"
# Discover all workspace crates
logging.info("Discovering workspace crates...")
crate_manifests, crate_names = discover_workspace_crates()
logging.info(f"Found {len(crate_manifests)} workspace crates:")
for manifest in crate_manifests:
logging.info(f" - {manifest}")
# Collect all dependencies from all crates
all_deps: Set[str] = set()
for manifest in crate_manifests:
logging.info(
f"\nRunning debcargo deb-dependencies on {manifest.relative_to(Path.cwd())}..."
)
deps = get_debcargo_dependencies(
manifest,
features=args.features,
all_features=args.all_features,
no_default_features=args.no_default_features,
allow_prerelease_deps=args.allow_prerelease_deps,
include_dev_dependencies=args.include_dev_dependencies,
)
logging.info(f" Found {len(deps)} dependencies")
all_deps.update(deps)
logging.info(f"\nTotal unique dependencies across all crates: {len(all_deps)}")
# Filter out local crate dependencies if requested
deps_to_add = sorted(all_deps)
if args.exclude_local_crates:
logging.info("\nFiltering out local workspace crates...")
deps_to_add = filter_local_crate_dependencies(deps_to_add, crate_names)
logging.info(f"Remaining dependencies after filtering: {len(deps_to_add)}")
# Update debian/control with all dependencies
logging.info(f"\nUpdating {control_path}...")
update_build_dependencies(
control_path, deps_to_add, drop_unreferenced=args.drop_unreferenced
)
logging.info("\nDone!")
return 0
if __name__ == "__main__":
sys.exit(main())
|