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

203 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-01-16 17:20 +0000

1from typing import Optional, cast, overload 

2from collections.abc import Mapping, Iterable 

3 

4from debian.debian_support import DpkgArchTable 

5 

6from ._deb_options_profiles import DebBuildOptionsAndProfiles 

7from .architecture_support import DpkgArchitectureBuildProcessValuesTable 

8from .lsp.vendoring._deb822_repro import ( 

9 parse_deb822_file, 

10 Deb822ParagraphElement, 

11 Deb822FileElement, 

12) 

13from .util import PackageTypeSelector, _error, active_profiles_match 

14 

15_MANDATORY_BINARY_PACKAGE_FIELD = [ 

16 "Package", 

17 "Architecture", 

18] 

19 

20 

21class DctrlParser: 

22 

23 def __init__( 

24 self, 

25 selected_packages: set[str] | frozenset[str], 

26 excluded_packages: set[str] | frozenset[str], 

27 select_arch_all: bool, 

28 select_arch_any: bool, 

29 dpkg_architecture_variables: None | ( 

30 DpkgArchitectureBuildProcessValuesTable 

31 ) = None, 

32 dpkg_arch_query_table: DpkgArchTable | None = None, 

33 deb_options_and_profiles: DebBuildOptionsAndProfiles | None = None, 

34 ignore_errors: bool = False, 

35 ) -> None: 

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

37 dpkg_architecture_variables = DpkgArchitectureBuildProcessValuesTable() 

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

39 dpkg_arch_query_table = DpkgArchTable.load_arch_table() 

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

41 deb_options_and_profiles = DebBuildOptionsAndProfiles() 

42 

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

44 # excluded ones) 

45 if not selected_packages and not select_arch_all and not select_arch_any: 

46 select_arch_all = True 

47 select_arch_any = True 

48 

49 self.selected_packages = selected_packages 

50 self.excluded_packages = excluded_packages 

51 self.select_arch_all = select_arch_all 

52 self.select_arch_any = select_arch_any 

53 self.dpkg_architecture_variables = dpkg_architecture_variables 

54 self.dpkg_arch_query_table = dpkg_arch_query_table 

55 self.deb_options_and_profiles = deb_options_and_profiles 

56 self.ignore_errors = ignore_errors 

57 

58 @overload 

59 def parse_source_debian_control( 59 ↛ exitline 59 didn't return from function 'parse_source_debian_control' because

60 self, 

61 debian_control_lines: Iterable[str], 

62 ) -> tuple[Deb822FileElement, "SourcePackage", dict[str, "BinaryPackage"]]: ... 

63 

64 @overload 

65 def parse_source_debian_control( 65 ↛ exitline 65 didn't return from function 'parse_source_debian_control' because

66 self, 

67 debian_control_lines: Iterable[str], 

68 *, 

69 ignore_errors: bool = False, 

70 ) -> tuple[ 

71 Deb822FileElement, 

72 Optional["SourcePackage"], 

73 dict[str, "BinaryPackage"] | None, 

74 ]: ... 

75 

76 def parse_source_debian_control( 

77 self, 

78 debian_control_lines: Iterable[str], 

79 *, 

80 ignore_errors: bool = False, 

81 ) -> tuple[ 

82 Deb822FileElement | None, 

83 Optional["SourcePackage"], 

84 dict[str, "BinaryPackage"] | None, 

85 ]: 

86 deb822_file = parse_deb822_file( 

87 debian_control_lines, 

88 accept_files_with_error_tokens=ignore_errors, 

89 accept_files_with_duplicated_fields=ignore_errors, 

90 ) 

91 dctrl_paragraphs = list(deb822_file) 

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

93 if not ignore_errors: 

94 _error( 

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

96 ) 

97 source_package = ( 

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

99 ) 

100 return deb822_file, source_package, None 

101 

102 source_package = SourcePackage(dctrl_paragraphs[0]) 

103 bin_pkgs = [] 

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

105 if ignore_errors: 

106 if "Package" not in p: 

107 continue 

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

109 if missing_field: 

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

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

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

113 # p = Deb822(p) works but mypy refuses it. 

114 copy = p.new_empty_paragraph() 

115 for k in p.iter_keys(): 

116 v = p.get_kvpair_element(k) 

117 assert v is not None 

118 copy.set_kvpair_element(k, v) 

119 

120 for f in _MANDATORY_BINARY_PACKAGE_FIELD: 

121 if f not in p: 

122 copy[f] = "unknown" 

123 p = copy 

124 bin_pkgs.append( 

125 _create_binary_package( 

126 p, 

127 self.selected_packages, 

128 self.excluded_packages, 

129 self.select_arch_all, 

130 self.select_arch_any, 

131 self.dpkg_architecture_variables, 

132 self.dpkg_arch_query_table, 

133 self.deb_options_and_profiles, 

134 i, 

135 ) 

136 ) 

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

138 

139 if not ignore_errors: 

140 if not self.selected_packages.issubset(bin_pkgs_table.keys()): 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true

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

142 _error( 

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

144 ) 

145 if not self.excluded_packages.issubset(bin_pkgs_table.keys()): 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true

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

147 _error( 

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

149 ) 

150 

151 return deb822_file, source_package, bin_pkgs_table 

152 

153 

154def _check_package_sets( 

155 provided_packages: set[str], 

156 valid_package_names: set[str], 

157 option_name: str, 

158) -> None: 

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

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

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

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

163 # 

164 # Bug filed: 

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

166 if not provided_packages <= valid_package_names: 

167 non_existing_packages = sorted(provided_packages - valid_package_names) 

168 invalid_package_list = ", ".join(non_existing_packages) 

169 msg = ( 

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

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

172 ) 

173 _error(msg) 

174 

175 

176def _create_binary_package( 

177 paragraph: Deb822ParagraphElement | dict[str, str], 

178 selected_packages: set[str] | frozenset[str], 

179 excluded_packages: set[str] | frozenset[str], 

180 select_arch_all: bool, 

181 select_arch_any: bool, 

182 dpkg_architecture_variables: DpkgArchitectureBuildProcessValuesTable, 

183 dpkg_arch_query_table: DpkgArchTable, 

184 build_env: DebBuildOptionsAndProfiles, 

185 paragraph_index: int, 

186) -> "BinaryPackage": 

187 try: 

188 package_name = paragraph["Package"] 

189 except KeyError: 

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

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

192 raise 

193 

194 for mandatory_field in _MANDATORY_BINARY_PACKAGE_FIELD: 

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

196 _error( 

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

198 f" (stanza number {paragraph_index})" 

199 ) 

200 

201 architecture = paragraph["Architecture"] 

202 

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

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

205 is_main_package = paragraph_index == 1 

206 

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

208 should_act_on = False 

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

210 should_act_on = True 

211 elif architecture == "all": 

212 should_act_on = select_arch_all 

213 else: 

214 should_act_on = select_arch_any 

215 

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

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

218 try: 

219 should_act_on = active_profiles_match( 

220 profiles_raw, build_env.deb_build_profiles 

221 ) 

222 except ValueError as e: 

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

224 

225 return BinaryPackage( 

226 paragraph, 

227 dpkg_architecture_variables, 

228 dpkg_arch_query_table, 

229 should_be_acted_on=should_act_on, 

230 is_main_package=is_main_package, 

231 ) 

232 

233 

234def _check_binary_arch( 

235 arch_table: DpkgArchTable, 

236 binary_arch: str, 

237 declared_arch: str, 

238) -> bool: 

239 if binary_arch == "all": 

240 return True 

241 arch_wildcards = declared_arch.split() 

242 for arch_wildcard in arch_wildcards: 

243 if arch_table.matches_architecture(binary_arch, arch_wildcard): 

244 return True 

245 return False 

246 

247 

248class BinaryPackage: 

249 __slots__ = [ 

250 "_package_fields", 

251 "_dbgsym_binary_package", 

252 "_should_be_acted_on", 

253 "_dpkg_architecture_variables", 

254 "_declared_arch_matches_output_arch", 

255 "_is_main_package", 

256 "_substvars", 

257 "_maintscript_snippets", 

258 ] 

259 

260 def __init__( 

261 self, 

262 fields: Mapping[str, str] | Deb822ParagraphElement, 

263 dpkg_architecture_variables: DpkgArchitectureBuildProcessValuesTable, 

264 dpkg_arch_query: DpkgArchTable, 

265 *, 

266 is_main_package: bool = False, 

267 should_be_acted_on: bool = True, 

268 ) -> None: 

269 super().__init__() 

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

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

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

273 self._dbgsym_binary_package = None 

274 self._should_be_acted_on = should_be_acted_on 

275 self._dpkg_architecture_variables = dpkg_architecture_variables 

276 self._is_main_package = is_main_package 

277 self._declared_arch_matches_output_arch = _check_binary_arch( 

278 dpkg_arch_query, self.resolved_architecture, self.declared_architecture 

279 ) 

280 

281 @property 

282 def name(self) -> str: 

283 return self.fields["Package"] 

284 

285 @property 

286 def archive_section(self) -> str: 

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

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

289 return "Unknown" 

290 return value 

291 

292 @property 

293 def archive_component(self) -> str: 

294 component = "" 

295 section = self.archive_section 

296 if "/" in section: 

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

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

299 if component == "main": 

300 component = "" 

301 return component 

302 

303 @property 

304 def is_essential(self) -> bool: 

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

306 

307 @property 

308 def is_udeb(self) -> bool: 

309 return self.package_type is PackageTypeSelector.UDEB 

310 

311 @property 

312 def should_be_acted_on(self) -> bool: 

313 return self._should_be_acted_on and self._declared_arch_matches_output_arch 

314 

315 @property 

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

317 return self._package_fields 

318 

319 @property 

320 def resolved_architecture(self) -> str: 

321 arch = self.declared_architecture 

322 if arch == "all": 

323 return arch 

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

325 return self._dpkg_architecture_variables["DEB_TARGET_ARCH"] 

326 return self._dpkg_architecture_variables.current_host_arch 

327 

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

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

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

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

332 

333 @property 

334 def deb_multiarch(self) -> str: 

335 return self.package_deb_architecture_variable("MULTIARCH") 

336 

337 @property 

338 def _x_dh_build_for_type(self) -> str: 

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

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

341 return "host" 

342 return v.lower() 

343 

344 @property 

345 def package_type(self) -> PackageTypeSelector.Singleton: 

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

347 v = self.fields.get("Package-Type", default="deb") 

348 try: 

349 return PackageTypeSelector.singleton(v) 

350 except KeyError: 

351 _error(f"invalid Package-Type: {v}, expected: {PackageTypeSelector.ALL})") 

352 

353 @property 

354 def is_main_package(self) -> bool: 

355 return self._is_main_package 

356 

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

358 arch_table = self._dpkg_architecture_variables 

359 if self._x_dh_build_for_type == "target": 

360 target_gnu_type = arch_table["DEB_TARGET_GNU_TYPE"] 

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

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

363 if arch_table.is_cross_compiling: 

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

365 return command 

366 

367 @property 

368 def declared_architecture(self) -> str: 

369 return self.fields["Architecture"] 

370 

371 @property 

372 def is_arch_all(self) -> bool: 

373 return self.declared_architecture == "all" 

374 

375 

376class SourcePackage: 

377 __slots__ = ("_package_fields",) 

378 

379 def __init__(self, fields: Mapping[str, str] | Deb822ParagraphElement): 

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

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

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

383 

384 @property 

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

386 return self._package_fields 

387 

388 @property 

389 def name(self) -> str: 

390 return self._package_fields["Source"]