Coverage for src/debputy/plugins/debputy/metadata_detectors.py: 91%

299 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-08-23 18:25 +0000

1import collections 

2import itertools 

3import os 

4import re 

5import subprocess 

6import textwrap 

7import typing 

8from collections.abc import Iterable, Iterator, Mapping 

9 

10from debian.deb822 import PkgRelation 

11from debputy.manifest_parser.util import AttributePath 

12from debputy.packaging.alternatives import SYSTEM_DEFAULT_PATH_DIRS 

13from debputy.plugin.api import ( 

14 VirtualPath, 

15 BinaryCtrlAccessor, 

16 PackageProcessingContext, 

17) 

18from debputy.plugins.debputy.paths import ( 

19 INITRAMFS_HOOK_DIR, 

20 SYSTEMD_TMPFILES_DIR, 

21 GSETTINGS_SCHEMA_DIR, 

22 SYSTEMD_SYSUSERS_DIR, 

23) 

24from debputy.plugins.debputy.types import ( 

25 DebputyCapability, 

26 BuiltUsing, 

27 StaticBuiltUsing, 

28) 

29from debputy.util import assume_not_none, _debug_log, _error, _warn 

30 

31DPKG_ROOT = '"${DPKG_ROOT}"' 

32DPKG_ROOT_UNQUOTED = "${DPKG_ROOT}" 

33 

34KERNEL_MODULE_EXTENSIONS = tuple( 

35 f"{ext}{comp_ext}" 

36 for ext, comp_ext in itertools.product( 

37 (".o", ".ko"), 

38 ("", ".gz", ".bz2", ".xz"), 

39 ) 

40) 

41 

42 

43def detect_initramfs_hooks( 

44 fs_root: VirtualPath, 

45 ctrl: BinaryCtrlAccessor, 

46 _unused: PackageProcessingContext, 

47) -> None: 

48 hook_dir = fs_root.lookup(INITRAMFS_HOOK_DIR) 

49 if not hook_dir: 

50 return 

51 for _ in hook_dir.iterdir(): 

52 # Only add the trigger if the directory is non-empty. It is unlikely to matter a lot, 

53 # but we do this to match debhelper. 

54 break 

55 else: 

56 return 

57 

58 ctrl.dpkg_trigger("activate-noawait", "update-initramfs") 

59 

60 

61def _all_tmpfiles_conf(fs_root: VirtualPath) -> Iterable[VirtualPath]: 

62 seen_tmpfiles = set() 

63 tmpfiles_dirs = [ 

64 SYSTEMD_TMPFILES_DIR, 

65 "./etc/tmpfiles.d", 

66 ] 

67 for tmpfiles_dir_path in tmpfiles_dirs: 

68 tmpfiles_dir = fs_root.lookup(tmpfiles_dir_path) 

69 if not tmpfiles_dir: 

70 continue 

71 for path in tmpfiles_dir.iterdir(): 

72 if ( 

73 not path.is_file 

74 or not path.name.endswith(".conf") 

75 or path.name in seen_tmpfiles 

76 ): 

77 continue 

78 seen_tmpfiles.add(path.name) 

79 yield path 

80 

81 

82def _read_all_files(paths: collections.abc.Iterable[VirtualPath]) -> str: 

83 contents = [] 

84 

85 for p in paths: 

86 with p.open() as fd: 

87 contents.append(fd.read()) 

88 

89 return "\n".join(contents).rstrip() 

90 

91 

92def detect_systemd_tmpfiles( 

93 fs_root: VirtualPath, 

94 ctrl: BinaryCtrlAccessor, 

95 _unused: PackageProcessingContext, 

96) -> None: 

97 tmpfiles_confs = sorted(_all_tmpfiles_conf(fs_root), key=lambda x: x.name) 

98 if not tmpfiles_confs: 

99 return 

100 

101 tmpfiles_escaped = ctrl.maintscript.escape_shell_words( 

102 *(t.name for t in tmpfiles_confs) 

103 ) 

104 tmpfiles_content = _read_all_files(tmpfiles_confs) 

105 

106 postinst_snippet = textwrap.dedent( 

107 f"systemd-tmpfiles ${ DPKG_ROOT:+--root='$DPKG_ROOT'} --create {tmpfiles_escaped} || true" 

108 ) 

109 

110 after_removal_snippet = textwrap.dedent("""\ 

111 systemd-tmpfiles ${{DPKG_ROOT:+--root="$DPKG_ROOT"}} --remove - >/dev/null <<TMPFILES_EOF || true 

112 {TMPFILES_CONTENT} 

113 TMPFILES_EOF 

114 """).format( 

115 TMPFILES_CONTENT=tmpfiles_content, 

116 ) 

117 purge_snippet = textwrap.dedent("""\ 

118 if systemd-tmpfiles --help | grep -qe "--purge"; then 

119 systemd-tmpfiles ${{DPKG_ROOT:+--root="$DPKG_ROOT"}} --purge - >/dev/null <<TMPFILES_EOF || true 

120 {TMPFILES_CONTENT} 

121 TMPFILES_EOF 

122 fi 

123 """).format( 

124 TMPFILES_CONTENT=tmpfiles_content, 

125 ) 

126 

127 ctrl.maintscript.on_configure(postinst_snippet) 

128 # We use "heredocs" and have to disable indent. 

129 ctrl.maintscript.on_removed(after_removal_snippet, indent=False) 

130 ctrl.maintscript.on_purge(purge_snippet, indent=False) 

131 ctrl.substvars.add_dependency( 

132 "misc:Depends", 

133 "systemd | systemd-standalone-tmpfiles | systemd-tmpfiles", 

134 ) 

135 

136 

137def _all_sysusers_conf(fs_root: VirtualPath) -> Iterable[VirtualPath]: 

138 sysusers_dir = fs_root.lookup(SYSTEMD_SYSUSERS_DIR) 

139 if not sysusers_dir: 

140 return 

141 for child in sysusers_dir.iterdir(): 

142 if not child.name.endswith(".conf"): 

143 continue 

144 yield child 

145 

146 

147def detect_systemd_sysusers( 

148 fs_root: VirtualPath, 

149 ctrl: BinaryCtrlAccessor, 

150 _unused: PackageProcessingContext, 

151) -> None: 

152 sysusers_confs = [p.name for p in _all_sysusers_conf(fs_root)] 

153 if not sysusers_confs: 

154 return 

155 

156 sysusers_escaped = ctrl.maintscript.escape_shell_words(*sysusers_confs) 

157 

158 snippet = textwrap.dedent(f"""\ 

159 systemd-sysusers ${ DPKG_ROOT:+--root="$DPKG_ROOT"} {sysusers_escaped} 

160 """) 

161 

162 ctrl.substvars.add_dependency( 

163 "misc:Depends", "systemd | systemd-standalone-sysusers | systemd-sysusers" 

164 ) 

165 ctrl.maintscript.on_configure(snippet) 

166 

167 

168def detect_commands( 

169 fs_root: VirtualPath, 

170 ctrl: BinaryCtrlAccessor, 

171 context: PackageProcessingContext, 

172) -> None: 

173 if context.binary_package.is_udeb: 

174 return 

175 for path_name in SYSTEM_DEFAULT_PATH_DIRS: 

176 path_dir = fs_root.lookup(path_name) 

177 if path_dir is None or not path_dir.is_dir: 

178 continue 

179 for child in path_dir.iterdir(): 

180 if not (child.is_file or child.is_symlink): 

181 continue 

182 ctrl.substvars.add_dependency("misc:Commands", child.name) 

183 

184 

185def detect_icons( 

186 fs_root: VirtualPath, 

187 ctrl: BinaryCtrlAccessor, 

188 _unused: PackageProcessingContext, 

189) -> None: 

190 icons_root_dir = fs_root.lookup("./usr/share/icons") 

191 if not icons_root_dir: 

192 return 

193 icon_dirs = [] 

194 for subdir in icons_root_dir.iterdir(): 

195 if subdir.name in ("gnome", "hicolor"): 

196 # dh_icons skips this for some reason. 

197 continue 

198 for p in subdir.all_paths(): 

199 if p.is_file and p.name.endswith((".png", ".svg", ".xpm", ".icon")): 

200 icon_dirs.append(subdir.absolute) 

201 break 

202 if not icon_dirs: 

203 return 

204 

205 icon_dir_list_escaped = ctrl.maintscript.escape_shell_words(*icon_dirs) 

206 

207 postinst_snippet = textwrap.dedent(f"""\ 

208 if command -v update-icon-caches >/dev/null; then 

209 update-icon-caches {icon_dir_list_escaped} 

210 fi 

211 """) 

212 

213 postrm_snippet = textwrap.dedent(f"""\ 

214 if command -v update-icon-caches >/dev/null; then 

215 update-icon-caches {icon_dir_list_escaped} 

216 fi 

217 """) 

218 

219 ctrl.maintscript.on_configure(postinst_snippet) 

220 ctrl.maintscript.unconditionally_in_script("postrm", postrm_snippet) 

221 

222 

223def detect_gsettings_dependencies( 

224 fs_root: VirtualPath, 

225 ctrl: BinaryCtrlAccessor, 

226 _unused: PackageProcessingContext, 

227) -> None: 

228 gsettings_schema_dir = fs_root.lookup(GSETTINGS_SCHEMA_DIR) 

229 if not gsettings_schema_dir: 

230 return 

231 

232 for path in gsettings_schema_dir.all_paths(): 

233 if path.is_file and path.name.endswith((".xml", ".override")): 

234 ctrl.substvars.add_dependency( 

235 "misc:Depends", "dconf-gsettings-backend | gsettings-backend" 

236 ) 

237 break 

238 

239 

240def detect_kernel_modules( 

241 fs_root: VirtualPath, 

242 ctrl: BinaryCtrlAccessor, 

243 _unused: PackageProcessingContext, 

244) -> None: 

245 for prefix in [".", "./usr"]: 

246 module_root_dir = fs_root.lookup(f"{prefix}/lib/modules") 

247 

248 if not module_root_dir: 

249 continue 

250 

251 module_version_dirs = [] 

252 

253 for module_version_dir in module_root_dir.iterdir(): 

254 if not module_version_dir.is_dir: 

255 continue 

256 

257 for fs_path in module_version_dir.all_paths(): 

258 if fs_path.name.endswith(KERNEL_MODULE_EXTENSIONS): 

259 module_version_dirs.append(module_version_dir.name) 

260 break 

261 

262 for module_version in module_version_dirs: 

263 module_version_escaped = ctrl.maintscript.escape_shell_words(module_version) 

264 postinst_snippet = textwrap.dedent(f"""\ 

265 if [ -e /boot/System.map-{module_version_escaped} ]; then 

266 depmod -a -F /boot/System.map-{module_version_escaped} {module_version_escaped} || true 

267 fi 

268 """) 

269 

270 postrm_snippet = textwrap.dedent(f"""\ 

271 if [ -e /boot/System.map-{module_version_escaped} ]; then 

272 depmod -a -F /boot/System.map-{module_version_escaped} {module_version_escaped} || true 

273 fi 

274 """) 

275 

276 ctrl.maintscript.on_configure(postinst_snippet) 

277 # TODO: This should probably be on removal. However, this is what debhelper did and we should 

278 # do the same until we are sure (not that it matters a lot). 

279 ctrl.maintscript.unconditionally_in_script("postrm", postrm_snippet) 

280 

281 

282def detect_xfonts( 

283 fs_root: VirtualPath, 

284 ctrl: BinaryCtrlAccessor, 

285 context: PackageProcessingContext, 

286) -> None: 

287 xfonts_root_dir = fs_root.lookup("./usr/share/fonts/X11/") 

288 if not xfonts_root_dir: 

289 return 

290 

291 cmds = [] 

292 cmds_postinst = [] 

293 cmds_postrm = [] 

294 escape_shell_words = ctrl.maintscript.escape_shell_words 

295 package_name = context.binary_package.name 

296 

297 for xfonts_dir in xfonts_root_dir.iterdir(): 

298 xfonts_dirname = xfonts_dir.name 

299 if not xfonts_dir.is_dir or xfonts_dirname.startswith("."): 

300 continue 

301 if fs_root.lookup(f"./etc/X11/xfonts/{xfonts_dirname}/{package_name}.scale"): 

302 cmds.append(escape_shell_words("update-fonts-scale", xfonts_dirname)) 

303 cmds.append( 

304 escape_shell_words("update-fonts-dir", "--x11r7-layout", xfonts_dirname) 

305 ) 

306 alias_file = fs_root.lookup( 

307 f"./etc/X11/xfonts/{xfonts_dirname}/{package_name}.alias" 

308 ) 

309 if alias_file: 

310 cmds_postinst.append( 

311 escape_shell_words( 

312 "update-fonts-alias", 

313 "--include", 

314 alias_file.absolute, 

315 xfonts_dirname, 

316 ) 

317 ) 

318 cmds_postrm.append( 

319 escape_shell_words( 

320 "update-fonts-alias", 

321 "--exclude", 

322 alias_file.absolute, 

323 xfonts_dirname, 

324 ) 

325 ) 

326 

327 if not cmds: 

328 return 

329 

330 postinst_snippet = textwrap.dedent(f"""\ 

331 if command -v update-fonts-dir >/dev/null; then 

332 {';'.join(itertools.chain(cmds, cmds_postinst))} 

333 fi 

334 """) 

335 

336 postrm_snippet = textwrap.dedent(f"""\ 

337 if [ -x "`command -v update-fonts-dir`" ]; then 

338 {';'.join(itertools.chain(cmds, cmds_postrm))} 

339 fi 

340 """) 

341 

342 ctrl.maintscript.unconditionally_in_script("postinst", postinst_snippet) 

343 ctrl.maintscript.unconditionally_in_script("postrm", postrm_snippet) 

344 ctrl.substvars.add_dependency("misc:Depends", "xfonts-utils") 

345 

346 

347# debputy does not support python2, so we do not list python / python2. 

348_PYTHON_PUBLIC_DIST_DIR_NAMES = re.compile(r"(?:pypy|python)3(?:[.]\d+)?") 

349 

350 

351def _public_python_dist_dirs(fs_root: VirtualPath) -> Iterator[VirtualPath]: 

352 usr_lib = fs_root.lookup("./usr/lib") 

353 root_dirs = [] 

354 if usr_lib: 

355 root_dirs.append(usr_lib) 

356 

357 dbg_root = fs_root.lookup("./usr/lib/debug/usr/lib") 

358 if dbg_root: 358 ↛ 359line 358 didn't jump to line 359 because the condition on line 358 was never true

359 root_dirs.append(dbg_root) 

360 

361 for root_dir in root_dirs: 

362 python_dirs = ( 

363 path 

364 for path in root_dir.iterdir() 

365 if path.is_dir and _PYTHON_PUBLIC_DIST_DIR_NAMES.match(path.name) 

366 ) 

367 for python_dir in python_dirs: 

368 dist_packages = python_dir.get("dist-packages") 

369 if not dist_packages: 

370 continue 

371 yield dist_packages 

372 

373 

374def _has_py_file_in_dir(d: VirtualPath) -> bool: 

375 return any(f.is_file and f.name.endswith(".py") for f in d.all_paths()) 

376 

377 

378def detect_pycompile_files( 

379 fs_root: VirtualPath, 

380 ctrl: BinaryCtrlAccessor, 

381 context: PackageProcessingContext, 

382) -> None: 

383 package = context.binary_package.name 

384 # TODO: Support configurable list of private dirs 

385 private_search_dirs = [ 

386 fs_root.lookup(os.path.join(d, package)) 

387 for d in [ 

388 "./usr/share", 

389 "./usr/share/games", 

390 "./usr/lib", 

391 f"./usr/lib/{context.binary_package.deb_multiarch}", 

392 "./usr/lib/games", 

393 ] 

394 ] 

395 private_search_dirs_with_py_files = [ 

396 p for p in private_search_dirs if p is not None and _has_py_file_in_dir(p) 

397 ] 

398 public_search_dirs_has_py_files = any( 

399 p is not None and _has_py_file_in_dir(p) 

400 for p in _public_python_dist_dirs(fs_root) 

401 ) 

402 

403 if not public_search_dirs_has_py_files and not private_search_dirs_with_py_files: 

404 return 

405 

406 # The dh_python3 helper also supports -V and -X. We do not use them. They can be 

407 # replaced by bcep support instead, which is how we will be supporting this kind 

408 # of configuration down the line. 

409 ctrl.maintscript.unconditionally_in_script( 

410 "prerm", 

411 textwrap.dedent(f"""\ 

412 if command -v py3clean >/dev/null 2>&1; then 

413 py3clean -p {package} 

414 else 

415 dpkg -L {package} | sed -En -e '/^(.*)\\/(.+)\\.py$/s,,rm "\\1/__pycache__/\\2".*,e' 

416 find /usr/lib/python3/dist-packages/ -type d -name __pycache__ -empty -print0 | xargs --null --no-run-if-empty rmdir 

417 fi 

418 """), 

419 ) 

420 if public_search_dirs_has_py_files: 

421 ctrl.maintscript.on_configure(textwrap.dedent(f"""\ 

422 if command -v py3compile >/dev/null 2>&1; then 

423 py3compile -p {package} 

424 fi 

425 if command -v pypy3compile >/dev/null 2>&1; then 

426 pypy3compile -p {package} || true 

427 fi 

428 """)) 

429 for private_dir in private_search_dirs_with_py_files: 

430 escaped_dir = ctrl.maintscript.escape_shell_words(private_dir.absolute) 

431 ctrl.maintscript.on_configure(textwrap.dedent(f"""\ 

432 if command -v py3compile >/dev/null 2>&1; then 

433 py3compile -p {package} {escaped_dir} 

434 fi 

435 if command -v pypy3compile >/dev/null 2>&1; then 

436 pypy3compile -p {package} {escaped_dir} || true 

437 fi 

438 """)) 

439 

440 

441def translate_capabilities( 

442 fs_root: VirtualPath, 

443 ctrl: BinaryCtrlAccessor, 

444 _context: PackageProcessingContext, 

445) -> None: 

446 caps = [] 

447 maintscript = ctrl.maintscript 

448 for p in fs_root.all_paths(): 

449 if not p.is_file: 

450 continue 

451 metadata_ref = p.metadata(DebputyCapability) 

452 capability = metadata_ref.value 

453 if capability is None: 

454 continue 

455 

456 abs_path = maintscript.escape_shell_words(p.absolute) 

457 

458 cap_script = "".join( 

459 [ 

460 " # Triggered by: {DEFINITION_SOURCE}\n" 

461 " _TPATH=$(dpkg-divert --truename {ABS_PATH})\n", 

462 ' if setcap {CAP} "{DPKG_ROOT_UNQUOTED}${{_TPATH}}"; then\n', 

463 ' chmod {MODE} "{DPKG_ROOT_UNQUOTED}${{_TPATH}}"\n', 

464 ' echo "Successfully applied capabilities {CAP} on ${{_TPATH}}"\n', 

465 " else\n", 

466 # We do not reset the mode here; generally a re-install or upgrade would re-store both mode, 

467 # and remove the capabilities. 

468 ' echo "The setcap failed to processes {CAP} on ${{_TPATH}}; falling back to no capability support" >&2\n', 

469 " fi\n", 

470 ] 

471 ).format( 

472 CAP=maintscript.escape_shell_words(capability.capabilities).replace( 

473 "\\+", "+" 

474 ), 

475 DPKG_ROOT_UNQUOTED=DPKG_ROOT_UNQUOTED, 

476 ABS_PATH=abs_path, 

477 MODE=maintscript.escape_shell_words(str(capability.capability_mode)), 

478 DEFINITION_SOURCE=capability.definition_source.replace("\n", "\\n"), 

479 ) 

480 assert cap_script.endswith("\n") 

481 caps.append(cap_script) 

482 

483 if not caps: 

484 return 

485 

486 maintscript.on_configure( 

487 textwrap.dedent("""\ 

488 if command -v setcap > /dev/null; then 

489 {SET_CAP_COMMANDS} 

490 unset _TPATH 

491 else 

492 echo "The setcap utility is not installed available; falling back to no capability support" >&2 

493 fi 

494 """).format( 

495 SET_CAP_COMMANDS="".join(caps).rstrip("\n"), 

496 ) 

497 ) 

498 

499 

500def pam_auth_update( 

501 fs_root: VirtualPath, 

502 ctrl: BinaryCtrlAccessor, 

503 _context: PackageProcessingContext, 

504) -> None: 

505 pam_configs_dir = fs_root.lookup("/usr/share/pam-configs") 

506 if not pam_configs_dir: 

507 return 

508 pam_configs = [f.name for f in pam_configs_dir.iterdir() if f.is_file] 

509 if not pam_configs: 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true

510 return 

511 maintscript = ctrl.maintscript 

512 maintscript.on_configure( 

513 'pam-auth-update ${DPKG_ROOT:+--root "$DPKG_ROOT"} --package\n' 

514 ) 

515 maintscript.on_before_removal( 

516 textwrap.dedent( 

517 f"""\ 

518 if [ "${ DPKG_MAINTSCRIPT_PACKAGE_REFCOUNT:-1} " = 1 ]; then 

519 pam-auth-update --package --remove {maintscript.escape_shell_words(*pam_configs)} 

520 fi 

521 """, 

522 ) 

523 ) 

524 

525 

526def auto_depends_arch_any_solink( 

527 fs_foot: VirtualPath, 

528 ctrl: BinaryCtrlAccessor, 

529 context: PackageProcessingContext, 

530) -> None: 

531 package = context.binary_package 

532 if package.is_arch_all: 

533 return 

534 libbasedir = fs_foot.lookup("usr/lib") 

535 if not libbasedir: 

536 return 

537 libmadir = libbasedir.get(package.deb_multiarch) 

538 if libmadir: 538 ↛ 541line 538 didn't jump to line 541 because the condition on line 538 was always true

539 libdirs = [libmadir, libbasedir] 

540 else: 

541 libdirs = [libbasedir] 

542 targets = [] 

543 for libdir in libdirs: 

544 for path in libdir.iterdir(): 

545 if not path.is_symlink or not path.name.endswith(".so"): 

546 continue 

547 target = path.readlink() 

548 resolved = assume_not_none(path.parent_dir).lookup(target) 

549 if resolved is not None: 549 ↛ 550line 549 didn't jump to line 550 because the condition on line 549 was never true

550 continue 

551 targets.append((libdir.path, target)) 

552 

553 roots = [ 

554 (pkg, root) 

555 for pkg, root in context.accessible_package_roots() 

556 if not pkg.is_udeb 

557 ] 

558 

559 if not roots: 

560 return 

561 

562 for libdir_path, target in targets: 

563 final_path = os.path.join(libdir_path, target) 

564 matches = [] 

565 for opkg, ofs_root in roots: 

566 m = ofs_root.lookup(final_path) 

567 if not m: 567 ↛ 568line 567 didn't jump to line 568 because the condition on line 567 was never true

568 continue 

569 matches.append(opkg) 

570 if not matches or len(matches) > 1: 

571 if matches: 571 ↛ 578line 571 didn't jump to line 578 because the condition on line 571 was always true

572 all_matches = ", ".join(p.name for p in matches) 

573 _warn( 

574 f"auto-depends-solink: The {final_path} was found in multiple packages ({all_matches}):" 

575 f" Not generating a dependency." 

576 ) 

577 else: 

578 _warn( 

579 f"auto-depends-solink: The {final_path} was NOT found in any accessible package:" 

580 " Not generating a dependency. This detection only works when both packages are arch:any" 

581 " and they have the same build-profiles." 

582 ) 

583 continue 

584 pkg_dep = matches[0] 

585 # The debputy API should not allow this constraint to fail 

586 assert pkg_dep.is_arch_all == package.is_arch_all 

587 # If both packages are arch:all or both are arch:any, we can generate a tight dependency 

588 relation = f"{pkg_dep.name} (= ${ binary:Version} )" 

589 ctrl.substvars.add_dependency("misc:Depends", relation) 

590 

591 

592def _built_using_enabled_matches( 

593 context: PackageProcessingContext, 

594) -> Iterator["_BuiltUsingTodo"]: 

595 """Helper implementing pass 1 of detect_built_using.""" 

596 pkg = context.binary_package 

597 host = pkg.resolved_architecture 

598 table = context.dpkg_arch_query_table 

599 profiles = context.deb_options_and_profiles.deb_build_profiles 

600 condition_context = context.condition_context(pkg) 

601 for cls, field in ( 

602 (BuiltUsing, "Built-Using"), 

603 (StaticBuiltUsing, "Static-Built-Using"), 

604 ): 

605 for deps, cond, attr in context.manifest_configuration(pkg, cls) or (): 

606 for is_first, relation in deps: 

607 name = relation["name"] 

608 if not PkgRelation.holds_on_arch(relation, host, table): 

609 _debug_log( 

610 f"{attr}: {name} in Build-Depends is disabled by host architecture {host}." 

611 ) 

612 elif not PkgRelation.holds_with_profiles(relation, profiles): 

613 _debug_log( 

614 f"{attr}: {name} in Build-Depends is disabled by profiles {' '.join(profiles)}." 

615 ) 

616 elif cond is not None and not cond.evaluate(condition_context): 

617 _debug_log(f"{attr}: {name} is disabled by its manifest condition.") 

618 else: 

619 yield _BuiltUsingTodo(field, name, is_first, attr) 

620 

621 

622class _BuiltUsingTodo(typing.NamedTuple): 

623 """Data transmitted between the two passes of detect_built_using.""" 

624 

625 field: str 

626 dep: str 

627 first_option: bool # This relation was the first in its alternative. 

628 attribute_path: AttributePath 

629 

630 

631def _split_dpkg_output(dpkg_output: str) -> Iterable[tuple[str, str]]: 

632 for line in dpkg_output.splitlines(keepends=False): 

633 status, package_name, source_and_version = line.split("\x1f") 

634 if status[1] == "i": 634 ↛ 632line 634 didn't jump to line 632 because the condition on line 634 was always true

635 yield package_name, source_and_version 

636 

637 

638def _sources_for( 

639 deps: Iterable[str], 

640) -> Mapping[str, str]: 

641 """Map installed packages among deps to a "source (= version)" 

642 relation, excluding unknown or not installed packages. 

643 

644 >>> r = _sources_for(("dpkg", "dpkg", "dpkg-dev", "gcc", "dummy")) 

645 >>> r["dpkg"] # doctest: +ELLIPSIS 

646 'dpkg (= ...)' 

647 >>> r["dpkg-dev"] # doctest: +ELLIPSIS 

648 'dpkg (= ...)' 

649 >>> r["gcc"] # doctest: +ELLIPSIS 

650 'gcc-defaults (= ...)' 

651 >>> "dummy" in r 

652 False 

653 """ 

654 cp = subprocess.run( 

655 args=( 

656 "dpkg-query", 

657 "-Wf${db:Status-Abbrev}\x1f${Package}\x1f${source:Package} (= ${source:Version})\n", 

658 *deps, 

659 ), 

660 capture_output=True, 

661 check=False, 

662 text=True, 

663 ) 

664 # 0: OK 1: unknown package 2: other 

665 if cp.returncode not in (0, 1): 665 ↛ 666line 665 didn't jump to line 666 because the condition on line 665 was never true

666 _error(f"dpkg-query -W failed (code: {cp.returncode}): {cp.stderr.rstrip()}") 

667 # For the example above, stdout is: 

668 # "ii \x1fdpkg\x1fdpkg (= 1.22.21)\n" 

669 # "ii \x1fdpkg-dev\x1fdpkg (= 1.22.21)\n" 

670 # "ii \x1fgcc\x1fgcc-defaults (= 1.220)\n" 

671 # ^: the package is (i)nstalled 

672 return dict(_split_dpkg_output(cp.stdout)) 

673 

674 

675def detect_built_using( 

676 _fs_root: VirtualPath, 

677 ctrl: BinaryCtrlAccessor, 

678 context: PackageProcessingContext, 

679) -> None: 

680 """For efficiency on patterns like librust-*-dev, a first pass 

681 constructs a todo list, then at most one dpkg-query subprocess is 

682 spawn per binary package and the process actually happens. 

683 """ 

684 

685 all_todos = tuple(_built_using_enabled_matches(context)) 

686 if all_todos: 

687 _built_using_process_matches(ctrl, all_todos) 

688 

689 

690def _built_using_process_matches( 

691 ctrl: BinaryCtrlAccessor, 

692 all_todos: Iterable[_BuiltUsingTodo], 

693) -> None: 

694 """Helper implementing pass 2 of detect_built_using.""" 

695 relevant_sources = _sources_for(sorted(t.dep for t in all_todos)) 

696 already_warned = set() 

697 for field, dep, first_option, attribute_path in all_todos: 

698 if dep in relevant_sources: 698 ↛ 705line 698 didn't jump to line 705 because the condition on line 698 was always true

699 ctrl.substvars.add_dependency( 

700 f"debputy:{field}", 

701 relevant_sources[dep], 

702 ) 

703 # With `Build-Depends: a | b`, in usual configurations, 

704 # `a` is installed but `b` might not be. 

705 elif first_option and dep not in already_warned: 

706 _warn( 

707 f"{attribute_path.path}: {dep} is not installed despite being the first or only option in a Build-Depends clause. It will be omitted from the built-using fields." 

708 ) 

709 already_warned.add(dep) 

710 else: 

711 _debug_log( 

712 f"{attribute_path.path}: {dep} is not installed and therefore not included in the built-using fields." 

713 )