Coverage for rust2rpm/project/crate.py: 83%
90 statements
« prev ^ index » next coverage.py v7.6.7, created at 2024-11-26 14:01 +0100
« prev ^ index » next coverage.py v7.6.7, created at 2024-11-26 14:01 +0100
1"""Module containing functionality for loading projects from "crate" archives."""
3import shutil
4from collections.abc import Callable
5from dataclasses import dataclass
6from pathlib import Path
8from cargo2rpm.metadata import Metadata, Package
9from cargo2rpm.semver import Version, VersionReq
11from rust2rpm import log
12from rust2rpm.cli import Options
13from rust2rpm.conf import TomlConf
14from rust2rpm.cratesio import download_crate, query_available_versions, query_newest_version
15from rust2rpm.inspect import files_from_crate
16from rust2rpm.patching import make_patches
17from rust2rpm.vendor import generate_vendor_tarball
19from .meta import InvalidVersionError, PatchFile, Project
20from .shared import file_name_auto_patch, file_name_manual_patch
23class OfflineError(Exception):
24 """Raised when attempting actions that would require internet access in offline mode."""
27def parse_crate_file_name(path: str) -> tuple[str, str]:
28 """Parse crate file name into crate name and crate version.
30 Arguments:
31 path: File name (including ".crate" suffix)
33 Returns:
34 Tuple consisting of (name, version) of the crate.
36 """
37 filename = path.removesuffix(".crate")
39 try:
40 name, version = filename.rsplit("-", 1)
41 Version.parse(version)
42 except ValueError:
43 pass
44 else:
45 return name, version
47 try:
48 name, version, pre = filename.rsplit("-", 2)
49 Version.parse(f"{version}-{pre}")
50 except ValueError as exc:
51 msg = f"Crate version cannot be determined from crate file name: {path}"
52 raise InvalidVersionError(msg) from exc
53 else:
54 return name, version
57def package_name_suffixed(name: str, suffix: str | None) -> str:
58 """Construct RPM package name for a Rust crate with given name and suffix.
60 This takes into account the rules for versioning compat packages from the
61 Fedora Packaging Guidelines (i.e. the compat suffix must use a `_` character
62 as a separator of the base package name ends in a digit).
63 """
64 joiner = "_" if suffix and name[-1].isdigit() else ""
65 return "rust-" + name + joiner + (suffix or "")
68def package_name_compat(name: str, version: str) -> str:
69 """Construct RPM package name for a Rust crate with the correct version suffix for a "compat" package.
71 To avoid dependency resolution issues that might be hard to debug, only one
72 version of a crate may be packaged for every "major" release branch of a
73 crate as defined by the cargo flavor of SemVer.
74 """
75 sv = Version.parse(version)
76 if sv.major > 0:
77 suffix = str(sv.major)
78 elif sv.minor > 0:
79 suffix = f"0.{sv.minor}"
80 else:
81 suffix = f"0.0.{sv.patch}"
82 return package_name_suffixed(name, suffix)
85def resolve_version(version: str, query: Callable[[], list[Version]]) -> Version | None:
86 """Query the crates.io API to resolve a fully specified or partial version string.
88 Arguments:
89 version: Full or partial version string.
90 query: Callable that takes zero arguments and returns the list of
91 available versions for the given crate.
93 Returns:
94 Greatest available version matching the specified version string,
95 or `None` if no available version matches the given version string.
97 Raises:
98 `InvalidVersionError` if the version string does not parse correctly
99 as either a SemVer version or version requirement string.
101 """
102 try:
103 return Version.parse(version)
104 except ValueError:
105 pass
107 try:
108 parsed_version = VersionReq.parse(version)
109 except ValueError as exc:
110 msg = f"Version argument does not parse as a version or version requirement: {version!r}"
111 raise InvalidVersionError(msg) from exc
113 log.info("Resolving version requirement ...")
115 available_versions = query()
116 filtered_versions = [*filter(lambda x: x in parsed_version, available_versions)]
117 return max(filtered_versions) if filtered_versions else None
120@dataclass(frozen=True)
121class CrateProject(Project):
122 """Description of a project based on a crate archive."""
124 compat: bool
125 """Flag that indicates whether this package is intended to be a compat package."""
127 @property
128 def package(self) -> Package:
129 """Metadata for the single cargo "package" present in cargo metadata."""
130 return self.metadata.packages[0]
132 @property
133 def rpm_name(self) -> str:
134 """RPM source package name based on project metadata."""
135 if self.compat:
136 return package_name_compat(self.name, self.version)
138 return package_name_suffixed(self.name, None)
140 @staticmethod
141 def from_crate_file(
142 path: Path,
143 name: str,
144 version: str,
145 out_dir: Path | None,
146 conf: TomlConf,
147 options: Options,
148 ) -> "Project":
149 """Load project metadata based on contents of a crate archive.
151 Arguments:
152 path: Path of the crate file.
153 name: Name of the crate.
154 version: Version of the crate.
155 out_dir: Output directory for any generated or downloaded files.
156 conf: Global rust2rpm configuration.
157 options: Options and flags derived from command-line arguments.
159 Returns:
160 Project metadata based on the contents of the crate archive.
162 Raises:
163 `ValueError` when the crate archive contains multiple crates.
165 """
166 # process files from a .crate archive
167 with files_from_crate(path, name, version, conf) as (toml_path, license_files, doc_files):
168 metadata = Metadata.from_cargo(str(toml_path))
170 if len(metadata.packages) > 1: 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true
171 log.error("Attempting to process a .crate file which contains a cargo workspace.")
172 log.error("This mode of operation is unusual and not supported by rust2rpm.")
173 msg = "Failed to process invalid .crate file (cargo workspace)"
174 raise ValueError(msg)
176 package = metadata.packages[0]
177 version = package.version
179 reapply_path = (out_dir or Path()) / file_name_manual_patch(name) if options.reuse_patch else None
181 auto_diff, manual_diff = make_patches(
182 name,
183 version,
184 toml_path,
185 reapply_path,
186 options,
187 )
189 # ensure metadata is up-to-date with changes from patches
190 metadata = Metadata.from_cargo(str(toml_path))
192 vendor_tarball = generate_vendor_tarball(toml_path, name, version, out_dir or Path(), options.vendor)
194 auto_patch = PatchFile(file_name_auto_patch(name), diff) if (diff := auto_diff) else None
195 manual_patch = PatchFile(file_name_manual_patch(name), diff) if (diff := manual_diff) else None
197 return CrateProject(
198 name,
199 version,
200 metadata,
201 license_files,
202 doc_files,
203 auto_patch,
204 manual_patch,
205 vendor_tarball,
206 options.compat,
207 )
209 @staticmethod
210 def from_local_crate(
211 path: Path,
212 out_dir: Path | None,
213 conf: TomlConf,
214 options: Options,
215 ) -> "Project":
216 """Load project metadata based on contents of a crate archive.
218 This is a wrapper method around `CrateProject.from_crate_file` but
219 it parses the file name into crate (name, version) so they can be
220 passed as arguments and don't need to be explicitly specified in
221 cases where they can be inferred from the file name.
223 Arguments:
224 path: Path of the crate file.
225 out_dir: Output directory for any generated or downloaded files.
226 conf: Global rust2rpm configuration.
227 options: Options and flags derived from command-line arguments.
229 Returns:
230 Project metadata based on the contents of the crate archive.
232 Raises:
233 `ValueError` when the crate archive contains multiple crates.
235 """
236 # determine name and version from the filename
237 name, version = parse_crate_file_name(path.name)
239 return CrateProject.from_crate_file(path, name, version, out_dir, conf, options)
241 @staticmethod
242 def from_crates_io(
243 name: str,
244 version: str | None,
245 out_dir: Path | None,
246 conf: TomlConf,
247 options: Options,
248 ) -> "Project": # pragma nocover: requires internet access
249 """Load project metadata based on contents of a crate archive.
251 This method downloads the given crate from the crates.io registry
252 and then calls `CrateProject.from_crate_file` internally.
254 The behaviour which version from crates.io is used depends on the value
255 of the `version` argument:
257 - If the argument is `None`, query crates.io for the greatest
258 non-prerelease version and use it. If there are no non-yanked
259 non-prerelease versions, `InvalidVersionError` is raised.
260 - If the argument is a full SemVer version string (i.e.
261 `major.minor.patch`), then it is used to query crates.io for this
262 exact version. If the version does not exixt, `InvalidVersionError`
263 is raised.
264 - If the argument is a SemVer version requirement (i.e. operator plus
265 partial version string), then crates.io is queried for available
266 versions, and the greatest version that matches the given
267 requirement is used. If no version matches the requirement,
268 `InvalidVersionError` is raised.
270 In "offline" mode, the version needs to be fully specified since
271 crates.io cannot be queried for a list of available versions to choose
272 from. The specified version needs to have been previously downloaded
273 into the rust2rpm download cache for "offline" mode to work.
275 Arguments:
276 name: Name of the crate in the crates.io registry.
277 version: Optional version or version requirement string, used
278 for filtering and choosing a version from all available versions.
279 out_dir: Output directory for any generated or downloaded files.
280 conf: Global rust2rpm configuration.
281 options: Options and flags derived from command-line arguments.
283 Returns:
284 Project metadata based on the contents of the crate archive.
286 Raises:
287 `InvalidVersionError` is raised if the `version` argument is invalid
288 or if there is no available version that matches the argument.
289 `OfflineError` is raised if the version argument was not specific
290 enough or if the specified crate version had not been downloaded
291 yet.
293 """
294 if version:
295 if options.offline:
296 try:
297 valid_version = Version.parse(version)
298 except ValueError as exc:
299 msg = "Crate version needs to be explicitly and fully specified in offline mode."
300 raise OfflineError(msg) from exc
302 crate_file_path = download_crate(name, valid_version, offline=True)
303 version = str(valid_version)
305 else:
306 # version or partial version was specified
307 resolved_version = resolve_version(version, lambda: query_available_versions(name))
309 if resolved_version:
310 log.info(f"Partial version matched with available version: {resolved_version}")
311 else:
312 msg = f"Partial version did not match any available version: {version}"
313 raise InvalidVersionError(msg)
315 crate_file_path = download_crate(name, resolved_version, offline=False)
316 version = str(resolved_version)
318 else:
319 if options.offline:
320 msg = "Crate version needs to be explicitly and fully specified in offline mode."
321 raise OfflineError(msg)
323 # no version was specified: download latest
324 newest_version = query_newest_version(name)
326 crate_file_path = download_crate(name, newest_version, offline=False)
327 version = str(newest_version)
329 copy_target = (out_dir or Path.cwd()) / crate_file_path.name
330 copy_target.parent.mkdir(parents=True, exist_ok=True)
332 if options.offline and not crate_file_path.exists() and not copy_target.exists():
333 msg = "The specified crate version has not been downloaded yet."
334 raise OfflineError(msg)
336 if options.store_crate:
337 # crate file was manually downloaded but is not in the cache yet
338 if options.offline and copy_target.exists() and not crate_file_path.exists():
339 shutil.copy2(copy_target, crate_file_path)
341 # crate file was downloaded to the cache but not yet copied to CWD
342 if not (copy_target.exists() and crate_file_path.samefile(copy_target)):
343 shutil.copy2(crate_file_path, copy_target)
345 return CrateProject.from_crate_file(crate_file_path, name, version, out_dir, conf, options)