master
py 285 lines 9.83 KB
Raw
1 #!/usr/bin/env python3
2
3 # SPDX-License-Identifier: GPL-2.0-or-later
4
5 """
6 update-cargo-wraps.py - Update Meson subprojects from a Cargo
7 registry or from the versions pinned in Cargo.lock.
8 """
9
10 # Copyright (C) 2025 Red Hat, Inc.
11 #
12 # Author: Paolo Bonzini <pbonzini@redhat.com>
13
14 import argparse
15 import configparser
16 import filecmp
17 import glob
18 import os
19 import shutil
20 import subprocess
21 import sys
22 import tomllib
23
24
25 def get_name_and_semver(namever: str) -> tuple[str, str]:
26 """Split a subproject name into its name and semantic version parts"""
27 parts = namever.rsplit("-", 1)
28 if len(parts) != 2:
29 return namever, ""
30
31 return parts[0], parts[1]
32
33
34 class CrateSource:
35 """Class for locating crate versions and pointing wrap files at them.
36 Subclasses know where the source of a crate comes from and how to
37 rewrite the ``[wrap-file]`` to consume it."""
38
39 origin: str
40
41 def find(self, namever: str) -> str | None:
42 """Resolve a 'name-semver' prefix to a concrete 'name-version'."""
43 raise NotImplementedError
44
45 def rewrite_source(self, section: configparser.SectionProxy, orig_namever: str, source_namever: str) -> bool:
46 """Update the download-related keys of a [wrap-file] section."""
47 raise NotImplementedError
48
49
50 class CargoRegistry(CrateSource):
51 """Locate crates already extracted in a local Cargo registry directory."""
52
53 origin = "the Cargo registry"
54
55 def __init__(self, path: str):
56 self.path = path
57
58 def find(self, namever: str) -> str | None:
59 """Find installed crate matching name and semver prefix"""
60 name, semver = get_name_and_semver(namever)
61
62 # exact version match
63 path = os.path.join(self.path, f"{name}-{semver}")
64 if os.path.exists(path):
65 return f"{name}-{semver}"
66
67 # semver match
68 matches = sorted(glob.glob(f"{path}.*"))
69 return os.path.basename(matches[0]) if matches else None
70
71 def rewrite_source(self, section: configparser.SectionProxy, orig_namever: str, registry_namever: str) -> bool:
72 # the registry already holds the extracted sources, so Meson does not
73 # download anything: drop the source_* keys.
74 for key in list(section.keys()):
75 if key.startswith("source"):
76 del section[key]
77 return True
78
79
80 class CargoLock(CrateSource):
81 """Locate crates by the versions pinned in a Cargo.lock file."""
82
83 origin = "crates.io"
84
85 def __init__(self, path: str):
86 with open(path, "rb") as f:
87 data = tomllib.load(f)
88
89 self.versions: dict[str, list[str]] = {}
90 self.checksums: dict[str, str] = {}
91 for pkg in data.get("package", []):
92 # workspace members have neither a checksum nor a crates.io tarball
93 if "checksum" not in pkg:
94 continue
95 name, version = pkg["name"], pkg["version"]
96 self.versions.setdefault(name, []).append(version)
97 self.checksums[f"{name}-{version}"] = pkg["checksum"]
98
99 def find(self, namever: str) -> str | None:
100 """Find pinned crate matching name and semver prefix"""
101 name, semver = get_name_and_semver(namever)
102 versions = sorted(self.versions.get(name, []))
103
104 # exact version match
105 if semver in versions:
106 return f"{name}-{semver}"
107
108 # semver match
109 matches = [v for v in versions if v.startswith(f"{semver}.")]
110 return f"{name}-{matches[0]}" if matches else None
111
112 def rewrite_source(self, section: configparser.SectionProxy, orig_namever: str, new_namever: str) -> bool:
113 # rewrite the download keys to fetch the pinned version from crates.io.
114 if orig_namever == new_namever:
115 return False
116 name, version = get_name_and_semver(new_namever)
117 section["source_url"] = f"https://crates.io/api/v1/crates/{name}/{version}/download"
118 section["source_filename"] = f"{new_namever}.tar.gz"
119 section["source_hash"] = self.checksums[new_namever]
120 return True
121
122
123 class UpdateSubprojects:
124 cargo_registry: str
125 source: CrateSource
126 top_srcdir: str
127 dry_run: bool
128 changes: int = 0
129
130 def compare_build_rs(self, orig_dir: str, source_namever: str) -> None:
131 """Warn if the build.rs in the original directory differs from the registry version."""
132 orig_build_rs = os.path.join(orig_dir, "build.rs")
133 new_build_rs = os.path.join(source_namever, "build.rs")
134
135 msg = None
136 if os.path.isfile(orig_build_rs) != os.path.isfile(new_build_rs):
137 if os.path.isfile(orig_build_rs):
138 msg = f"build.rs removed in {source_namever}"
139 if os.path.isfile(new_build_rs):
140 msg = f"build.rs added in {source_namever}"
141
142 elif os.path.isfile(orig_build_rs) and not filecmp.cmp(orig_build_rs, new_build_rs, shallow=False):
143 msg = f"build.rs changed from {orig_dir} to {source_namever}"
144 # diff exits non-zero when the files differ, which is expected here
145 subprocess.run(["diff", "-u", orig_build_rs, new_build_rs])
146
147 if msg:
148 print(f"⚠️ Warning: {msg}")
149 print(" This may affect the build process - please review the differences.")
150
151 def update_subproject(self, wrap_file: str, source_namever: str) -> None:
152 """Modify [wrap-file] section to use the crate resolved as `source_namever`."""
153 assert wrap_file.endswith("-rs.wrap")
154 wrap_name = wrap_file[:-5]
155
156 env = os.environ.copy()
157 if self.cargo_registry:
158 env["MESON_PACKAGE_CACHE_DIR"] = self.cargo_registry
159
160 config = configparser.ConfigParser()
161 config.read(wrap_file)
162 if "wrap-file" not in config:
163 return
164
165 section = config["wrap-file"]
166 orig_dir = section["directory"]
167
168 if self.dry_run:
169 if orig_dir != source_namever:
170 print(f"Will replace {orig_dir} with {source_namever}.")
171 elif not os.path.exists(orig_dir) or self.cargo_registry:
172 print(f"Will install {orig_dir} from {self.source.origin}.")
173 else:
174 print(f"Will update {orig_dir} from cache.")
175 return
176 self.changes += 1
177 return
178
179 section["directory"] = source_namever
180 if self.source.rewrite_source(section, orig_dir, source_namever):
181 with open(wrap_file, "w") as f:
182 config.write(f)
183
184 with open(wrap_file, "w") as f:
185 config.write(f)
186
187 if orig_dir != source_namever:
188 print(f"👉 Replacing {orig_dir} with {source_namever}.")
189 elif not os.path.exists(orig_dir) or self.cargo_registry:
190 print(f"👉 Installing {orig_dir} from {self.source.origin}.")
191 else:
192 print(f"👉 Updating {orig_dir} from cache.")
193 subprocess.run(
194 ["meson", "subprojects", "update", "--reset", wrap_name],
195 cwd=self.top_srcdir,
196 env=env,
197 check=True,
198 )
199 return
200
201 subprocess.run(
202 ["meson", "subprojects", "download", wrap_name],
203 cwd=self.top_srcdir,
204 env=env,
205 check=True,
206 )
207 self.changes += 1
208
209 if os.path.exists(orig_dir) and orig_dir != source_namever:
210 self.compare_build_rs(orig_dir, source_namever)
211 shutil.rmtree(orig_dir)
212
213 @staticmethod
214 def parse_cmdline() -> argparse.Namespace:
215 parser = argparse.ArgumentParser(
216 description="Replace Meson subprojects with packages in a Cargo registry"
217 )
218 parser.add_argument(
219 "--cargo-lock",
220 action='store_true',
221 default=False,
222 help="Update wraps from Cargo.lock",
223 )
224 parser.add_argument(
225 "--cargo-registry",
226 default=None,
227 help="Path to Cargo registry (default: CARGO_REGISTRY env var)",
228 )
229 parser.add_argument(
230 "--dry-run",
231 action="store_true",
232 default=False,
233 help="Do not actually replace anything",
234 )
235
236 args = parser.parse_args()
237 if args.cargo_registry and args.cargo_lock:
238 print("error: --cargo-registry and --cargo-lock are incompatible")
239 sys.exit(1)
240 if not args.cargo_registry and not args.cargo_lock:
241 args.cargo_registry = os.environ.get("CARGO_REGISTRY")
242 if not args.cargo_registry:
243 print("error: CARGO_REGISTRY environment variable not set and " +
244 "--cargo-registry or --cargo-lock not provided")
245 sys.exit(1)
246
247 return args
248
249 def __init__(self, args: argparse.Namespace):
250 self.cargo_registry = args.cargo_registry
251 self.dry_run = args.dry_run
252 self.top_srcdir = os.getcwd()
253 if args.cargo_lock:
254 self.source = CargoLock(os.path.join(self.top_srcdir, "Cargo.lock"))
255 else:
256 self.source = CargoRegistry(args.cargo_registry)
257
258 def main(self) -> None:
259 if not os.path.exists("subprojects"):
260 print("'subprojects' directory not found, nothing to do.")
261 return
262
263 os.chdir("subprojects")
264 for wrap_file in sorted(glob.glob("*-rs.wrap")):
265 namever = wrap_file[:-8] # Remove '-rs.wrap'
266
267 source_namever = self.source.find(namever)
268 if not source_namever:
269 print(f"No crate found for {wrap_file}")
270 continue
271
272 self.update_subproject(wrap_file, source_namever)
273
274 if self.changes:
275 if self.dry_run:
276 print("Rerun without --dry-run to apply changes.")
277 else:
278 print(f"{self.changes} subproject(s) updated!")
279 else:
280 print("No changes.")
281
282
283 if __name__ == "__main__":
284 args = UpdateSubprojects.parse_cmdline()
285 UpdateSubprojects(args).main()