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

344 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-01-26 19:30 +0000

1import dataclasses 

2import json 

3import os 

4import stat 

5import subprocess 

6from typing import ( 

7 AbstractSet, 

8 Any, 

9 TypedDict, 

10 NotRequired, 

11 Literal, 

12) 

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

14 

15from debputy.analysis import REFERENCE_DATA_TABLE 

16from debputy.analysis.analysis_util import flatten_ppfs 

17from debputy.dh.dh_assistant import ( 

18 resolve_active_and_inactive_dh_commands, 

19 read_dh_addon_sequences, 

20 extract_dh_compat_level, 

21) 

22from debputy.integration_detection import determine_debputy_integration_mode 

23from debputy.packager_provided_files import ( 

24 PackagerProvidedFile, 

25 detect_all_packager_provided_files, 

26) 

27from debputy.packages import BinaryPackage, SourcePackage 

28from debputy.plugin.api import ( 

29 VirtualPath, 

30 packager_provided_file_reference_documentation, 

31) 

32from debputy.plugin.api.feature_set import PluginProvidedFeatureSet 

33from debputy.plugin.api.impl import plugin_metadata_for_debputys_own_plugin 

34from debputy.plugin.api.impl_types import ( 

35 PluginProvidedKnownPackagingFile, 

36 DebputyPluginMetadata, 

37 KnownPackagingFileInfo, 

38 DHCompatibilityBasedRule, 

39 PackagerProvidedFileClassSpec, 

40 expand_known_packaging_config_features, 

41) 

42from debputy.plugin.api.spec import DebputyIntegrationMode 

43from debputy.util import ( 

44 assume_not_none, 

45 escape_shell, 

46 _trace_log, 

47 _is_trace_log_enabled, 

48 render_command, 

49) 

50 

51PackagingFileInfo = TypedDict( 

52 "PackagingFileInfo", 

53 { 

54 "path": str, 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

70 "generates": NotRequired[str], 

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

72 }, 

73) 

74PackagingFileInfoStringListFieldKey = Literal[ 

75 "config-features", 

76 "debputy-cmd-templates", 

77 "documentation-uris", 

78 "file-categories", 

79] 

80 

81 

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

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

84 return None 

85 

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

87 return base 

88 

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

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

91 # 

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

93 if isinstance(base, int): 

94 if base == 0: 

95 return False 

96 return True if base == 1 else base 

97 if isinstance(base, str): 

98 return False if base == "" else base 

99 return base 

100 

101 

102def scan_debian_dir( 

103 feature_set: PluginProvidedFeatureSet, 

104 source_package: SourcePackage, 

105 binary_packages: Mapping[str, BinaryPackage], 

106 debian_dir: VirtualPath, 

107 *, 

108 uses_dh_sequencer: bool = True, 

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

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

111 known_packaging_files = feature_set.known_packaging_files 

112 debputy_plugin_metadata = plugin_metadata_for_debputys_own_plugin() 

113 reference_data_set_names = [ 

114 "config-features", 

115 "file-categories", 

116 ] 

117 for n in reference_data_set_names: 

118 assert n in REFERENCE_DATA_TABLE 

119 

120 annotated: list[PackagingFileInfo] = [] 

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

122 

123 if dh_sequences is None: 

124 r = read_dh_addon_sequences(debian_dir) 

125 if r is not None: 

126 bd_sequences, dr_sequences, uses_dh_sequencer = r 

127 dh_sequences = bd_sequences | dr_sequences 

128 else: 

129 dh_sequences = set() 

130 uses_dh_sequencer = False 

131 debputy_integration_mode = determine_debputy_integration_mode( 

132 source_package.fields, 

133 dh_sequences, 

134 ) 

135 is_debputy_package = debputy_integration_mode is not None 

136 dh_compat_level, dh_assistant_exit_code = extract_dh_compat_level() 

137 dh_issues: object | None = [] 

138 

139 static_packaging_files = { 

140 kpf.detection_value: kpf 

141 for kpf in known_packaging_files.values() 

142 if kpf.detection_method == "path" 

143 } 

144 dh_pkgfile_docs = { 

145 kpf.detection_value: kpf 

146 for kpf in known_packaging_files.values() 

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

148 } 

149 if is_debputy_package: 

150 all_debputy_ppfs = list( 

151 flatten_ppfs( 

152 detect_all_packager_provided_files( 

153 feature_set, 

154 debian_dir, 

155 binary_packages, 

156 allow_fuzzy_matches=True, 

157 detect_typos=True, 

158 ignore_paths=static_packaging_files, 

159 ) 

160 ) 

161 ) 

162 else: 

163 all_debputy_ppfs = [] 

164 

165 if dh_compat_level is not None: 

166 ( 

167 all_dh_ppfs, 

168 _, 

169 dh_issues, 

170 dh_assistant_exit_code, 

171 ) = resolve_debhelper_config_files( 

172 debian_dir, 

173 binary_packages, 

174 debputy_plugin_metadata, 

175 feature_set, 

176 dh_sequences, 

177 dh_compat_level, 

178 uses_dh_sequencer, 

179 debputy_integration_mode=debputy_integration_mode, 

180 ignore_paths=static_packaging_files, 

181 ) 

182 

183 else: 

184 all_dh_ppfs = [] 

185 

186 for ppf in all_debputy_ppfs: 

187 key = ppf.path.path 

188 ref_doc = ppf.definition.reference_documentation 

189 documentation_uris = ( 

190 ref_doc.format_documentation_uris if ref_doc is not None else None 

191 ) 

192 details: PackagingFileInfo = { 

193 "path": key, 

194 "binary-package": ppf.package_name, 

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

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

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

198 "debputy-cmd-templates": [ 

199 f"debputy plugin show p-p-f {ppf.definition.stem}", 

200 ], 

201 } 

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

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

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

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

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

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

208 name_segment = ppf.name_segment 

209 arch_restriction = ppf.architecture_restriction 

210 if name_segment is not None: 

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

212 if arch_restriction: 

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

214 seen_paths[key] = details 

215 annotated.append(details) 

216 static_details = static_packaging_files.get(key) 

217 if static_details is not None: 

218 # debhelper compat rules does not apply to debputy files 

219 _add_known_packaging_data(details, static_details, None) 

220 if documentation_uris: 

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

222 

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

224 

225 for virtual_path in _scan_debian_dir(debian_dir): 

226 key = virtual_path.path 

227 if key in seen_paths or _skip_path(virtual_path): 

228 continue 

229 

230 static_match = static_packaging_files.get(virtual_path.path) 

231 if static_match is not None: 

232 details = { 

233 "path": key, 

234 } 

235 annotated.append(details) 

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

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

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

239 _add_known_packaging_data(details, static_match, dh_compat_level) 

240 

241 return annotated, reference_data_set_names, dh_assistant_exit_code, dh_issues 

242 

243 

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

245 if virtual_path.is_symlink: 

246 try: 

247 st = os.stat(virtual_path.fs_path) 

248 except FileNotFoundError: 

249 return True 

250 else: 

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

252 return True 

253 elif not virtual_path.is_file: 

254 return True 

255 return False 

256 

257 

258def _fake_PPFClassSpec( 

259 debputy_plugin_metadata: DebputyPluginMetadata, 

260 stem: str, 

261 doc_uris: Sequence[str] | None, 

262 install_pattern: str | None, 

263 *, 

264 default_priority: int | None = None, 

265 packageless_is_fallback_for_all_packages: bool = False, 

266 post_formatting_rewrite: str | None = None, 

267 bug_950723: bool = False, 

268 has_active_command: bool = False, 

269) -> PackagerProvidedFileClassSpec: 

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

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

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

273 formatting_hook = _POST_FORMATTING_REWRITE[post_formatting_rewrite] 

274 else: 

275 formatting_hook = None 

276 return PackagerProvidedFileClassSpec( 

277 debputy_plugin_metadata, 

278 stem, 

279 install_pattern, 

280 allow_architecture_segment=True, 

281 allow_name_segment=True, 

282 default_priority=default_priority, 

283 default_mode=0o644, 

284 post_formatting_rewrite=formatting_hook, 

285 packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages, 

286 reservation_only=False, 

287 formatting_callback=None, 

288 bug_950723=bug_950723, 

289 has_active_command=has_active_command, 

290 reference_documentation=packager_provided_file_reference_documentation( 

291 format_documentation_uris=doc_uris, 

292 ), 

293 ) 

294 

295 

296def _relevant_dh_compat_rules( 

297 compat_level: int | None, 

298 info: KnownPackagingFileInfo, 

299) -> Iterable[DHCompatibilityBasedRule]: 

300 if compat_level is None: 

301 return 

302 dh_compat_rules = info.get("dh_compat_rules") 

303 if not dh_compat_rules: 

304 return 

305 for dh_compat_rule in dh_compat_rules: 

306 rule_compat_level = dh_compat_rule.get("starting_with_compat_level") 

307 if rule_compat_level is not None and compat_level < rule_compat_level: 

308 continue 

309 yield dh_compat_rule 

310 

311 

312def _kpf_install_pattern( 

313 compat_level: int | None, 

314 ppkpf: PluginProvidedKnownPackagingFile, 

315) -> str | None: 

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

317 install_pattern = compat_rule.get("install_pattern") 

318 if install_pattern is not None: 

319 return install_pattern 

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

321 

322 

323def resolve_debhelper_config_files( 

324 debian_dir: VirtualPath, 

325 binary_packages: Mapping[str, BinaryPackage], 

326 debputy_plugin_metadata: DebputyPluginMetadata, 

327 plugin_feature_set: PluginProvidedFeatureSet, 

328 dh_rules_addons: AbstractSet[str], 

329 dh_compat_level: int, 

330 saw_dh: bool, 

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

332 *, 

333 debputy_integration_mode: DebputyIntegrationMode | None = None, 

334 cwd: str | None = None, 

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

336 

337 dh_ppf_docs = { 

338 kpf.detection_value: kpf 

339 for kpf in plugin_feature_set.known_packaging_files.values() 

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

341 } 

342 

343 dh_ppfs = {} 

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

345 

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

347 if dh_rules_addons: 

348 addons = ",".join(dh_rules_addons) 

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

350 try: 

351 output = subprocess.check_output( 

352 cmd, 

353 stderr=subprocess.DEVNULL, 

354 cwd=cwd, 

355 encoding="utf-8", 

356 ) 

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

358 config_files = [] 

359 issues = None 

360 missing_introspection: list[str] = [] 

361 if isinstance(e, subprocess.CalledProcessError): 

362 exit_code = e.returncode 

363 else: 

364 exit_code = 127 

365 if _is_trace_log_enabled(): 

366 _trace_log( 

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

368 ) 

369 else: 

370 result = json.loads(output) 

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

372 missing_introspection = result.get("commands-not-introspectable", []) 

373 issues = result.get("issues") 

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

375 _trace_log( 

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

377 ) 

378 dh_commands = resolve_active_and_inactive_dh_commands(dh_rules_addons) 

379 for config_file in config_files: 

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

381 continue 

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

383 continue 

384 stem = config_file.get("pkgfile") 

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

386 continue 

387 internal = config_file.get("internal") 

388 if isinstance(internal, dict): 

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

390 else: 

391 bug_950723 = False 

392 commands = config_file.get("commands") 

393 documentation_uris = [] 

394 related_tools = [] 

395 seen_commands = set() 

396 seen_docs = set() 

397 ppkpf = dh_ppf_docs.get(stem) 

398 

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

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

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

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

403 if doc_uris is not None: 

404 seen_docs.update(doc_uris) 

405 documentation_uris.extend(doc_uris) 

406 if dh_cmds is not None: 

407 seen_commands.update(dh_cmds) 

408 related_tools.extend(dh_cmds) 

409 install_pattern = _kpf_install_pattern(dh_compat_level, ppkpf) 

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

411 packageless_is_fallback_for_all_packages = ppkpf.info.get( 

412 "packageless_is_fallback_for_all_packages", 

413 False, 

414 ) 

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

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

417 else: 

418 install_pattern = None 

419 default_priority = None 

420 post_formatting_rewrite = None 

421 packageless_is_fallback_for_all_packages = False 

422 has_active_command = False 

423 for command in commands: 

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

425 command_name = command.get("command") 

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

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

428 related_tools.append(command_name) 

429 seen_commands.add(command_name) 

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

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

432 documentation_uris.append(manpage) 

433 seen_docs.add(manpage) 

434 else: 

435 continue 

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

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

438 is_active = True 

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

440 continue 

441 if is_active: 

442 has_active_command = True 

443 

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

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

446 has_active_command = False 

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

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

449 # which commands are active. 

450 has_active_command = True 

451 dh_ppfs[stem] = _fake_PPFClassSpec( 

452 debputy_plugin_metadata, 

453 stem, 

454 documentation_uris, 

455 install_pattern, 

456 default_priority=default_priority, 

457 post_formatting_rewrite=post_formatting_rewrite, 

458 packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages, 

459 bug_950723=bug_950723, 

460 has_active_command=has_active_command, 

461 ) 

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

463 stem = ppkpf.detection_value 

464 if stem in dh_ppfs: 

465 continue 

466 

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

468 install_pattern = _kpf_install_pattern(dh_compat_level, ppkpf) 

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

470 packageless_is_fallback_for_all_packages = ppkpf.info.get( 

471 "packageless_is_fallback_for_all_packages", 

472 False, 

473 ) 

474 has_active_command = ( 

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

476 ) 

477 if not has_active_command: 

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

479 if dh_cmds: 

480 has_active_command = any( 

481 c in dh_commands.active_commands for c in dh_cmds 

482 ) 

483 dh_ppfs[stem] = _fake_PPFClassSpec( 

484 debputy_plugin_metadata, 

485 stem, 

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

487 install_pattern, 

488 default_priority=default_priority, 

489 post_formatting_rewrite=post_formatting_rewrite, 

490 packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages, 

491 has_active_command=has_active_command, 

492 ) 

493 dh_ppf_feature_set = _fake_plugin_feature_set(plugin_feature_set, dh_ppfs) 

494 all_dh_ppfs = list( 

495 flatten_ppfs( 

496 detect_all_packager_provided_files( 

497 dh_ppf_feature_set, 

498 debian_dir, 

499 binary_packages, 

500 allow_fuzzy_matches=True, 

501 detect_typos=True, 

502 ignore_paths=ignore_paths, 

503 ) 

504 ) 

505 ) 

506 return all_dh_ppfs, missing_introspection, issues, exit_code 

507 

508 

509def _fake_plugin_feature_set( 

510 plugin_feature_set: PluginProvidedFeatureSet, 

511 fake_defs: dict[str, PackagerProvidedFileClassSpec], 

512) -> PluginProvidedFeatureSet: 

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

514 

515 

516def _merge_list( 

517 existing_table: PackagingFileInfo, 

518 key: PackagingFileInfoStringListFieldKey, 

519 new_data: Sequence[str] | None, 

520) -> None: 

521 if not new_data: 

522 return 

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

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 if "pkgfile-stem" not in details: 

567 details["pkgfile-stem"] = ppf.definition.stem 

568 if "pkgfile-explicit-package-name" not in details: 

569 details["pkgfile-explicit-package-name"] = ( 

570 ppf.definition.has_active_command 

571 ) 

572 if "binary-package" not in details: 

573 details["binary-package"] = ppf.package_name 

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

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

576 ): 

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

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

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

580 

581 name_segment = ppf.name_segment 

582 arch_restriction = ppf.architecture_restriction 

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

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

585 if ( 

586 arch_restriction is not None 

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

588 ): 

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

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

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

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

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

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

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

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

597 details["install-path"] = dest 

598 

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

600 if extra_details is not None: 

601 _add_known_packaging_data(details, extra_details, dh_compat_level) 

602 

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

604 

605 

606def _relevant_dh_commands( 

607 dh_rules_addons: Iterable[str], 

608 cwd: str | None = None, 

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

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

611 if dh_rules_addons: 

612 addons = ",".join(dh_rules_addons) 

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

614 try: 

615 output = subprocess.check_output( 

616 cmd, 

617 stderr=subprocess.DEVNULL, 

618 cwd=cwd, 

619 encoding="utf-8", 

620 ) 

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

622 exit_code = 127 

623 if isinstance(e, subprocess.CalledProcessError): 

624 exit_code = e.returncode 

625 if _is_trace_log_enabled(): 

626 _trace_log( 

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

628 ) 

629 return [], exit_code 

630 else: 

631 data = json.loads(output) 

632 commands_json = data.get("commands") 

633 commands = [] 

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

635 _trace_log( 

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

637 ) 

638 for command in commands_json: 

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

640 command_name = command.get("command") 

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

642 commands.append(command_name) 

643 return commands, 0 

644 

645 

646def _add_known_packaging_data( 

647 details: PackagingFileInfo, 

648 plugin_data: PluginProvidedKnownPackagingFile, 

649 dh_compat_level: int | None, 

650): 

651 install_pattern = _kpf_install_pattern( 

652 dh_compat_level, 

653 plugin_data, 

654 ) 

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

656 if config_features: 

657 config_features = expand_known_packaging_config_features( 

658 dh_compat_level or 0, 

659 config_features, 

660 ) 

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

662 

663 if dh_compat_level is not None: 

664 extra_config_features = [] 

665 for dh_compat_rule in _relevant_dh_compat_rules( 

666 dh_compat_level, plugin_data.info 

667 ): 

668 cf = dh_compat_rule.get("add_config_features") 

669 if cf: 

670 extra_config_features.extend(cf) 

671 if extra_config_features: 

672 extra_config_features = expand_known_packaging_config_features( 

673 dh_compat_level, 

674 extra_config_features, 

675 ) 

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

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

678 details["install-pattern"] = install_pattern 

679 

680 _merge_list(details, "file-categories", plugin_data.info.get("file_categories")) 

681 _merge_list( 

682 details, "documentation-uris", plugin_data.info.get("documentation_uris") 

683 ) 

684 _merge_list( 

685 details, 

686 "debputy-cmd-templates", 

687 [escape_shell(*c) for c in plugin_data.info.get("debputy_cmd_templates", [])], 

688 ) 

689 

690 

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

692 for p in debian_dir.iterdir: 

693 yield p 

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

695 yield from p.iterdir 

696 

697 

698_POST_FORMATTING_REWRITE = { 

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

700}