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

1"""Module containing functionality for loading projects from "crate" archives.""" 

2 

3import shutil 

4from collections.abc import Callable 

5from dataclasses import dataclass 

6from pathlib import Path 

7 

8from cargo2rpm.metadata import Metadata, Package 

9from cargo2rpm.semver import Version, VersionReq 

10 

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 

18 

19from .meta import InvalidVersionError, PatchFile, Project 

20from .shared import file_name_auto_patch, file_name_manual_patch 

21 

22 

23class OfflineError(Exception): 

24 """Raised when attempting actions that would require internet access in offline mode.""" 

25 

26 

27def parse_crate_file_name(path: str) -> tuple[str, str]: 

28 """Parse crate file name into crate name and crate version. 

29 

30 Arguments: 

31 path: File name (including ".crate" suffix) 

32 

33 Returns: 

34 Tuple consisting of (name, version) of the crate. 

35 

36 """ 

37 filename = path.removesuffix(".crate") 

38 

39 try: 

40 name, version = filename.rsplit("-", 1) 

41 Version.parse(version) 

42 except ValueError: 

43 pass 

44 else: 

45 return name, version 

46 

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 

55 

56 

57def package_name_suffixed(name: str, suffix: str | None) -> str: 

58 """Construct RPM package name for a Rust crate with given name and suffix. 

59 

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

66 

67 

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. 

70 

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) 

83 

84 

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. 

87 

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. 

92 

93 Returns: 

94 Greatest available version matching the specified version string, 

95 or `None` if no available version matches the given version string. 

96 

97 Raises: 

98 `InvalidVersionError` if the version string does not parse correctly 

99 as either a SemVer version or version requirement string. 

100 

101 """ 

102 try: 

103 return Version.parse(version) 

104 except ValueError: 

105 pass 

106 

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 

112 

113 log.info("Resolving version requirement ...") 

114 

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 

118 

119 

120@dataclass(frozen=True) 

121class CrateProject(Project): 

122 """Description of a project based on a crate archive.""" 

123 

124 compat: bool 

125 """Flag that indicates whether this package is intended to be a compat package.""" 

126 

127 @property 

128 def package(self) -> Package: 

129 """Metadata for the single cargo "package" present in cargo metadata.""" 

130 return self.metadata.packages[0] 

131 

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) 

137 

138 return package_name_suffixed(self.name, None) 

139 

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. 

150 

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. 

158 

159 Returns: 

160 Project metadata based on the contents of the crate archive. 

161 

162 Raises: 

163 `ValueError` when the crate archive contains multiple crates. 

164 

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

169 

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) 

175 

176 package = metadata.packages[0] 

177 version = package.version 

178 

179 reapply_path = (out_dir or Path()) / file_name_manual_patch(name) if options.reuse_patch else None 

180 

181 auto_diff, manual_diff = make_patches( 

182 name, 

183 version, 

184 toml_path, 

185 reapply_path, 

186 options, 

187 ) 

188 

189 # ensure metadata is up-to-date with changes from patches 

190 metadata = Metadata.from_cargo(str(toml_path)) 

191 

192 vendor_tarball = generate_vendor_tarball(toml_path, name, version, out_dir or Path(), options.vendor) 

193 

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 

196 

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 ) 

208 

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. 

217 

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. 

222 

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. 

228 

229 Returns: 

230 Project metadata based on the contents of the crate archive. 

231 

232 Raises: 

233 `ValueError` when the crate archive contains multiple crates. 

234 

235 """ 

236 # determine name and version from the filename 

237 name, version = parse_crate_file_name(path.name) 

238 

239 return CrateProject.from_crate_file(path, name, version, out_dir, conf, options) 

240 

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. 

250 

251 This method downloads the given crate from the crates.io registry 

252 and then calls `CrateProject.from_crate_file` internally. 

253 

254 The behaviour which version from crates.io is used depends on the value 

255 of the `version` argument: 

256 

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. 

269 

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. 

274 

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. 

282 

283 Returns: 

284 Project metadata based on the contents of the crate archive. 

285 

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. 

292 

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 

301 

302 crate_file_path = download_crate(name, valid_version, offline=True) 

303 version = str(valid_version) 

304 

305 else: 

306 # version or partial version was specified 

307 resolved_version = resolve_version(version, lambda: query_available_versions(name)) 

308 

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) 

314 

315 crate_file_path = download_crate(name, resolved_version, offline=False) 

316 version = str(resolved_version) 

317 

318 else: 

319 if options.offline: 

320 msg = "Crate version needs to be explicitly and fully specified in offline mode." 

321 raise OfflineError(msg) 

322 

323 # no version was specified: download latest 

324 newest_version = query_newest_version(name) 

325 

326 crate_file_path = download_crate(name, newest_version, offline=False) 

327 version = str(newest_version) 

328 

329 copy_target = (out_dir or Path.cwd()) / crate_file_path.name 

330 copy_target.parent.mkdir(parents=True, exist_ok=True) 

331 

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) 

335 

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) 

340 

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) 

344 

345 return CrateProject.from_crate_file(crate_file_path, name, version, out_dir, conf, options)