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

298 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-04 09:01 +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(f"""\ 

107 if [ -x "$(command -v systemd-tmpfiles)" ]; then 

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

109 fi 

110 """) 

111 

112 after_removal_snippet = textwrap.dedent("""\ 

113 if [ -x "$(command -v systemd-tmpfiles)" ]; then 

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

115 {TMPFILES_CONTENT} 

116 TMPFILES_EOF 

117 fi 

118 """).format( 

119 TMPFILES_CONTENT=tmpfiles_content, 

120 ) 

121 purge_snippet = textwrap.dedent("""\ 

122 if [ -x "$(command -v systemd-tmpfiles)" ]; then 

123 if systemd-tmpfiles --help 2>/dev/null | grep -qe "--purge"; then 

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

125 {TMPFILES_CONTENT} 

126 TMPFILES_EOF 

127 fi 

128 fi 

129 """).format( 

130 TMPFILES_CONTENT=tmpfiles_content, 

131 ) 

132 

133 ctrl.maintscript.on_configure(postinst_snippet) 

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

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

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

137 

138 

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

140 sysusers_dir = fs_root.lookup(SYSTEMD_SYSUSERS_DIR) 

141 if not sysusers_dir: 

142 return 

143 for child in sysusers_dir.iterdir(): 

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

145 continue 

146 yield child 

147 

148 

149def detect_systemd_sysusers( 

150 fs_root: VirtualPath, 

151 ctrl: BinaryCtrlAccessor, 

152 _unused: PackageProcessingContext, 

153) -> None: 

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

155 if not sysusers_confs: 

156 return 

157 

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

159 

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

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

162 """) 

163 

164 ctrl.substvars.add_dependency( 

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

166 ) 

167 ctrl.maintscript.on_configure(snippet) 

168 

169 

170def detect_commands( 

171 fs_root: VirtualPath, 

172 ctrl: BinaryCtrlAccessor, 

173 context: PackageProcessingContext, 

174) -> None: 

175 if context.binary_package.is_udeb: 

176 return 

177 for path_name in SYSTEM_DEFAULT_PATH_DIRS: 

178 path_dir = fs_root.lookup(path_name) 

179 if path_dir is None or not path_dir.is_dir: 

180 continue 

181 for child in path_dir.iterdir(): 

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

183 continue 

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

185 

186 

187def detect_icons( 

188 fs_root: VirtualPath, 

189 ctrl: BinaryCtrlAccessor, 

190 _unused: PackageProcessingContext, 

191) -> None: 

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

193 if not icons_root_dir: 

194 return 

195 icon_dirs = [] 

196 for subdir in icons_root_dir.iterdir(): 

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

198 # dh_icons skips this for some reason. 

199 continue 

200 for p in subdir.all_paths(): 

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

202 icon_dirs.append(subdir.absolute) 

203 break 

204 if not icon_dirs: 

205 return 

206 

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

208 

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

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

211 update-icon-caches {icon_dir_list_escaped} 

212 fi 

213 """) 

214 

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

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

217 update-icon-caches {icon_dir_list_escaped} 

218 fi 

219 """) 

220 

221 ctrl.maintscript.on_configure(postinst_snippet) 

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

223 

224 

225def detect_gsettings_dependencies( 

226 fs_root: VirtualPath, 

227 ctrl: BinaryCtrlAccessor, 

228 _unused: PackageProcessingContext, 

229) -> None: 

230 gsettings_schema_dir = fs_root.lookup(GSETTINGS_SCHEMA_DIR) 

231 if not gsettings_schema_dir: 

232 return 

233 

234 for path in gsettings_schema_dir.all_paths(): 

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

236 ctrl.substvars.add_dependency( 

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

238 ) 

239 break 

240 

241 

242def detect_kernel_modules( 

243 fs_root: VirtualPath, 

244 ctrl: BinaryCtrlAccessor, 

245 _unused: PackageProcessingContext, 

246) -> None: 

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

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

249 

250 if not module_root_dir: 

251 continue 

252 

253 module_version_dirs = [] 

254 

255 for module_version_dir in module_root_dir.iterdir(): 

256 if not module_version_dir.is_dir: 

257 continue 

258 

259 for fs_path in module_version_dir.all_paths(): 

260 if fs_path.name.endswith(KERNEL_MODULE_EXTENSIONS): 

261 module_version_dirs.append(module_version_dir.name) 

262 break 

263 

264 for module_version in module_version_dirs: 

265 module_version_escaped = ctrl.maintscript.escape_shell_words(module_version) 

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

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

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

269 fi 

270 """) 

271 

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

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

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

275 fi 

276 """) 

277 

278 ctrl.maintscript.on_configure(postinst_snippet) 

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

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

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

282 

283 

284def detect_xfonts( 

285 fs_root: VirtualPath, 

286 ctrl: BinaryCtrlAccessor, 

287 context: PackageProcessingContext, 

288) -> None: 

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

290 if not xfonts_root_dir: 

291 return 

292 

293 cmds = [] 

294 cmds_postinst = [] 

295 cmds_postrm = [] 

296 escape_shell_words = ctrl.maintscript.escape_shell_words 

297 package_name = context.binary_package.name 

298 

299 for xfonts_dir in xfonts_root_dir.iterdir(): 

300 xfonts_dirname = xfonts_dir.name 

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

302 continue 

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

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

305 cmds.append( 

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

307 ) 

308 alias_file = fs_root.lookup( 

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

310 ) 

311 if alias_file: 

312 cmds_postinst.append( 

313 escape_shell_words( 

314 "update-fonts-alias", 

315 "--include", 

316 alias_file.absolute, 

317 xfonts_dirname, 

318 ) 

319 ) 

320 cmds_postrm.append( 

321 escape_shell_words( 

322 "update-fonts-alias", 

323 "--exclude", 

324 alias_file.absolute, 

325 xfonts_dirname, 

326 ) 

327 ) 

328 

329 if not cmds: 

330 return 

331 

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

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

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

335 fi 

336 """) 

337 

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

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

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

341 fi 

342 """) 

343 

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

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

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

347 

348 

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

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

351 

352 

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

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

355 root_dirs = [] 

356 if usr_lib: 

357 root_dirs.append(usr_lib) 

358 

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

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

361 root_dirs.append(dbg_root) 

362 

363 for root_dir in root_dirs: 

364 python_dirs = ( 

365 path 

366 for path in root_dir.iterdir() 

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

368 ) 

369 for python_dir in python_dirs: 

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

371 if not dist_packages: 

372 continue 

373 yield dist_packages 

374 

375 

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

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

378 

379 

380def detect_pycompile_files( 

381 fs_root: VirtualPath, 

382 ctrl: BinaryCtrlAccessor, 

383 context: PackageProcessingContext, 

384) -> None: 

385 package = context.binary_package.name 

386 # TODO: Support configurable list of private dirs 

387 private_search_dirs = [ 

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

389 for d in [ 

390 "./usr/share", 

391 "./usr/share/games", 

392 "./usr/lib", 

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

394 "./usr/lib/games", 

395 ] 

396 ] 

397 private_search_dirs_with_py_files = [ 

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

399 ] 

400 public_search_dirs_has_py_files = any( 

401 p is not None and _has_py_file_in_dir(p) 

402 for p in _public_python_dist_dirs(fs_root) 

403 ) 

404 

405 if not public_search_dirs_has_py_files and not private_search_dirs_with_py_files: 

406 return 

407 

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

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

410 # of configuration down the line. 

411 ctrl.maintscript.unconditionally_in_script( 

412 "prerm", 

413 textwrap.dedent(f"""\ 

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

415 py3clean -p {package} 

416 else 

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

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

419 fi 

420 """), 

421 ) 

422 if public_search_dirs_has_py_files: 

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

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

425 py3compile -p {package} 

426 fi 

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

428 pypy3compile -p {package} || true 

429 fi 

430 """)) 

431 for private_dir in private_search_dirs_with_py_files: 

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

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

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

435 py3compile -p {package} {escaped_dir} 

436 fi 

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

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

439 fi 

440 """)) 

441 

442 

443def translate_capabilities( 

444 fs_root: VirtualPath, 

445 ctrl: BinaryCtrlAccessor, 

446 _context: PackageProcessingContext, 

447) -> None: 

448 caps = [] 

449 maintscript = ctrl.maintscript 

450 for p in fs_root.all_paths(): 

451 if not p.is_file: 

452 continue 

453 metadata_ref = p.metadata(DebputyCapability) 

454 capability = metadata_ref.value 

455 if capability is None: 

456 continue 

457 

458 abs_path = maintscript.escape_shell_words(p.absolute) 

459 

460 cap_script = "".join( 

461 [ 

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

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

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

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

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

467 " else\n", 

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

469 # and remove the capabilities. 

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

471 " fi\n", 

472 ] 

473 ).format( 

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

475 "\\+", "+" 

476 ), 

477 DPKG_ROOT_UNQUOTED=DPKG_ROOT_UNQUOTED, 

478 ABS_PATH=abs_path, 

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

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

481 ) 

482 assert cap_script.endswith("\n") 

483 caps.append(cap_script) 

484 

485 if not caps: 

486 return 

487 

488 maintscript.on_configure( 

489 textwrap.dedent("""\ 

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

491 {SET_CAP_COMMANDS} 

492 unset _TPATH 

493 else 

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

495 fi 

496 """).format( 

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

498 ) 

499 ) 

500 

501 

502def pam_auth_update( 

503 fs_root: VirtualPath, 

504 ctrl: BinaryCtrlAccessor, 

505 _context: PackageProcessingContext, 

506) -> None: 

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

508 if not pam_configs_dir: 

509 return 

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

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

512 return 

513 maintscript = ctrl.maintscript 

514 maintscript.on_configure( 

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

516 ) 

517 maintscript.on_before_removal( 

518 textwrap.dedent( 

519 f"""\ 

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

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

522 fi 

523 """, 

524 ) 

525 ) 

526 

527 

528def auto_depends_arch_any_solink( 

529 fs_foot: VirtualPath, 

530 ctrl: BinaryCtrlAccessor, 

531 context: PackageProcessingContext, 

532) -> None: 

533 package = context.binary_package 

534 if package.is_arch_all: 

535 return 

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

537 if not libbasedir: 

538 return 

539 libmadir = libbasedir.get(package.deb_multiarch) 

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

541 libdirs = [libmadir, libbasedir] 

542 else: 

543 libdirs = [libbasedir] 

544 targets = [] 

545 for libdir in libdirs: 

546 for path in libdir.iterdir(): 

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

548 continue 

549 target = path.readlink() 

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

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

552 continue 

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

554 

555 roots = [ 

556 (pkg, root) 

557 for pkg, root in context.accessible_package_roots() 

558 if not pkg.is_udeb 

559 ] 

560 

561 if not roots: 

562 return 

563 

564 for libdir_path, target in targets: 

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

566 matches = [] 

567 for opkg, ofs_root in roots: 

568 m = ofs_root.lookup(final_path) 

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

570 continue 

571 matches.append(opkg) 

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

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

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

575 _warn( 

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

577 f" Not generating a dependency." 

578 ) 

579 else: 

580 _warn( 

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

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

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

584 ) 

585 continue 

586 pkg_dep = matches[0] 

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

588 assert pkg_dep.is_arch_all == package.is_arch_all 

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

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

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

592 

593 

594def _built_using_enabled_matches( 

595 context: PackageProcessingContext, 

596) -> Iterator["_BuiltUsingTodo"]: 

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

598 pkg = context.binary_package 

599 host = pkg.resolved_architecture 

600 table = context.dpkg_arch_query_table 

601 profiles = context.deb_options_and_profiles.deb_build_profiles 

602 condition_context = context.condition_context(pkg) 

603 for cls, field in ( 

604 (BuiltUsing, "Built-Using"), 

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

606 ): 

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

608 for is_first, relation in deps: 

609 name = relation["name"] 

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

611 _debug_log( 

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

613 ) 

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

615 _debug_log( 

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

617 ) 

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

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

620 else: 

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

622 

623 

624class _BuiltUsingTodo(typing.NamedTuple): 

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

626 

627 field: str 

628 dep: str 

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

630 attribute_path: AttributePath 

631 

632 

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

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

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

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

637 yield package_name, source_and_version 

638 

639 

640def _sources_for( 

641 deps: Iterable[str], 

642) -> Mapping[str, str]: 

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

644 relation, excluding unknown or not installed packages. 

645 

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

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

648 'dpkg (= ...)' 

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

650 'dpkg (= ...)' 

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

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

653 >>> "dummy" in r 

654 False 

655 """ 

656 cp = subprocess.run( 

657 args=( 

658 "dpkg-query", 

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

660 *deps, 

661 ), 

662 capture_output=True, 

663 check=False, 

664 text=True, 

665 ) 

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

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

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

669 # For the example above, stdout is: 

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

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

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

673 # ^: the package is (i)nstalled 

674 return dict(_split_dpkg_output(cp.stdout)) 

675 

676 

677def detect_built_using( 

678 _fs_root: VirtualPath, 

679 ctrl: BinaryCtrlAccessor, 

680 context: PackageProcessingContext, 

681) -> None: 

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

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

684 spawn per binary package and the process actually happens. 

685 """ 

686 

687 all_todos = tuple(_built_using_enabled_matches(context)) 

688 if all_todos: 

689 _built_using_process_matches(ctrl, all_todos) 

690 

691 

692def _built_using_process_matches( 

693 ctrl: BinaryCtrlAccessor, 

694 all_todos: Iterable[_BuiltUsingTodo], 

695) -> None: 

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

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

698 already_warned = set() 

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

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

701 ctrl.substvars.add_dependency( 

702 f"debputy:{field}", 

703 relevant_sources[dep], 

704 ) 

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

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

707 elif first_option and dep not in already_warned: 

708 _warn( 

709 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." 

710 ) 

711 already_warned.add(dep) 

712 else: 

713 _debug_log( 

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

715 )