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

344 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-04-19 20:37 +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 PackageTypeSelector, 

50) 

51 

52PackagingFileInfo = TypedDict( 

53 "PackagingFileInfo", 

54 { 

55 "path": str, 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

71 "generates": NotRequired[str], 

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

73 }, 

74) 

75PackagingFileInfoStringListFieldKey = Literal[ 

76 "config-features", 

77 "debputy-cmd-templates", 

78 "documentation-uris", 

79 "file-categories", 

80] 

81 

82 

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

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

85 return None 

86 

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

88 return base 

89 

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

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

92 # 

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

94 if isinstance(base, int): 

95 if base == 0: 

96 return False 

97 return True if base == 1 else base 

98 if isinstance(base, str): 

99 return False if base == "" else base 

100 return base 

101 

102 

103def scan_debian_dir( 

104 feature_set: PluginProvidedFeatureSet, 

105 source_package: SourcePackage, 

106 binary_packages: Mapping[str, BinaryPackage], 

107 debian_dir: VirtualPath, 

108 *, 

109 uses_dh_sequencer: bool = True, 

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

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

112 known_packaging_files = feature_set.known_packaging_files 

113 debputy_plugin_metadata = plugin_metadata_for_debputys_own_plugin() 

114 reference_data_set_names = [ 

115 "config-features", 

116 "file-categories", 

117 ] 

118 for n in reference_data_set_names: 

119 assert n in REFERENCE_DATA_TABLE 

120 

121 annotated: list[PackagingFileInfo] = [] 

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

123 

124 if dh_sequences is None: 

125 r = read_dh_addon_sequences(debian_dir) 

126 if r is not None: 

127 bd_sequences, dr_sequences, uses_dh_sequencer = r 

128 dh_sequences = bd_sequences | dr_sequences 

129 else: 

130 dh_sequences = set() 

131 uses_dh_sequencer = False 

132 debputy_integration_mode = determine_debputy_integration_mode( 

133 source_package.fields, 

134 dh_sequences, 

135 ) 

136 is_debputy_package = debputy_integration_mode is not None 

137 dh_compat_level, dh_assistant_exit_code = extract_dh_compat_level() 

138 dh_issues: object | None = [] 

139 

140 static_packaging_files = { 

141 kpf.detection_value: kpf 

142 for kpf in known_packaging_files.values() 

143 if kpf.detection_method == "path" 

144 } 

145 dh_pkgfile_docs = { 

146 kpf.detection_value: kpf 

147 for kpf in known_packaging_files.values() 

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

149 } 

150 if is_debputy_package: 

151 all_debputy_ppfs = list( 

152 flatten_ppfs( 

153 detect_all_packager_provided_files( 

154 feature_set, 

155 debian_dir, 

156 binary_packages, 

157 allow_fuzzy_matches=True, 

158 detect_typos=True, 

159 ignore_paths=static_packaging_files, 

160 ) 

161 ) 

162 ) 

163 else: 

164 all_debputy_ppfs = [] 

165 

166 if dh_compat_level is not None: 

167 ( 

168 all_dh_ppfs, 

169 _, 

170 dh_issues, 

171 dh_assistant_exit_code, 

172 ) = resolve_debhelper_config_files( 

173 debian_dir, 

174 binary_packages, 

175 debputy_plugin_metadata, 

176 feature_set, 

177 dh_sequences, 

178 dh_compat_level, 

179 uses_dh_sequencer, 

180 debputy_integration_mode=debputy_integration_mode, 

181 ignore_paths=static_packaging_files, 

182 ) 

183 

184 else: 

185 all_dh_ppfs = [] 

186 

187 for ppf in all_debputy_ppfs: 

188 key = ppf.path.path 

189 ref_doc = ppf.definition.reference_documentation 

190 documentation_uris = ( 

191 ref_doc.format_documentation_uris if ref_doc is not None else None 

192 ) 

193 details: PackagingFileInfo = { 

194 "path": key, 

195 "binary-package": ppf.package_name, 

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

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

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

199 "debputy-cmd-templates": [ 

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

201 ], 

202 } 

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

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

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

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

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

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

209 name_segment = ppf.name_segment 

210 arch_restriction = ppf.architecture_restriction 

211 if name_segment is not None: 

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

213 if arch_restriction: 

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

215 seen_paths[key] = details 

216 annotated.append(details) 

217 static_details = static_packaging_files.get(key) 

218 if static_details is not None: 

219 # debhelper compat rules does not apply to debputy files 

220 _add_known_packaging_data(details, static_details, None) 

221 if documentation_uris: 

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

223 

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

225 

226 for virtual_path in _scan_debian_dir(debian_dir): 

227 key = virtual_path.path 

228 if key in seen_paths or _skip_path(virtual_path): 

229 continue 

230 

231 static_match = static_packaging_files.get(virtual_path.path) 

232 if static_match is not None: 

233 details = { 

234 "path": key, 

235 } 

236 annotated.append(details) 

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

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

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

240 _add_known_packaging_data(details, static_match, dh_compat_level) 

241 

242 return annotated, reference_data_set_names, dh_assistant_exit_code, dh_issues 

243 

244 

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

246 if virtual_path.is_symlink: 

247 try: 

248 st = os.stat(virtual_path.fs_path) 

249 except FileNotFoundError: 

250 return True 

251 else: 

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

253 return True 

254 elif not virtual_path.is_file: 

255 return True 

256 return False 

257 

258 

259def _fake_PPFClassSpec( 

260 debputy_plugin_metadata: DebputyPluginMetadata, 

261 stem: str, 

262 doc_uris: Sequence[str] | None, 

263 install_pattern: str | None, 

264 *, 

265 default_priority: int | None = None, 

266 packageless_is_fallback_for_all_packages: bool = False, 

267 package_types: PackageTypeSelector = PackageTypeSelector.ALL, 

268 post_formatting_rewrite: str | None = None, 

269 bug_950723: bool = False, 

270 has_active_command: bool = False, 

271) -> PackagerProvidedFileClassSpec: 

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

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

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

275 formatting_hook = _POST_FORMATTING_REWRITE[post_formatting_rewrite] 

276 else: 

277 formatting_hook = None 

278 return PackagerProvidedFileClassSpec( 

279 debputy_plugin_metadata, 

280 stem, 

281 install_pattern, 

282 allow_architecture_segment=True, 

283 allow_name_segment=True, 

284 default_priority=default_priority, 

285 default_mode=0o644, 

286 post_formatting_rewrite=formatting_hook, 

287 packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages, 

288 package_types=package_types, 

289 reservation_only=False, 

290 formatting_callback=None, 

291 bug_950723=bug_950723, 

292 has_active_command=has_active_command, 

293 reference_documentation=packager_provided_file_reference_documentation( 

294 format_documentation_uris=doc_uris, 

295 ), 

296 ) 

297 

298 

299def _relevant_dh_compat_rules( 

300 compat_level: int | None, 

301 info: KnownPackagingFileInfo, 

302) -> Iterable[DHCompatibilityBasedRule]: 

303 if compat_level is None: 

304 return 

305 dh_compat_rules = info.get("dh_compat_rules") 

306 if not dh_compat_rules: 

307 return 

308 for dh_compat_rule in dh_compat_rules: 

309 rule_compat_level = dh_compat_rule.get("starting_with_compat_level") 

310 if rule_compat_level is not None and compat_level < rule_compat_level: 

311 continue 

312 yield dh_compat_rule 

313 

314 

315def _kpf_install_pattern( 

316 compat_level: int | None, 

317 ppkpf: PluginProvidedKnownPackagingFile, 

318) -> str | None: 

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

320 install_pattern = compat_rule.get("install_pattern") 

321 if install_pattern is not None: 

322 return install_pattern 

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

324 

325 

326def resolve_debhelper_config_files( 

327 debian_dir: VirtualPath, 

328 binary_packages: Mapping[str, BinaryPackage], 

329 debputy_plugin_metadata: DebputyPluginMetadata, 

330 plugin_feature_set: PluginProvidedFeatureSet, 

331 dh_rules_addons: AbstractSet[str], 

332 dh_compat_level: int, 

333 saw_dh: bool, 

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

335 *, 

336 debputy_integration_mode: DebputyIntegrationMode | None = None, 

337 cwd: str | None = None, 

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

339 

340 dh_ppf_docs = { 

341 kpf.detection_value: kpf 

342 for kpf in plugin_feature_set.known_packaging_files.values() 

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

344 } 

345 

346 dh_ppfs = {} 

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

348 

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

350 if dh_rules_addons: 

351 addons = ",".join(dh_rules_addons) 

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

353 try: 

354 output = subprocess.check_output( 

355 cmd, 

356 stderr=subprocess.DEVNULL, 

357 cwd=cwd, 

358 encoding="utf-8", 

359 ) 

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

361 config_files = [] 

362 issues = None 

363 missing_introspection: list[str] = [] 

364 if isinstance(e, subprocess.CalledProcessError): 

365 exit_code = e.returncode 

366 else: 

367 exit_code = 127 

368 if _is_trace_log_enabled(): 

369 _trace_log( 

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

371 ) 

372 else: 

373 result = json.loads(output) 

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

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

376 issues = result.get("issues") 

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

378 _trace_log( 

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

380 ) 

381 dh_commands = resolve_active_and_inactive_dh_commands(dh_rules_addons) 

382 for config_file in config_files: 

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

384 continue 

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

386 continue 

387 stem = config_file.get("pkgfile") 

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

389 continue 

390 internal = config_file.get("internal") 

391 if isinstance(internal, dict): 

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

393 else: 

394 bug_950723 = False 

395 commands = config_file.get("commands") 

396 documentation_uris = [] 

397 related_tools = [] 

398 seen_commands = set() 

399 seen_docs = set() 

400 ppkpf = dh_ppf_docs.get(stem) 

401 

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

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

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

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

406 if doc_uris is not None: 

407 seen_docs.update(doc_uris) 

408 documentation_uris.extend(doc_uris) 

409 if dh_cmds is not None: 

410 seen_commands.update(dh_cmds) 

411 related_tools.extend(dh_cmds) 

412 install_pattern = _kpf_install_pattern(dh_compat_level, ppkpf) 

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

414 packageless_is_fallback_for_all_packages = ppkpf.info.get( 

415 "packageless_is_fallback_for_all_packages", 

416 False, 

417 ) 

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

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

420 else: 

421 install_pattern = None 

422 default_priority = None 

423 post_formatting_rewrite = None 

424 packageless_is_fallback_for_all_packages = False 

425 has_active_command = False 

426 for command in commands: 

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

428 command_name = command.get("command") 

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

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

431 related_tools.append(command_name) 

432 seen_commands.add(command_name) 

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

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

435 documentation_uris.append(manpage) 

436 seen_docs.add(manpage) 

437 else: 

438 continue 

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

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

441 is_active = True 

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

443 continue 

444 if is_active: 

445 has_active_command = True 

446 

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

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

449 has_active_command = False 

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

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

452 # which commands are active. 

453 has_active_command = True 

454 dh_ppfs[stem] = _fake_PPFClassSpec( 

455 debputy_plugin_metadata, 

456 stem, 

457 documentation_uris, 

458 install_pattern, 

459 default_priority=default_priority, 

460 post_formatting_rewrite=post_formatting_rewrite, 

461 packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages, 

462 bug_950723=bug_950723, 

463 has_active_command=has_active_command, 

464 ) 

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

466 stem = ppkpf.detection_value 

467 if stem in dh_ppfs: 

468 continue 

469 

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

471 install_pattern = _kpf_install_pattern(dh_compat_level, ppkpf) 

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

473 packageless_is_fallback_for_all_packages = ppkpf.info.get( 

474 "packageless_is_fallback_for_all_packages", 

475 False, 

476 ) 

477 has_active_command = ( 

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

479 ) 

480 if not has_active_command: 

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

482 if dh_cmds: 

483 has_active_command = any( 

484 c in dh_commands.active_commands for c in dh_cmds 

485 ) 

486 dh_ppfs[stem] = _fake_PPFClassSpec( 

487 debputy_plugin_metadata, 

488 stem, 

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

490 install_pattern, 

491 default_priority=default_priority, 

492 post_formatting_rewrite=post_formatting_rewrite, 

493 packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages, 

494 has_active_command=has_active_command, 

495 ) 

496 dh_ppf_feature_set = _fake_plugin_feature_set(plugin_feature_set, dh_ppfs) 

497 all_dh_ppfs = list( 

498 flatten_ppfs( 

499 detect_all_packager_provided_files( 

500 dh_ppf_feature_set, 

501 debian_dir, 

502 binary_packages, 

503 allow_fuzzy_matches=True, 

504 detect_typos=True, 

505 ignore_paths=ignore_paths, 

506 ) 

507 ) 

508 ) 

509 return all_dh_ppfs, missing_introspection, issues, exit_code 

510 

511 

512def _fake_plugin_feature_set( 

513 plugin_feature_set: PluginProvidedFeatureSet, 

514 fake_defs: dict[str, PackagerProvidedFileClassSpec], 

515) -> PluginProvidedFeatureSet: 

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

517 

518 

519def _merge_list( 

520 existing_table: PackagingFileInfo, 

521 key: PackagingFileInfoStringListFieldKey, 

522 new_data: Sequence[str] | None, 

523) -> None: 

524 if not new_data: 

525 return 

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

527 seen = set(existing_values) 

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

529 existing_table[key] = existing_values 

530 

531 

532def _merge_ppfs( 

533 identified: list[PackagingFileInfo], 

534 seen_paths: dict[str, PackagingFileInfo], 

535 ppfs: list[PackagerProvidedFile], 

536 context: Mapping[str, PluginProvidedKnownPackagingFile], 

537 dh_compat_level: int | None, 

538) -> None: 

539 for ppf in ppfs: 

540 key = ppf.path.path 

541 ref_doc = ppf.definition.reference_documentation 

542 documentation_uris = ( 

543 ref_doc.format_documentation_uris if ref_doc is not None else None 

544 ) 

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

546 try: 

547 parts = ppf.compute_dest() 

548 except RuntimeError: 

549 dest = None 

550 else: 

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

552 else: 

553 dest = None 

554 orig_details = seen_paths.get(key) 

555 if orig_details is None: 

556 details: PackagingFileInfo = { 

557 "path": key, 

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

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

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

561 "binary-package": ppf.package_name, 

562 } 

563 if ppf.expected_path is not None: 

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

565 identified.append(details) 

566 else: 

567 details = orig_details 

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

569 if "pkgfile-stem" not in details: 

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

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

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

573 ppf.definition.has_active_command 

574 ) 

575 if "binary-package" not in details: 

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

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

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

579 ): 

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

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

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

583 

584 name_segment = ppf.name_segment 

585 arch_restriction = ppf.architecture_restriction 

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

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

588 if ( 

589 arch_restriction is not None 

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

591 ): 

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

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

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

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

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

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

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

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

600 details["install-path"] = dest 

601 

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

603 if extra_details is not None: 

604 _add_known_packaging_data(details, extra_details, dh_compat_level) 

605 

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

607 

608 

609def _relevant_dh_commands( 

610 dh_rules_addons: Iterable[str], 

611 cwd: str | None = None, 

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

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

614 if dh_rules_addons: 

615 addons = ",".join(dh_rules_addons) 

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

617 try: 

618 output = subprocess.check_output( 

619 cmd, 

620 stderr=subprocess.DEVNULL, 

621 cwd=cwd, 

622 encoding="utf-8", 

623 ) 

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

625 exit_code = 127 

626 if isinstance(e, subprocess.CalledProcessError): 

627 exit_code = e.returncode 

628 if _is_trace_log_enabled(): 

629 _trace_log( 

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

631 ) 

632 return [], exit_code 

633 else: 

634 data = json.loads(output) 

635 commands_json = data.get("commands") 

636 commands = [] 

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

638 _trace_log( 

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

640 ) 

641 for command in commands_json: 

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

643 command_name = command.get("command") 

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

645 commands.append(command_name) 

646 return commands, 0 

647 

648 

649def _add_known_packaging_data( 

650 details: PackagingFileInfo, 

651 plugin_data: PluginProvidedKnownPackagingFile, 

652 dh_compat_level: int | None, 

653): 

654 install_pattern = _kpf_install_pattern( 

655 dh_compat_level, 

656 plugin_data, 

657 ) 

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

659 if config_features: 

660 config_features = expand_known_packaging_config_features( 

661 dh_compat_level or 0, 

662 config_features, 

663 ) 

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

665 

666 if dh_compat_level is not None: 

667 extra_config_features = [] 

668 for dh_compat_rule in _relevant_dh_compat_rules( 

669 dh_compat_level, plugin_data.info 

670 ): 

671 cf = dh_compat_rule.get("add_config_features") 

672 if cf: 

673 extra_config_features.extend(cf) 

674 if extra_config_features: 

675 extra_config_features = expand_known_packaging_config_features( 

676 dh_compat_level, 

677 extra_config_features, 

678 ) 

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

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

681 details["install-pattern"] = install_pattern 

682 

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

684 _merge_list( 

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

686 ) 

687 _merge_list( 

688 details, 

689 "debputy-cmd-templates", 

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

691 ) 

692 

693 

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

695 for p in debian_dir.iterdir(): 

696 yield p 

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

698 yield from p.iterdir() 

699 

700 

701_POST_FORMATTING_REWRITE = { 

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

703}