Coverage for src/debputy/packages.py: 72%

198 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2025-01-27 13:59 +0000

1from typing import ( 

2 Dict, 

3 Union, 

4 Tuple, 

5 Optional, 

6 Set, 

7 cast, 

8 Mapping, 

9 FrozenSet, 

10 Iterable, 

11 overload, 

12) 

13 

14from debian.deb822 import Deb822 

15from debian.debian_support import DpkgArchTable 

16 

17from ._deb_options_profiles import DebBuildOptionsAndProfiles 

18from .architecture_support import ( 

19 DpkgArchitectureBuildProcessValuesTable, 

20 dpkg_architecture_table, 

21) 

22from .lsp.vendoring._deb822_repro import ( 

23 parse_deb822_file, 

24 Deb822ParagraphElement, 

25 Deb822FileElement, 

26) 

27from .util import DEFAULT_PACKAGE_TYPE, UDEB_PACKAGE_TYPE, _error, active_profiles_match 

28 

29_MANDATORY_BINARY_PACKAGE_FIELD = [ 

30 "Package", 

31 "Architecture", 

32] 

33 

34 

35class DctrlParser: 

36 

37 def __init__( 

38 self, 

39 selected_packages: Union[Set[str], FrozenSet[str]], 

40 excluded_packages: Union[Set[str], FrozenSet[str]], 

41 select_arch_all: bool, 

42 select_arch_any: bool, 

43 dpkg_architecture_variables: Optional[ 

44 DpkgArchitectureBuildProcessValuesTable 

45 ] = None, 

46 dpkg_arch_query_table: Optional[DpkgArchTable] = None, 

47 deb_options_and_profiles: Optional[DebBuildOptionsAndProfiles] = None, 

48 ignore_errors: bool = False, 

49 ) -> None: 

50 if dpkg_architecture_variables is None: 50 ↛ 51line 50 didn't jump to line 51 because the condition on line 50 was never true

51 dpkg_architecture_variables = dpkg_architecture_table() 

52 if dpkg_arch_query_table is None: 52 ↛ 53line 52 didn't jump to line 53 because the condition on line 52 was never true

53 dpkg_arch_query_table = DpkgArchTable.load_arch_table() 

54 if deb_options_and_profiles is None: 54 ↛ 55line 54 didn't jump to line 55 because the condition on line 54 was never true

55 deb_options_and_profiles = DebBuildOptionsAndProfiles.instance() 

56 

57 # If no selection option is set, then all packages are acted on (except the 

58 # excluded ones) 

59 if not selected_packages and not select_arch_all and not select_arch_any: 59 ↛ 60line 59 didn't jump to line 60 because the condition on line 59 was never true

60 select_arch_all = True 

61 select_arch_any = True 

62 

63 self.selected_packages = selected_packages 

64 self.excluded_packages = excluded_packages 

65 self.select_arch_all = select_arch_all 

66 self.select_arch_any = select_arch_any 

67 self.dpkg_architecture_variables = dpkg_architecture_variables 

68 self.dpkg_arch_query_table = dpkg_arch_query_table 

69 self.deb_options_and_profiles = deb_options_and_profiles 

70 self.ignore_errors = ignore_errors 

71 

72 @overload 

73 def parse_source_debian_control( 73 ↛ exitline 73 didn't jump to the function exit

74 self, 

75 debian_control_lines: Iterable[str], 

76 ) -> Tuple[Deb822FileElement, "SourcePackage", Dict[str, "BinaryPackage"]]: ... 

77 

78 @overload 

79 def parse_source_debian_control( 79 ↛ exitline 79 didn't jump to the function exit

80 self, 

81 debian_control_lines: Iterable[str], 

82 *, 

83 ignore_errors: bool = False, 

84 ) -> Tuple[ 

85 Deb822FileElement, 

86 Optional["SourcePackage"], 

87 Optional[Dict[str, "BinaryPackage"]], 

88 ]: ... 

89 

90 def parse_source_debian_control( 

91 self, 

92 debian_control_lines: Iterable[str], 

93 *, 

94 ignore_errors: bool = False, 

95 ) -> Tuple[ 

96 Optional[Deb822FileElement], 

97 Optional["SourcePackage"], 

98 Optional[Dict[str, "BinaryPackage"]], 

99 ]: 

100 deb822_file = parse_deb822_file( 

101 debian_control_lines, 

102 accept_files_with_error_tokens=ignore_errors, 

103 accept_files_with_duplicated_fields=ignore_errors, 

104 ) 

105 dctrl_paragraphs = list(deb822_file) 

106 if len(dctrl_paragraphs) < 2: 106 ↛ 107line 106 didn't jump to line 107 because the condition on line 106 was never true

107 if not ignore_errors: 

108 _error( 

109 "debian/control must contain at least two stanza (1 Source + 1-N Package stanza)" 

110 ) 

111 source_package = ( 

112 SourcePackage(dctrl_paragraphs[0]) if dctrl_paragraphs else None 

113 ) 

114 return deb822_file, source_package, None 

115 

116 source_package = SourcePackage(dctrl_paragraphs[0]) 

117 bin_pkgs = [] 

118 for i, p in enumerate(dctrl_paragraphs[1:], 1): 

119 if ignore_errors: 119 ↛ 132line 119 didn't jump to line 132 because the condition on line 119 was always true

120 if "Package" not in p: 

121 continue 

122 missing_field = any(f not in p for f in _MANDATORY_BINARY_PACKAGE_FIELD) 

123 if missing_field: 

124 # In the LSP context, it is problematic if we "add" fields as it ranges and provides invalid 

125 # results. However, `debputy` also needs the mandatory fields to be there, so we clone the 

126 # stanzas that `debputy` (build) will see to add missing fields. 

127 copy = Deb822(p) 

128 for f in _MANDATORY_BINARY_PACKAGE_FIELD: 

129 if f not in p: 

130 copy[f] = "unknown" 

131 p = copy 

132 bin_pkgs.append( 

133 _create_binary_package( 

134 p, 

135 self.selected_packages, 

136 self.excluded_packages, 

137 self.select_arch_all, 

138 self.select_arch_any, 

139 self.dpkg_architecture_variables, 

140 self.dpkg_arch_query_table, 

141 self.deb_options_and_profiles, 

142 i, 

143 ) 

144 ) 

145 bin_pkgs_table = {p.name: p for p in bin_pkgs} 

146 

147 if not ignore_errors: 147 ↛ 148line 147 didn't jump to line 148 because the condition on line 147 was never true

148 if not self.selected_packages.issubset(bin_pkgs_table.keys()): 

149 unknown = self.selected_packages - bin_pkgs_table.keys() 

150 _error( 

151 f"The following *selected* packages (-p) are not listed in debian/control: {sorted(unknown)}" 

152 ) 

153 if not self.excluded_packages.issubset(bin_pkgs_table.keys()): 

154 unknown = self.selected_packages - bin_pkgs_table.keys() 

155 _error( 

156 f"The following *excluded* packages (-N) are not listed in debian/control: {sorted(unknown)}" 

157 ) 

158 

159 return deb822_file, source_package, bin_pkgs_table 

160 

161 

162def _check_package_sets( 

163 provided_packages: Set[str], 

164 valid_package_names: Set[str], 

165 option_name: str, 

166) -> None: 

167 # SonarLint proposes to use `provided_packages > valid_package_names`, which is valid for boolean 

168 # logic, but not for set logic. We want to assert that provided_packages is a proper subset 

169 # of valid_package_names. The rewrite would cause no errors for {'foo'} > {'bar'} - in set logic, 

170 # neither is a superset / subset of the other, but we want an error for this case. 

171 # 

172 # Bug filed: 

173 # https://community.sonarsource.com/t/sonarlint-python-s1940-rule-does-not-seem-to-take-set-logic-into-account/79718 

174 if not (provided_packages <= valid_package_names): 

175 non_existing_packages = sorted(provided_packages - valid_package_names) 

176 invalid_package_list = ", ".join(non_existing_packages) 

177 msg = ( 

178 f"Invalid package names passed to {option_name}: {invalid_package_list}: " 

179 f'Valid package names are: {", ".join(valid_package_names)}' 

180 ) 

181 _error(msg) 

182 

183 

184def _create_binary_package( 

185 paragraph: Union[Deb822ParagraphElement, Dict[str, str]], 

186 selected_packages: Union[Set[str], FrozenSet[str]], 

187 excluded_packages: Union[Set[str], FrozenSet[str]], 

188 select_arch_all: bool, 

189 select_arch_any: bool, 

190 dpkg_architecture_variables: DpkgArchitectureBuildProcessValuesTable, 

191 dpkg_arch_query_table: DpkgArchTable, 

192 build_env: DebBuildOptionsAndProfiles, 

193 paragraph_index: int, 

194) -> "BinaryPackage": 

195 try: 

196 package_name = paragraph["Package"] 

197 except KeyError: 

198 _error(f'Missing mandatory field "Package" in stanza number {paragraph_index}') 

199 # The raise is there to help PyCharm type-checking (which fails at "NoReturn") 

200 raise 

201 

202 for mandatory_field in _MANDATORY_BINARY_PACKAGE_FIELD: 

203 if mandatory_field not in paragraph: 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true

204 _error( 

205 f'Missing mandatory field "{mandatory_field}" for binary package {package_name}' 

206 f" (stanza number {paragraph_index})" 

207 ) 

208 

209 architecture = paragraph["Architecture"] 

210 

211 if paragraph_index < 1: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 raise ValueError("stanza index must be 1-indexed (1, 2, ...)") 

213 is_main_package = paragraph_index == 1 

214 

215 if package_name in excluded_packages: 215 ↛ 216line 215 didn't jump to line 216 because the condition on line 215 was never true

216 should_act_on = False 

217 elif package_name in selected_packages: 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true

218 should_act_on = True 

219 elif architecture == "all": 

220 should_act_on = select_arch_all 

221 else: 

222 should_act_on = select_arch_any 

223 

224 profiles_raw = paragraph.get("Build-Profiles", "").strip() 

225 if should_act_on and profiles_raw: 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true

226 try: 

227 should_act_on = active_profiles_match( 

228 profiles_raw, build_env.deb_build_profiles 

229 ) 

230 except ValueError as e: 

231 _error(f"Invalid Build-Profiles field for {package_name}: {e.args[0]}") 

232 

233 return BinaryPackage( 

234 paragraph, 

235 dpkg_architecture_variables, 

236 dpkg_arch_query_table, 

237 should_be_acted_on=should_act_on, 

238 is_main_package=is_main_package, 

239 ) 

240 

241 

242def _check_binary_arch( 

243 arch_table: DpkgArchTable, 

244 binary_arch: str, 

245 declared_arch: str, 

246) -> bool: 

247 if binary_arch == "all": 

248 return True 

249 arch_wildcards = declared_arch.split() 

250 for arch_wildcard in arch_wildcards: 

251 if arch_table.matches_architecture(binary_arch, arch_wildcard): 

252 return True 

253 return False 

254 

255 

256class BinaryPackage: 

257 __slots__ = [ 

258 "_package_fields", 

259 "_dbgsym_binary_package", 

260 "_should_be_acted_on", 

261 "_dpkg_architecture_variables", 

262 "_declared_arch_matches_output_arch", 

263 "_is_main_package", 

264 "_substvars", 

265 "_maintscript_snippets", 

266 ] 

267 

268 def __init__( 

269 self, 

270 fields: Union[Mapping[str, str], Deb822ParagraphElement], 

271 dpkg_architecture_variables: DpkgArchitectureBuildProcessValuesTable, 

272 dpkg_arch_query: DpkgArchTable, 

273 *, 

274 is_main_package: bool = False, 

275 should_be_acted_on: bool = True, 

276 ) -> None: 

277 super(BinaryPackage, self).__init__() 

278 # Typing-wise, Deb822ParagraphElement is *not* a Mapping[str, str] but it behaves enough 

279 # like one that we rely on it and just cast it. 

280 self._package_fields = cast("Mapping[str, str]", fields) 

281 self._dbgsym_binary_package = None 

282 self._should_be_acted_on = should_be_acted_on 

283 self._dpkg_architecture_variables = dpkg_architecture_variables 

284 self._is_main_package = is_main_package 

285 self._declared_arch_matches_output_arch = _check_binary_arch( 

286 dpkg_arch_query, self.resolved_architecture, self.declared_architecture 

287 ) 

288 

289 @property 

290 def name(self) -> str: 

291 return self.fields["Package"] 

292 

293 @property 

294 def archive_section(self) -> str: 

295 value = self.fields.get("Section") 

296 if value is None: 296 ↛ 297line 296 didn't jump to line 297 because the condition on line 296 was never true

297 return "Unknown" 

298 return value 

299 

300 @property 

301 def archive_component(self) -> str: 

302 component = "" 

303 section = self.archive_section 

304 if "/" in section: 

305 component = section.rsplit("/", 1)[0] 

306 # The "main" component is always shortened to "" 

307 if component == "main": 

308 component = "" 

309 return component 

310 

311 @property 

312 def is_essential(self) -> bool: 

313 return self._package_fields.get("Essential") == "yes" 

314 

315 @property 

316 def is_udeb(self) -> bool: 

317 return self.package_type == UDEB_PACKAGE_TYPE 

318 

319 @property 

320 def should_be_acted_on(self) -> bool: 

321 return self._should_be_acted_on and self._declared_arch_matches_output_arch 

322 

323 @property 

324 def fields(self) -> Mapping[str, str]: 

325 return self._package_fields 

326 

327 @property 

328 def resolved_architecture(self) -> str: 

329 arch = self.declared_architecture 

330 if arch == "all": 

331 return arch 

332 if self._x_dh_build_for_type == "target": 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true

333 return self._dpkg_architecture_variables["DEB_TARGET_ARCH"] 

334 return self._dpkg_architecture_variables.current_host_arch 

335 

336 def package_deb_architecture_variable(self, variable_suffix: str) -> str: 

337 if self._x_dh_build_for_type == "target": 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true

338 return self._dpkg_architecture_variables[f"DEB_TARGET_{variable_suffix}"] 

339 return self._dpkg_architecture_variables[f"DEB_HOST_{variable_suffix}"] 

340 

341 @property 

342 def deb_multiarch(self) -> str: 

343 return self.package_deb_architecture_variable("MULTIARCH") 

344 

345 @property 

346 def _x_dh_build_for_type(self) -> str: 

347 v = self._package_fields.get("X-DH-Build-For-Type") 

348 if v is None: 348 ↛ 350line 348 didn't jump to line 350 because the condition on line 348 was always true

349 return "host" 

350 return v.lower() 

351 

352 @property 

353 def package_type(self) -> str: 

354 """Short for Package-Type (with proper default if absent)""" 

355 v = self.fields.get("Package-Type") 

356 if v is None: 

357 return DEFAULT_PACKAGE_TYPE 

358 return v 

359 

360 @property 

361 def is_main_package(self) -> bool: 

362 return self._is_main_package 

363 

364 def cross_command(self, command: str) -> str: 

365 arch_table = self._dpkg_architecture_variables 

366 if self._x_dh_build_for_type == "target": 

367 target_gnu_type = arch_table["DEB_TARGET_GNU_TYPE"] 

368 if arch_table["DEB_HOST_GNU_TYPE"] != target_gnu_type: 

369 return f"{target_gnu_type}-{command}" 

370 if arch_table.is_cross_compiling: 

371 return f"{arch_table['DEB_HOST_GNU_TYPE']}-{command}" 

372 return command 

373 

374 @property 

375 def declared_architecture(self) -> str: 

376 return self.fields["Architecture"] 

377 

378 @property 

379 def is_arch_all(self) -> bool: 

380 return self.declared_architecture == "all" 

381 

382 

383class SourcePackage: 

384 __slots__ = ("_package_fields",) 

385 

386 def __init__(self, fields: Union[Mapping[str, str], Deb822ParagraphElement]): 

387 # Typing-wise, Deb822ParagraphElement is *not* a Mapping[str, str] but it behaves enough 

388 # like one that we rely on it and just cast it. 

389 self._package_fields = cast("Mapping[str, str]", fields) 

390 

391 @property 

392 def fields(self) -> Mapping[str, str]: 

393 return self._package_fields 

394 

395 @property 

396 def name(self) -> str: 

397 return self._package_fields["Source"]