Coverage for src/debputy/analysis/debian_dir.py: 30%

345 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-10-12 15:06 +0000

1import dataclasses 

2import json 

3import os 

4import stat 

5import subprocess 

6from typing import ( 

7 AbstractSet, 

8 List, 

9 Tuple, 

10 Optional, 

11 Dict, 

12 Any, 

13 Union, 

14 TypedDict, 

15 NotRequired, 

16) 

17from collections.abc import Mapping, Iterable, Sequence, Iterator, Container 

18 

19from debputy.analysis import REFERENCE_DATA_TABLE 

20from debputy.analysis.analysis_util import flatten_ppfs 

21from debputy.dh.dh_assistant import ( 

22 resolve_active_and_inactive_dh_commands, 

23 read_dh_addon_sequences, 

24 extract_dh_compat_level, 

25) 

26from debputy.integration_detection import determine_debputy_integration_mode 

27from debputy.packager_provided_files import ( 

28 PackagerProvidedFile, 

29 detect_all_packager_provided_files, 

30) 

31from debputy.packages import BinaryPackage, SourcePackage 

32from debputy.plugin.api import ( 

33 VirtualPath, 

34 packager_provided_file_reference_documentation, 

35) 

36from debputy.plugin.api.feature_set import PluginProvidedFeatureSet 

37from debputy.plugin.api.impl import plugin_metadata_for_debputys_own_plugin 

38from debputy.plugin.api.impl_types import ( 

39 PluginProvidedKnownPackagingFile, 

40 DebputyPluginMetadata, 

41 KnownPackagingFileInfo, 

42 DHCompatibilityBasedRule, 

43 PackagerProvidedFileClassSpec, 

44 expand_known_packaging_config_features, 

45) 

46from debputy.plugin.api.spec import DebputyIntegrationMode 

47from debputy.util import ( 

48 assume_not_none, 

49 escape_shell, 

50 _trace_log, 

51 _is_trace_log_enabled, 

52 render_command, 

53) 

54 

55PackagingFileInfo = TypedDict( 

56 "PackagingFileInfo", 

57 { 

58 "path": str, 

59 "binary-package": NotRequired[str], 

60 "install-path": NotRequired[str], 

61 "install-pattern": NotRequired[str], 

62 "file-categories": NotRequired[list[str]], 

63 "config-features": NotRequired[list[str]], 

64 "pkgfile-is-active-in-build": NotRequired[bool], 

65 "pkgfile-stem": NotRequired[str], 

66 "pkgfile-explicit-package-name": NotRequired[bool], 

67 "pkgfile-name-segment": NotRequired[str], 

68 "pkgfile-architecture-restriction": NotRequired[str], 

69 "likely-typo-of": NotRequired[str], 

70 "likely-generated-from": NotRequired[list[str]], 

71 "related-tools": NotRequired[list[str]], 

72 "documentation-uris": NotRequired[list[str]], 

73 "debputy-cmd-templates": NotRequired[list[list[str]]], 

74 "generates": NotRequired[str], 

75 "generated-from": NotRequired[str], 

76 }, 

77) 

78 

79 

80def _perl_json_bool(base: Any | None) -> Any | None: 

81 if base is None: 81 ↛ 82line 81 didn't jump to line 82 because the condition on line 81 was never true

82 return None 

83 

84 if isinstance(base, bool): 84 ↛ 91line 84 didn't jump to line 91 because the condition on line 84 was always true

85 return base 

86 

87 # Account for different ways that Perl will or could return a JSON bool, 

88 # when not using `JSON::PP::{true,false}`. 

89 # 

90 # https://salsa.debian.org/debian/debputy/-/issues/119#note_627057 

91 if isinstance(base, int): 

92 if base == 0: 

93 return False 

94 return True if base == 1 else base 

95 if isinstance(base, str): 

96 return False if base == "" else base 

97 return base 

98 

99 

100def scan_debian_dir( 

101 feature_set: PluginProvidedFeatureSet, 

102 source_package: SourcePackage, 

103 binary_packages: Mapping[str, BinaryPackage], 

104 debian_dir: VirtualPath, 

105 *, 

106 uses_dh_sequencer: bool = True, 

107 dh_sequences: AbstractSet[str] | None = None, 

108) -> tuple[list[PackagingFileInfo], list[str], int, object | None]: 

109 known_packaging_files = feature_set.known_packaging_files 

110 debputy_plugin_metadata = plugin_metadata_for_debputys_own_plugin() 

111 reference_data_set_names = [ 

112 "config-features", 

113 "file-categories", 

114 ] 

115 for n in reference_data_set_names: 

116 assert n in REFERENCE_DATA_TABLE 

117 

118 annotated: list[PackagingFileInfo] = [] 

119 seen_paths: dict[str, PackagingFileInfo] = {} 

120 

121 if dh_sequences is None: 

122 r = read_dh_addon_sequences(debian_dir) 

123 if r is not None: 

124 bd_sequences, dr_sequences, uses_dh_sequencer = r 

125 dh_sequences = bd_sequences | dr_sequences 

126 else: 

127 dh_sequences = set() 

128 uses_dh_sequencer = False 

129 debputy_integration_mode = determine_debputy_integration_mode( 

130 source_package.fields, 

131 dh_sequences, 

132 ) 

133 is_debputy_package = debputy_integration_mode is not None 

134 dh_compat_level, dh_assistant_exit_code = extract_dh_compat_level() 

135 dh_issues = [] 

136 

137 static_packaging_files = { 

138 kpf.detection_value: kpf 

139 for kpf in known_packaging_files.values() 

140 if kpf.detection_method == "path" 

141 } 

142 dh_pkgfile_docs = { 

143 kpf.detection_value: kpf 

144 for kpf in known_packaging_files.values() 

145 if kpf.detection_method == "dh.pkgfile" 

146 } 

147 if is_debputy_package: 

148 all_debputy_ppfs = list( 

149 flatten_ppfs( 

150 detect_all_packager_provided_files( 

151 feature_set, 

152 debian_dir, 

153 binary_packages, 

154 allow_fuzzy_matches=True, 

155 detect_typos=True, 

156 ignore_paths=static_packaging_files, 

157 ) 

158 ) 

159 ) 

160 else: 

161 all_debputy_ppfs = [] 

162 

163 if dh_compat_level is not None: 

164 ( 

165 all_dh_ppfs, 

166 _, 

167 dh_issues, 

168 dh_assistant_exit_code, 

169 ) = resolve_debhelper_config_files( 

170 debian_dir, 

171 binary_packages, 

172 debputy_plugin_metadata, 

173 feature_set, 

174 dh_sequences, 

175 dh_compat_level, 

176 uses_dh_sequencer, 

177 debputy_integration_mode=debputy_integration_mode, 

178 ignore_paths=static_packaging_files, 

179 ) 

180 

181 else: 

182 all_dh_ppfs = [] 

183 

184 for ppf in all_debputy_ppfs: 

185 key = ppf.path.path 

186 ref_doc = ppf.definition.reference_documentation 

187 documentation_uris = ( 

188 ref_doc.format_documentation_uris if ref_doc is not None else None 

189 ) 

190 details: PackagingFileInfo = { 

191 "path": key, 

192 "binary-package": ppf.package_name, 

193 "pkgfile-stem": ppf.definition.stem, 

194 "pkgfile-explicit-package-name": ppf.uses_explicit_package_name, 

195 "pkgfile-is-active-in-build": ppf.definition.has_active_command, 

196 "debputy-cmd-templates": [ 

197 ["debputy", "plugin", "show", "p-p-f", ppf.definition.stem] 

198 ], 

199 } 

200 if ppf.fuzzy_match and key.endswith(".in"): 

201 _merge_list(details, "file-categories", ["generic-template"]) 

202 details["generates"] = key[:-3] 

203 elif assume_not_none(ppf.path.parent_dir).get(ppf.path.name + ".in"): 

204 _merge_list(details, "file-categories", ["generated"]) 

205 details["generated-from"] = key + ".in" 

206 name_segment = ppf.name_segment 

207 arch_restriction = ppf.architecture_restriction 

208 if name_segment is not None: 

209 details["pkgfile-name-segment"] = name_segment 

210 if arch_restriction: 

211 details["pkgfile-architecture-restriction"] = arch_restriction 

212 seen_paths[key] = details 

213 annotated.append(details) 

214 static_details = static_packaging_files.get(key) 

215 if static_details is not None: 

216 # debhelper compat rules does not apply to debputy files 

217 _add_known_packaging_data(details, static_details, None) 

218 if documentation_uris: 

219 details["documentation-uris"] = list(documentation_uris) 

220 

221 _merge_ppfs(annotated, seen_paths, all_dh_ppfs, dh_pkgfile_docs, dh_compat_level) 

222 

223 for virtual_path in _scan_debian_dir(debian_dir): 

224 key = virtual_path.path 

225 if key in seen_paths or _skip_path(virtual_path): 

226 continue 

227 

228 static_match = static_packaging_files.get(virtual_path.path) 

229 if static_match is not None: 

230 details: PackagingFileInfo = { 

231 "path": key, 

232 } 

233 annotated.append(details) 

234 if assume_not_none(virtual_path.parent_dir).get(virtual_path.name + ".in"): 

235 details["generated-from"] = key + ".in" 

236 _merge_list(details, "file-categories", ["generated"]) 

237 _add_known_packaging_data(details, static_match, dh_compat_level) 

238 

239 return annotated, reference_data_set_names, dh_assistant_exit_code, dh_issues 

240 

241 

242def _skip_path(virtual_path: VirtualPath) -> bool: 

243 if virtual_path.is_symlink: 

244 try: 

245 st = os.stat(virtual_path.fs_path) 

246 except FileNotFoundError: 

247 return True 

248 else: 

249 if not stat.S_ISREG(st.st_mode): 

250 return True 

251 elif not virtual_path.is_file: 

252 return True 

253 return False 

254 

255 

256def _fake_PPFClassSpec( 

257 debputy_plugin_metadata: DebputyPluginMetadata, 

258 stem: str, 

259 doc_uris: Sequence[str] | None, 

260 install_pattern: str | None, 

261 *, 

262 default_priority: int | None = None, 

263 packageless_is_fallback_for_all_packages: bool = False, 

264 post_formatting_rewrite: str | None = None, 

265 bug_950723: bool = False, 

266 has_active_command: bool = False, 

267) -> PackagerProvidedFileClassSpec: 

268 if install_pattern is None: 268 ↛ 270line 268 didn't jump to line 270 because the condition on line 268 was always true

269 install_pattern = "not-a-real-ppf" 

270 if post_formatting_rewrite is not None: 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true

271 formatting_hook = _POST_FORMATTING_REWRITE[post_formatting_rewrite] 

272 else: 

273 formatting_hook = None 

274 return PackagerProvidedFileClassSpec( 

275 debputy_plugin_metadata, 

276 stem, 

277 install_pattern, 

278 allow_architecture_segment=True, 

279 allow_name_segment=True, 

280 default_priority=default_priority, 

281 default_mode=0o644, 

282 post_formatting_rewrite=formatting_hook, 

283 packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages, 

284 reservation_only=False, 

285 formatting_callback=None, 

286 bug_950723=bug_950723, 

287 has_active_command=has_active_command, 

288 reference_documentation=packager_provided_file_reference_documentation( 

289 format_documentation_uris=doc_uris, 

290 ), 

291 ) 

292 

293 

294def _relevant_dh_compat_rules( 

295 compat_level: int | None, 

296 info: KnownPackagingFileInfo, 

297) -> Iterable[DHCompatibilityBasedRule]: 

298 if compat_level is None: 

299 return 

300 dh_compat_rules = info.get("dh_compat_rules") 

301 if not dh_compat_rules: 

302 return 

303 for dh_compat_rule in dh_compat_rules: 

304 rule_compat_level = dh_compat_rule.get("starting_with_compat_level") 

305 if rule_compat_level is not None and compat_level < rule_compat_level: 

306 continue 

307 yield dh_compat_rule 

308 

309 

310def _kpf_install_pattern( 

311 compat_level: int | None, 

312 ppkpf: PluginProvidedKnownPackagingFile, 

313) -> str | None: 

314 for compat_rule in _relevant_dh_compat_rules(compat_level, ppkpf.info): 

315 install_pattern = compat_rule.get("install_pattern") 

316 if install_pattern is not None: 

317 return install_pattern 

318 return ppkpf.info.get("install_pattern") 

319 

320 

321def resolve_debhelper_config_files( 

322 debian_dir: VirtualPath, 

323 binary_packages: Mapping[str, BinaryPackage], 

324 debputy_plugin_metadata: DebputyPluginMetadata, 

325 plugin_feature_set: PluginProvidedFeatureSet, 

326 dh_rules_addons: AbstractSet[str], 

327 dh_compat_level: int, 

328 saw_dh: bool, 

329 ignore_paths: Container[str] = frozenset(), 

330 *, 

331 debputy_integration_mode: DebputyIntegrationMode | None = None, 

332 cwd: str | None = None, 

333) -> tuple[list[PackagerProvidedFile], list[str], object | None, int]: 

334 

335 dh_ppf_docs = { 

336 kpf.detection_value: kpf 

337 for kpf in plugin_feature_set.known_packaging_files.values() 

338 if kpf.detection_method == "dh.pkgfile" 

339 } 

340 

341 dh_ppfs = {} 

342 commands, exit_code = _relevant_dh_commands(dh_rules_addons, cwd=cwd) 

343 

344 cmd = ["dh_assistant", "list-guessed-dh-config-files"] 

345 if dh_rules_addons: 

346 addons = ",".join(dh_rules_addons) 

347 cmd.append(f"--with={addons}") 

348 try: 

349 output = subprocess.check_output( 

350 cmd, 

351 stderr=subprocess.DEVNULL, 

352 cwd=cwd, 

353 ) 

354 except (subprocess.CalledProcessError, FileNotFoundError) as e: 

355 config_files = [] 

356 issues = None 

357 missing_introspection: list[str] = [] 

358 if isinstance(e, subprocess.CalledProcessError): 

359 exit_code = e.returncode 

360 else: 

361 exit_code = 127 

362 if _is_trace_log_enabled(): 

363 _trace_log( 

364 f"Command {render_command(*cmd, cwd=cwd)} failed with {exit_code} ({cwd=})" 

365 ) 

366 else: 

367 result = json.loads(output) 

368 config_files = result.get("config-files", []) 

369 missing_introspection: list[str] = result.get("commands-not-introspectable", []) 

370 issues = result.get("issues") 

371 if _is_trace_log_enabled(): 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true

372 _trace_log( 

373 f"Command {render_command(*cmd, cwd=cwd)} returned successfully: {output}" 

374 ) 

375 dh_commands = resolve_active_and_inactive_dh_commands(dh_rules_addons) 

376 for config_file in config_files: 

377 if not isinstance(config_file, dict): 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true

378 continue 

379 if config_file.get("file-type") != "pkgfile": 

380 continue 

381 stem = config_file.get("pkgfile") 

382 if stem is None: 382 ↛ 383line 382 didn't jump to line 383 because the condition on line 382 was never true

383 continue 

384 internal = config_file.get("internal") 

385 if isinstance(internal, dict): 

386 bug_950723 = internal.get("bug#950723", False) is True 

387 else: 

388 bug_950723 = False 

389 commands = config_file.get("commands") 

390 documentation_uris = [] 

391 related_tools = [] 

392 seen_commands = set() 

393 seen_docs = set() 

394 ppkpf = dh_ppf_docs.get(stem) 

395 

396 if ppkpf: 396 ↛ 397line 396 didn't jump to line 397 because the condition on line 396 was never true

397 dh_cmds = ppkpf.info.get("debhelper_commands") 

398 doc_uris = ppkpf.info.get("documentation_uris") 

399 default_priority = ppkpf.info.get("default_priority") 

400 if doc_uris is not None: 

401 seen_docs.update(doc_uris) 

402 documentation_uris.extend(doc_uris) 

403 if dh_cmds is not None: 

404 seen_commands.update(dh_cmds) 

405 related_tools.extend(dh_cmds) 

406 install_pattern = _kpf_install_pattern(dh_compat_level, ppkpf) 

407 post_formatting_rewrite = ppkpf.info.get("post_formatting_rewrite") 

408 packageless_is_fallback_for_all_packages = ppkpf.info.get( 

409 "packageless_is_fallback_for_all_packages", 

410 False, 

411 ) 

412 # If it is a debhelper PPF, then `has_active_command` is false by default. 

413 has_active_command = ppkpf.info.get("has_active_command", False) 

414 else: 

415 install_pattern = None 

416 default_priority = None 

417 post_formatting_rewrite = None 

418 packageless_is_fallback_for_all_packages = False 

419 has_active_command = False 

420 for command in commands: 

421 if isinstance(command, dict): 421 ↛ 420line 421 didn't jump to line 420 because the condition on line 421 was always true

422 command_name = command.get("command") 

423 if isinstance(command_name, str) and command_name: 423 ↛ 432line 423 didn't jump to line 432 because the condition on line 423 was always true

424 if command_name not in seen_commands: 424 ↛ 427line 424 didn't jump to line 427 because the condition on line 424 was always true

425 related_tools.append(command_name) 

426 seen_commands.add(command_name) 

427 manpage = f"man:{command_name}(1)" 

428 if manpage not in seen_docs: 428 ↛ 433line 428 didn't jump to line 433 because the condition on line 428 was always true

429 documentation_uris.append(manpage) 

430 seen_docs.add(manpage) 

431 else: 

432 continue 

433 is_active = _perl_json_bool(command.get("is-active", True)) 

434 if is_active is None and command_name in dh_commands.active_commands: 434 ↛ 435line 434 didn't jump to line 435 because the condition on line 434 was never true

435 is_active = True 

436 if not isinstance(is_active, bool): 436 ↛ 437line 436 didn't jump to line 437 because the condition on line 436 was never true

437 continue 

438 if is_active: 

439 has_active_command = True 

440 

441 if debputy_integration_mode == "full": 441 ↛ 443line 441 didn't jump to line 443 because the condition on line 441 was never true

442 # dh commands are never active in full integration mode. 

443 has_active_command = False 

444 elif not saw_dh: 444 ↛ 448line 444 didn't jump to line 448 because the condition on line 444 was always true

445 # If we did not see `dh`, we assume classic `debhelper` where we have no way of knowing 

446 # which commands are active. 

447 has_active_command = True 

448 dh_ppfs[stem] = _fake_PPFClassSpec( 

449 debputy_plugin_metadata, 

450 stem, 

451 documentation_uris, 

452 install_pattern, 

453 default_priority=default_priority, 

454 post_formatting_rewrite=post_formatting_rewrite, 

455 packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages, 

456 bug_950723=bug_950723, 

457 has_active_command=has_active_command, 

458 ) 

459 for ppkpf in dh_ppf_docs.values(): 459 ↛ 460line 459 didn't jump to line 460 because the loop on line 459 never started

460 stem = ppkpf.detection_value 

461 if stem in dh_ppfs: 

462 continue 

463 

464 default_priority = ppkpf.info.get("default_priority") 

465 install_pattern = _kpf_install_pattern(dh_compat_level, ppkpf) 

466 post_formatting_rewrite = ppkpf.info.get("post_formatting_rewrite") 

467 packageless_is_fallback_for_all_packages = ppkpf.info.get( 

468 "packageless_is_fallback_for_all_packages", 

469 False, 

470 ) 

471 has_active_command = ( 

472 ppkpf.info.get("has_active_command", False) if saw_dh else False 

473 ) 

474 if not has_active_command: 

475 dh_cmds = ppkpf.info.get("debhelper_commands") 

476 if dh_cmds: 

477 has_active_command = any( 

478 c in dh_commands.active_commands for c in dh_cmds 

479 ) 

480 dh_ppfs[stem] = _fake_PPFClassSpec( 

481 debputy_plugin_metadata, 

482 stem, 

483 ppkpf.info.get("documentation_uris"), 

484 install_pattern, 

485 default_priority=default_priority, 

486 post_formatting_rewrite=post_formatting_rewrite, 

487 packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages, 

488 has_active_command=has_active_command, 

489 ) 

490 dh_ppf_feature_set = _fake_plugin_feature_set(plugin_feature_set, dh_ppfs) 

491 all_dh_ppfs = list( 

492 flatten_ppfs( 

493 detect_all_packager_provided_files( 

494 dh_ppf_feature_set, 

495 debian_dir, 

496 binary_packages, 

497 allow_fuzzy_matches=True, 

498 detect_typos=True, 

499 ignore_paths=ignore_paths, 

500 ) 

501 ) 

502 ) 

503 return all_dh_ppfs, missing_introspection, issues, exit_code 

504 

505 

506def _fake_plugin_feature_set( 

507 plugin_feature_set: PluginProvidedFeatureSet, 

508 fake_defs: Mapping[str, PackagerProvidedFileClassSpec], 

509) -> PluginProvidedFeatureSet: 

510 return dataclasses.replace(plugin_feature_set, packager_provided_files=fake_defs) 

511 

512 

513def _merge_list( 

514 existing_table: dict[str, Any], 

515 key: str, 

516 new_data: Sequence[str] | None, 

517) -> None: 

518 if not new_data: 

519 return 

520 existing_values = existing_table.get(key, []) 

521 if isinstance(existing_values, tuple): 

522 existing_values = list(existing_values) 

523 assert isinstance(existing_values, list) 

524 seen = set(existing_values) 

525 existing_values.extend(x for x in new_data if x not in seen) 

526 existing_table[key] = existing_values 

527 

528 

529def _merge_ppfs( 

530 identified: list[PackagingFileInfo], 

531 seen_paths: dict[str, PackagingFileInfo], 

532 ppfs: list[PackagerProvidedFile], 

533 context: Mapping[str, PluginProvidedKnownPackagingFile], 

534 dh_compat_level: int | None, 

535) -> None: 

536 for ppf in ppfs: 

537 key = ppf.path.path 

538 ref_doc = ppf.definition.reference_documentation 

539 documentation_uris = ( 

540 ref_doc.format_documentation_uris if ref_doc is not None else None 

541 ) 

542 if not ppf.definition.installed_as_format.startswith("not-a-real-ppf"): 

543 try: 

544 parts = ppf.compute_dest() 

545 except RuntimeError: 

546 dest = None 

547 else: 

548 dest = "/".join(parts).lstrip(".") 

549 else: 

550 dest = None 

551 orig_details = seen_paths.get(key) 

552 if orig_details is None: 

553 details: PackagingFileInfo = { 

554 "path": key, 

555 "pkgfile-stem": ppf.definition.stem, 

556 "pkgfile-is-active-in-build": ppf.definition.has_active_command, 

557 "pkgfile-explicit-package-name": ppf.uses_explicit_package_name, 

558 "binary-package": ppf.package_name, 

559 } 

560 if ppf.expected_path is not None: 

561 details["likely-typo-of"] = ppf.expected_path 

562 identified.append(details) 

563 else: 

564 details = orig_details 

565 # We do not merge the "is typo" field; if the original 

566 for k, v in [ 

567 ("pkgfile-stem", ppf.definition.stem), 

568 ("pkgfile-explicit-package-name", ppf.definition.has_active_command), 

569 ("binary-package", ppf.package_name), 

570 ]: 

571 if k not in details: 

572 details[k] = v 

573 if ppf.definition.has_active_command and details.get( 

574 "pkgfile-is-active-in-build", False 

575 ): 

576 details["pkgfile-is-active-in-build"] = True 

577 if ppf.expected_path is None and "likely-typo-of" in details: 

578 del details["likely-typo-of"] 

579 

580 name_segment = ppf.name_segment 

581 arch_restriction = ppf.architecture_restriction 

582 if name_segment is not None and "pkgfile-name-segment" not in details: 

583 details["pkgfile-name-segment"] = name_segment 

584 if ( 

585 arch_restriction is not None 

586 and "pkgfile-architecture-restriction" not in details 

587 ): 

588 details["pkgfile-architecture-restriction"] = arch_restriction 

589 if ppf.fuzzy_match and key.endswith(".in"): 

590 _merge_list(details, "file-categories", ["generic-template"]) 

591 details["generates"] = key[:-3] 

592 elif assume_not_none(ppf.path.parent_dir).get(ppf.path.name + ".in"): 

593 _merge_list(details, "file-categories", ["generated"]) 

594 details["generated-from"] = key + ".in" 

595 if dest is not None and "install-path" not in details: 

596 details["install-path"] = dest 

597 

598 extra_details = context.get(ppf.definition.stem) 

599 if extra_details is not None: 

600 _add_known_packaging_data(details, extra_details, dh_compat_level) 

601 

602 _merge_list(details, "documentation-uris", documentation_uris) 

603 

604 

605def _relevant_dh_commands( 

606 dh_rules_addons: Iterable[str], 

607 cwd: str | None = None, 

608) -> tuple[list[str], int]: 

609 cmd = ["dh_assistant", "list-commands", "--output-format=json"] 

610 if dh_rules_addons: 

611 addons = ",".join(dh_rules_addons) 

612 cmd.append(f"--with={addons}") 

613 try: 

614 output = subprocess.check_output( 

615 cmd, 

616 stderr=subprocess.DEVNULL, 

617 cwd=cwd, 

618 ) 

619 except (FileNotFoundError, subprocess.CalledProcessError) as e: 

620 exit_code = 127 

621 if isinstance(e, subprocess.CalledProcessError): 

622 exit_code = e.returncode 

623 if _is_trace_log_enabled(): 

624 _trace_log( 

625 f"Command {render_command(*cmd, cwd=cwd)} failed with {exit_code} ({cwd=})" 

626 ) 

627 return [], exit_code 

628 else: 

629 data = json.loads(output) 

630 commands_json = data.get("commands") 

631 commands = [] 

632 if _is_trace_log_enabled(): 632 ↛ 633line 632 didn't jump to line 633 because the condition on line 632 was never true

633 _trace_log( 

634 f"Command {render_command(*cmd, cwd=cwd)} returned successfully: {output}" 

635 ) 

636 for command in commands_json: 

637 if isinstance(command, dict): 637 ↛ 636line 637 didn't jump to line 636 because the condition on line 637 was always true

638 command_name = command.get("command") 

639 if isinstance(command_name, str) and command_name: 639 ↛ 636line 639 didn't jump to line 636 because the condition on line 639 was always true

640 commands.append(command_name) 

641 return commands, 0 

642 

643 

644def _add_known_packaging_data( 

645 details: PackagingFileInfo, 

646 plugin_data: PluginProvidedKnownPackagingFile, 

647 dh_compat_level: int | None, 

648): 

649 install_pattern = _kpf_install_pattern( 

650 dh_compat_level, 

651 plugin_data, 

652 ) 

653 config_features = plugin_data.info.get("config_features") 

654 if config_features: 

655 config_features = expand_known_packaging_config_features( 

656 dh_compat_level or 0, 

657 config_features, 

658 ) 

659 _merge_list(details, "config-features", config_features) 

660 

661 if dh_compat_level is not None: 

662 extra_config_features = [] 

663 for dh_compat_rule in _relevant_dh_compat_rules( 

664 dh_compat_level, plugin_data.info 

665 ): 

666 cf = dh_compat_rule.get("add_config_features") 

667 if cf: 

668 extra_config_features.extend(cf) 

669 if extra_config_features: 

670 extra_config_features = expand_known_packaging_config_features( 

671 dh_compat_level, 

672 extra_config_features, 

673 ) 

674 _merge_list(details, "config-features", extra_config_features) 

675 if "install-pattern" not in details and install_pattern is not None: 

676 details["install-pattern"] = install_pattern 

677 for mk, ok in [ 

678 ("file_categories", "file-categories"), 

679 ("documentation_uris", "documentation-uris"), 

680 ("debputy_cmd_templates", "debputy-cmd-templates"), 

681 ]: 

682 value = plugin_data.info.get(mk) 

683 if value and ok == "debputy-cmd-templates": 

684 value = [escape_shell(*c) for c in value] 

685 _merge_list(details, ok, value) 

686 

687 

688def _scan_debian_dir(debian_dir: VirtualPath) -> Iterator[VirtualPath]: 

689 for p in debian_dir.iterdir: 

690 yield p 

691 if p.is_dir and p.path in ("debian/source", "debian/tests"): 

692 yield from p.iterdir 

693 

694 

695_POST_FORMATTING_REWRITE = { 

696 "period-to-underscore": lambda n: n.replace(".", "_"), 

697}