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

290 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-01-16 17:20 +0000

1import itertools 

2import os 

3import re 

4import subprocess 

5import textwrap 

6import typing 

7from collections.abc import Iterable, Iterator, Mapping 

8 

9from debputy.bug1120283 import holds_with_profiles, holds_on_arch 

10from debputy.manifest_parser.util import AttributePath 

11from debputy.packaging.alternatives import SYSTEM_DEFAULT_PATH_DIRS 

12from debputy.plugin.api import ( 

13 VirtualPath, 

14 BinaryCtrlAccessor, 

15 PackageProcessingContext, 

16) 

17from debputy.plugins.debputy.paths import ( 

18 INITRAMFS_HOOK_DIR, 

19 SYSTEMD_TMPFILES_DIR, 

20 GSETTINGS_SCHEMA_DIR, 

21 SYSTEMD_SYSUSERS_DIR, 

22) 

23from debputy.plugins.debputy.types import ( 

24 DebputyCapability, 

25 BuiltUsing, 

26 StaticBuiltUsing, 

27) 

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

29 

30DPKG_ROOT = '"${DPKG_ROOT}"' 

31DPKG_ROOT_UNQUOTED = "${DPKG_ROOT}" 

32 

33KERNEL_MODULE_EXTENSIONS = tuple( 

34 f"{ext}{comp_ext}" 

35 for ext, comp_ext in itertools.product( 

36 (".o", ".ko"), 

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

38 ) 

39) 

40 

41 

42def detect_initramfs_hooks( 

43 fs_root: VirtualPath, 

44 ctrl: BinaryCtrlAccessor, 

45 _unused: PackageProcessingContext, 

46) -> None: 

47 hook_dir = fs_root.lookup(INITRAMFS_HOOK_DIR) 

48 if not hook_dir: 

49 return 

50 for _ in hook_dir.iterdir: 

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

52 # but we do this to match debhelper. 

53 break 

54 else: 

55 return 

56 

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

58 

59 

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

61 seen_tmpfiles = set() 

62 tmpfiles_dirs = [ 

63 SYSTEMD_TMPFILES_DIR, 

64 "./etc/tmpfiles.d", 

65 ] 

66 for tmpfiles_dir_path in tmpfiles_dirs: 

67 tmpfiles_dir = fs_root.lookup(tmpfiles_dir_path) 

68 if not tmpfiles_dir: 

69 continue 

70 for path in tmpfiles_dir.iterdir: 

71 if ( 

72 not path.is_file 

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

74 or path.name in seen_tmpfiles 

75 ): 

76 continue 

77 seen_tmpfiles.add(path.name) 

78 yield path 

79 

80 

81def detect_systemd_tmpfiles( 

82 fs_root: VirtualPath, 

83 ctrl: BinaryCtrlAccessor, 

84 _unused: PackageProcessingContext, 

85) -> None: 

86 tmpfiles_confs = [ 

87 x.name for x in sorted(_all_tmpfiles_conf(fs_root), key=lambda x: x.name) 

88 ] 

89 if not tmpfiles_confs: 

90 return 

91 

92 tmpfiles_escaped = ctrl.maintscript.escape_shell_words(*tmpfiles_confs) 

93 

94 snippet = textwrap.dedent( 

95 f"""\ 

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

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

98 fi 

99 """ 

100 ) 

101 

102 ctrl.maintscript.on_configure(snippet) 

103 

104 

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

106 sysusers_dir = fs_root.lookup(SYSTEMD_SYSUSERS_DIR) 

107 if not sysusers_dir: 

108 return 

109 for child in sysusers_dir.iterdir: 

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

111 continue 

112 yield child 

113 

114 

115def detect_systemd_sysusers( 

116 fs_root: VirtualPath, 

117 ctrl: BinaryCtrlAccessor, 

118 _unused: PackageProcessingContext, 

119) -> None: 

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

121 if not sysusers_confs: 

122 return 

123 

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

125 

126 snippet = textwrap.dedent( 

127 f"""\ 

128 systemd-sysusers ${ DPKG_ROOT:+--root="$DPKG_ROOT"} --create {sysusers_escaped} || true 

129 """ 

130 ) 

131 

132 ctrl.substvars.add_dependency( 

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

134 ) 

135 ctrl.maintscript.on_configure(snippet) 

136 

137 

138def detect_commands( 

139 fs_root: VirtualPath, 

140 ctrl: BinaryCtrlAccessor, 

141 context: PackageProcessingContext, 

142) -> None: 

143 if context.binary_package.is_udeb: 

144 return 

145 for path_name in SYSTEM_DEFAULT_PATH_DIRS: 

146 path_dir = fs_root.lookup(path_name) 

147 if path_dir is None or not path_dir.is_dir: 

148 continue 

149 for child in path_dir.iterdir: 

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

151 continue 

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

153 

154 

155def detect_icons( 

156 fs_root: VirtualPath, 

157 ctrl: BinaryCtrlAccessor, 

158 _unused: PackageProcessingContext, 

159) -> None: 

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

161 if not icons_root_dir: 

162 return 

163 icon_dirs = [] 

164 for subdir in icons_root_dir.iterdir: 

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

166 # dh_icons skips this for some reason. 

167 continue 

168 for p in subdir.all_paths(): 

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

170 icon_dirs.append(subdir.absolute) 

171 break 

172 if not icon_dirs: 

173 return 

174 

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

176 

177 postinst_snippet = textwrap.dedent( 

178 f"""\ 

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

180 update-icon-caches {icon_dir_list_escaped} 

181 fi 

182 """ 

183 ) 

184 

185 postrm_snippet = textwrap.dedent( 

186 f"""\ 

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

188 update-icon-caches {icon_dir_list_escaped} 

189 fi 

190 """ 

191 ) 

192 

193 ctrl.maintscript.on_configure(postinst_snippet) 

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

195 

196 

197def detect_gsettings_dependencies( 

198 fs_root: VirtualPath, 

199 ctrl: BinaryCtrlAccessor, 

200 _unused: PackageProcessingContext, 

201) -> None: 

202 gsettings_schema_dir = fs_root.lookup(GSETTINGS_SCHEMA_DIR) 

203 if not gsettings_schema_dir: 

204 return 

205 

206 for path in gsettings_schema_dir.all_paths(): 

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

208 ctrl.substvars.add_dependency( 

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

210 ) 

211 break 

212 

213 

214def detect_kernel_modules( 

215 fs_root: VirtualPath, 

216 ctrl: BinaryCtrlAccessor, 

217 _unused: PackageProcessingContext, 

218) -> None: 

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

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

221 

222 if not module_root_dir: 

223 continue 

224 

225 module_version_dirs = [] 

226 

227 for module_version_dir in module_root_dir.iterdir: 

228 if not module_version_dir.is_dir: 

229 continue 

230 

231 for fs_path in module_version_dir.all_paths(): 

232 if fs_path.name.endswith(KERNEL_MODULE_EXTENSIONS): 

233 module_version_dirs.append(module_version_dir.name) 

234 break 

235 

236 for module_version in module_version_dirs: 

237 module_version_escaped = ctrl.maintscript.escape_shell_words(module_version) 

238 postinst_snippet = textwrap.dedent( 

239 f"""\ 

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

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

242 fi 

243 """ 

244 ) 

245 

246 postrm_snippet = textwrap.dedent( 

247 f"""\ 

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

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

250 fi 

251 """ 

252 ) 

253 

254 ctrl.maintscript.on_configure(postinst_snippet) 

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

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

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

258 

259 

260def detect_xfonts( 

261 fs_root: VirtualPath, 

262 ctrl: BinaryCtrlAccessor, 

263 context: PackageProcessingContext, 

264) -> None: 

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

266 if not xfonts_root_dir: 

267 return 

268 

269 cmds = [] 

270 cmds_postinst = [] 

271 cmds_postrm = [] 

272 escape_shell_words = ctrl.maintscript.escape_shell_words 

273 package_name = context.binary_package.name 

274 

275 for xfonts_dir in xfonts_root_dir.iterdir: 

276 xfonts_dirname = xfonts_dir.name 

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

278 continue 

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

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

281 cmds.append( 

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

283 ) 

284 alias_file = fs_root.lookup( 

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

286 ) 

287 if alias_file: 

288 cmds_postinst.append( 

289 escape_shell_words( 

290 "update-fonts-alias", 

291 "--include", 

292 alias_file.absolute, 

293 xfonts_dirname, 

294 ) 

295 ) 

296 cmds_postrm.append( 

297 escape_shell_words( 

298 "update-fonts-alias", 

299 "--exclude", 

300 alias_file.absolute, 

301 xfonts_dirname, 

302 ) 

303 ) 

304 

305 if not cmds: 

306 return 

307 

308 postinst_snippet = textwrap.dedent( 

309 f"""\ 

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

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

312 fi 

313 """ 

314 ) 

315 

316 postrm_snippet = textwrap.dedent( 

317 f"""\ 

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

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

320 fi 

321 """ 

322 ) 

323 

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

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

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

327 

328 

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

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

331 

332 

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

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

335 root_dirs = [] 

336 if usr_lib: 

337 root_dirs.append(usr_lib) 

338 

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

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

341 root_dirs.append(dbg_root) 

342 

343 for root_dir in root_dirs: 

344 python_dirs = ( 

345 path 

346 for path in root_dir.iterdir 

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

348 ) 

349 for python_dir in python_dirs: 

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

351 if not dist_packages: 

352 continue 

353 yield dist_packages 

354 

355 

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

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

358 

359 

360def detect_pycompile_files( 

361 fs_root: VirtualPath, 

362 ctrl: BinaryCtrlAccessor, 

363 context: PackageProcessingContext, 

364) -> None: 

365 package = context.binary_package.name 

366 # TODO: Support configurable list of private dirs 

367 private_search_dirs = [ 

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

369 for d in [ 

370 "./usr/share", 

371 "./usr/share/games", 

372 "./usr/lib", 

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

374 "./usr/lib/games", 

375 ] 

376 ] 

377 private_search_dirs_with_py_files = [ 

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

379 ] 

380 public_search_dirs_has_py_files = any( 

381 p is not None and _has_py_file_in_dir(p) 

382 for p in _public_python_dist_dirs(fs_root) 

383 ) 

384 

385 if not public_search_dirs_has_py_files and not private_search_dirs_with_py_files: 

386 return 

387 

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

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

390 # of configuration down the line. 

391 ctrl.maintscript.unconditionally_in_script( 

392 "prerm", 

393 textwrap.dedent( 

394 f"""\ 

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

396 py3clean -p {package} 

397 else 

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

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

400 fi 

401 """ 

402 ), 

403 ) 

404 if public_search_dirs_has_py_files: 

405 ctrl.maintscript.on_configure( 

406 textwrap.dedent( 

407 f"""\ 

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

409 py3compile -p {package} 

410 fi 

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

412 pypy3compile -p {package} || true 

413 fi 

414 """ 

415 ) 

416 ) 

417 for private_dir in private_search_dirs_with_py_files: 

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

419 ctrl.maintscript.on_configure( 

420 textwrap.dedent( 

421 f"""\ 

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

423 py3compile -p {package} {escaped_dir} 

424 fi 

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

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

427 fi 

428 """ 

429 ) 

430 ) 

431 

432 

433def translate_capabilities( 

434 fs_root: VirtualPath, 

435 ctrl: BinaryCtrlAccessor, 

436 _context: PackageProcessingContext, 

437) -> None: 

438 caps = [] 

439 maintscript = ctrl.maintscript 

440 for p in fs_root.all_paths(): 

441 if not p.is_file: 

442 continue 

443 metadata_ref = p.metadata(DebputyCapability) 

444 capability = metadata_ref.value 

445 if capability is None: 

446 continue 

447 

448 abs_path = maintscript.escape_shell_words(p.absolute) 

449 

450 cap_script = "".join( 

451 [ 

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

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

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

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

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

457 " else\n", 

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

459 # and remove the capabilities. 

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

461 " fi\n", 

462 ] 

463 ).format( 

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

465 "\\+", "+" 

466 ), 

467 DPKG_ROOT_UNQUOTED=DPKG_ROOT_UNQUOTED, 

468 ABS_PATH=abs_path, 

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

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

471 ) 

472 assert cap_script.endswith("\n") 

473 caps.append(cap_script) 

474 

475 if not caps: 

476 return 

477 

478 maintscript.on_configure( 

479 textwrap.dedent( 

480 """\ 

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

482 {SET_CAP_COMMANDS} 

483 unset _TPATH 

484 else 

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

486 fi 

487 """ 

488 ).format( 

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

490 ) 

491 ) 

492 

493 

494def pam_auth_update( 

495 fs_root: VirtualPath, 

496 ctrl: BinaryCtrlAccessor, 

497 _context: PackageProcessingContext, 

498) -> None: 

499 pam_configs = fs_root.lookup("/usr/share/pam-configs") 

500 if not pam_configs: 

501 return 

502 maintscript = ctrl.maintscript 

503 for pam_config in pam_configs.iterdir: 

504 if not pam_config.is_file: 504 ↛ 505line 504 didn't jump to line 505 because the condition on line 504 was never true

505 continue 

506 maintscript.on_configure("pam-auth-update --package\n") 

507 maintscript.on_before_removal( 

508 textwrap.dedent( 

509 f"""\ 

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

511 pam-auth-update --package --remove {maintscript.escape_shell_words(pam_config.name)} 

512 fi 

513 """ 

514 ) 

515 ) 

516 

517 

518def auto_depends_arch_any_solink( 

519 fs_foot: VirtualPath, 

520 ctrl: BinaryCtrlAccessor, 

521 context: PackageProcessingContext, 

522) -> None: 

523 package = context.binary_package 

524 if package.is_arch_all: 

525 return 

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

527 if not libbasedir: 

528 return 

529 libmadir = libbasedir.get(package.deb_multiarch) 

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

531 libdirs = [libmadir, libbasedir] 

532 else: 

533 libdirs = [libbasedir] 

534 targets = [] 

535 for libdir in libdirs: 

536 for path in libdir.iterdir: 

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

538 continue 

539 target = path.readlink() 

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

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

542 continue 

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

544 

545 roots = list(context.accessible_package_roots()) 

546 if not roots: 

547 return 

548 

549 for libdir_path, target in targets: 

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

551 matches = [] 

552 for opkg, ofs_root in roots: 

553 m = ofs_root.lookup(final_path) 

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

555 continue 

556 matches.append(opkg) 

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

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

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

560 _warn( 

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

562 f" Not generating a dependency." 

563 ) 

564 else: 

565 _warn( 

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

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

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

569 ) 

570 continue 

571 pkg_dep = matches[0] 

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

573 assert pkg_dep.is_arch_all == package.is_arch_all 

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

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

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

577 

578 

579def _built_using_enabled_matches( 

580 context: PackageProcessingContext, 

581) -> Iterator["_BuiltUsingTodo"]: 

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

583 pkg = context.binary_package 

584 host = pkg.resolved_architecture 

585 table = context.dpkg_arch_query_table 

586 profiles = context.deb_options_and_profiles.deb_build_profiles 

587 condition_context = context.condition_context(pkg) 

588 for cls, field in ( 

589 (BuiltUsing, "Built-Using"), 

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

591 ): 

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

593 for is_first, relation in deps: 

594 name = relation["name"] 

595 if not holds_on_arch(relation, host, table): 

596 _debug_log( 

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

598 ) 

599 elif not holds_with_profiles(relation, profiles): 

600 _debug_log( 

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

602 ) 

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

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

605 else: 

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

607 

608 

609class _BuiltUsingTodo(typing.NamedTuple): 

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

611 

612 field: str 

613 dep: str 

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

615 attribute_path: AttributePath 

616 

617 

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

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

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

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

622 yield package_name, source_and_version 

623 

624 

625def _sources_for( 

626 deps: Iterable[str], 

627) -> Mapping[str, str]: 

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

629 relation, excluding unknown or not installed packages. 

630 

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

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

633 'dpkg (= ...)' 

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

635 'dpkg (= ...)' 

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

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

638 >>> "dummy" in r 

639 False 

640 """ 

641 cp = subprocess.run( 

642 args=( 

643 "dpkg-query", 

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

645 *deps, 

646 ), 

647 capture_output=True, 

648 check=False, 

649 text=True, 

650 ) 

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

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

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

654 # For the example above, stdout is: 

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

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

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

658 # ^: the package is (i)nstalled 

659 return dict(_split_dpkg_output(cp.stdout)) 

660 

661 

662def detect_built_using( 

663 _fs_root: VirtualPath, 

664 ctrl: BinaryCtrlAccessor, 

665 context: PackageProcessingContext, 

666) -> None: 

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

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

669 spawn per binary package and the process actually happens. 

670 """ 

671 

672 all_todos = tuple(_built_using_enabled_matches(context)) 

673 if all_todos: 

674 _built_using_process_matches(ctrl, all_todos) 

675 

676 

677def _built_using_process_matches( 

678 ctrl: BinaryCtrlAccessor, 

679 all_todos: Iterable[_BuiltUsingTodo], 

680) -> None: 

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

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

683 already_warned = set() 

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

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

686 ctrl.substvars.add_dependency( 

687 f"debputy:{field}", 

688 relevant_sources[dep], 

689 ) 

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

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

692 elif first_option and dep not in already_warned: 

693 _warn( 

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

695 ) 

696 already_warned.add(dep) 

697 else: 

698 _debug_log( 

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

700 )