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

290 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-04-19 20:37 +0000

1import itertools 

2import os 

3import re 

4import subprocess 

5import textwrap 

6import typing 

7from collections.abc import Iterable, Iterator, Mapping 

8 

9from debian.deb822 import PkgRelation 

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(f"""\ 

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

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

97 fi 

98 """) 

99 

100 ctrl.maintscript.on_configure(snippet) 

101 

102 

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

104 sysusers_dir = fs_root.lookup(SYSTEMD_SYSUSERS_DIR) 

105 if not sysusers_dir: 

106 return 

107 for child in sysusers_dir.iterdir(): 

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

109 continue 

110 yield child 

111 

112 

113def detect_systemd_sysusers( 

114 fs_root: VirtualPath, 

115 ctrl: BinaryCtrlAccessor, 

116 _unused: PackageProcessingContext, 

117) -> None: 

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

119 if not sysusers_confs: 

120 return 

121 

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

123 

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

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

126 """) 

127 

128 ctrl.substvars.add_dependency( 

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

130 ) 

131 ctrl.maintscript.on_configure(snippet) 

132 

133 

134def detect_commands( 

135 fs_root: VirtualPath, 

136 ctrl: BinaryCtrlAccessor, 

137 context: PackageProcessingContext, 

138) -> None: 

139 if context.binary_package.is_udeb: 

140 return 

141 for path_name in SYSTEM_DEFAULT_PATH_DIRS: 

142 path_dir = fs_root.lookup(path_name) 

143 if path_dir is None or not path_dir.is_dir: 

144 continue 

145 for child in path_dir.iterdir(): 

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

147 continue 

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

149 

150 

151def detect_icons( 

152 fs_root: VirtualPath, 

153 ctrl: BinaryCtrlAccessor, 

154 _unused: PackageProcessingContext, 

155) -> None: 

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

157 if not icons_root_dir: 

158 return 

159 icon_dirs = [] 

160 for subdir in icons_root_dir.iterdir(): 

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

162 # dh_icons skips this for some reason. 

163 continue 

164 for p in subdir.all_paths(): 

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

166 icon_dirs.append(subdir.absolute) 

167 break 

168 if not icon_dirs: 

169 return 

170 

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

172 

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

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

175 update-icon-caches {icon_dir_list_escaped} 

176 fi 

177 """) 

178 

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

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

181 update-icon-caches {icon_dir_list_escaped} 

182 fi 

183 """) 

184 

185 ctrl.maintscript.on_configure(postinst_snippet) 

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

187 

188 

189def detect_gsettings_dependencies( 

190 fs_root: VirtualPath, 

191 ctrl: BinaryCtrlAccessor, 

192 _unused: PackageProcessingContext, 

193) -> None: 

194 gsettings_schema_dir = fs_root.lookup(GSETTINGS_SCHEMA_DIR) 

195 if not gsettings_schema_dir: 

196 return 

197 

198 for path in gsettings_schema_dir.all_paths(): 

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

200 ctrl.substvars.add_dependency( 

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

202 ) 

203 break 

204 

205 

206def detect_kernel_modules( 

207 fs_root: VirtualPath, 

208 ctrl: BinaryCtrlAccessor, 

209 _unused: PackageProcessingContext, 

210) -> None: 

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

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

213 

214 if not module_root_dir: 

215 continue 

216 

217 module_version_dirs = [] 

218 

219 for module_version_dir in module_root_dir.iterdir(): 

220 if not module_version_dir.is_dir: 

221 continue 

222 

223 for fs_path in module_version_dir.all_paths(): 

224 if fs_path.name.endswith(KERNEL_MODULE_EXTENSIONS): 

225 module_version_dirs.append(module_version_dir.name) 

226 break 

227 

228 for module_version in module_version_dirs: 

229 module_version_escaped = ctrl.maintscript.escape_shell_words(module_version) 

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

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

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

233 fi 

234 """) 

235 

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

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

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

239 fi 

240 """) 

241 

242 ctrl.maintscript.on_configure(postinst_snippet) 

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

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

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

246 

247 

248def detect_xfonts( 

249 fs_root: VirtualPath, 

250 ctrl: BinaryCtrlAccessor, 

251 context: PackageProcessingContext, 

252) -> None: 

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

254 if not xfonts_root_dir: 

255 return 

256 

257 cmds = [] 

258 cmds_postinst = [] 

259 cmds_postrm = [] 

260 escape_shell_words = ctrl.maintscript.escape_shell_words 

261 package_name = context.binary_package.name 

262 

263 for xfonts_dir in xfonts_root_dir.iterdir(): 

264 xfonts_dirname = xfonts_dir.name 

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

266 continue 

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

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

269 cmds.append( 

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

271 ) 

272 alias_file = fs_root.lookup( 

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

274 ) 

275 if alias_file: 

276 cmds_postinst.append( 

277 escape_shell_words( 

278 "update-fonts-alias", 

279 "--include", 

280 alias_file.absolute, 

281 xfonts_dirname, 

282 ) 

283 ) 

284 cmds_postrm.append( 

285 escape_shell_words( 

286 "update-fonts-alias", 

287 "--exclude", 

288 alias_file.absolute, 

289 xfonts_dirname, 

290 ) 

291 ) 

292 

293 if not cmds: 

294 return 

295 

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

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

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

299 fi 

300 """) 

301 

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

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

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

305 fi 

306 """) 

307 

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

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

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

311 

312 

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

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

315 

316 

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

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

319 root_dirs = [] 

320 if usr_lib: 

321 root_dirs.append(usr_lib) 

322 

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

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

325 root_dirs.append(dbg_root) 

326 

327 for root_dir in root_dirs: 

328 python_dirs = ( 

329 path 

330 for path in root_dir.iterdir() 

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

332 ) 

333 for python_dir in python_dirs: 

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

335 if not dist_packages: 

336 continue 

337 yield dist_packages 

338 

339 

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

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

342 

343 

344def detect_pycompile_files( 

345 fs_root: VirtualPath, 

346 ctrl: BinaryCtrlAccessor, 

347 context: PackageProcessingContext, 

348) -> None: 

349 package = context.binary_package.name 

350 # TODO: Support configurable list of private dirs 

351 private_search_dirs = [ 

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

353 for d in [ 

354 "./usr/share", 

355 "./usr/share/games", 

356 "./usr/lib", 

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

358 "./usr/lib/games", 

359 ] 

360 ] 

361 private_search_dirs_with_py_files = [ 

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

363 ] 

364 public_search_dirs_has_py_files = any( 

365 p is not None and _has_py_file_in_dir(p) 

366 for p in _public_python_dist_dirs(fs_root) 

367 ) 

368 

369 if not public_search_dirs_has_py_files and not private_search_dirs_with_py_files: 

370 return 

371 

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

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

374 # of configuration down the line. 

375 ctrl.maintscript.unconditionally_in_script( 

376 "prerm", 

377 textwrap.dedent(f"""\ 

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

379 py3clean -p {package} 

380 else 

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

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

383 fi 

384 """), 

385 ) 

386 if public_search_dirs_has_py_files: 

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

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

389 py3compile -p {package} 

390 fi 

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

392 pypy3compile -p {package} || true 

393 fi 

394 """)) 

395 for private_dir in private_search_dirs_with_py_files: 

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

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

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

399 py3compile -p {package} {escaped_dir} 

400 fi 

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

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

403 fi 

404 """)) 

405 

406 

407def translate_capabilities( 

408 fs_root: VirtualPath, 

409 ctrl: BinaryCtrlAccessor, 

410 _context: PackageProcessingContext, 

411) -> None: 

412 caps = [] 

413 maintscript = ctrl.maintscript 

414 for p in fs_root.all_paths(): 

415 if not p.is_file: 

416 continue 

417 metadata_ref = p.metadata(DebputyCapability) 

418 capability = metadata_ref.value 

419 if capability is None: 

420 continue 

421 

422 abs_path = maintscript.escape_shell_words(p.absolute) 

423 

424 cap_script = "".join( 

425 [ 

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

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

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

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

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

431 " else\n", 

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

433 # and remove the capabilities. 

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

435 " fi\n", 

436 ] 

437 ).format( 

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

439 "\\+", "+" 

440 ), 

441 DPKG_ROOT_UNQUOTED=DPKG_ROOT_UNQUOTED, 

442 ABS_PATH=abs_path, 

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

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

445 ) 

446 assert cap_script.endswith("\n") 

447 caps.append(cap_script) 

448 

449 if not caps: 

450 return 

451 

452 maintscript.on_configure( 

453 textwrap.dedent("""\ 

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

455 {SET_CAP_COMMANDS} 

456 unset _TPATH 

457 else 

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

459 fi 

460 """).format( 

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

462 ) 

463 ) 

464 

465 

466def pam_auth_update( 

467 fs_root: VirtualPath, 

468 ctrl: BinaryCtrlAccessor, 

469 _context: PackageProcessingContext, 

470) -> None: 

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

472 if not pam_configs_dir: 

473 return 

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

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

476 return 

477 maintscript = ctrl.maintscript 

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

479 maintscript.on_before_removal( 

480 textwrap.dedent( 

481 f"""\ 

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

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

484 fi 

485 """, 

486 ) 

487 ) 

488 

489 

490def auto_depends_arch_any_solink( 

491 fs_foot: VirtualPath, 

492 ctrl: BinaryCtrlAccessor, 

493 context: PackageProcessingContext, 

494) -> None: 

495 package = context.binary_package 

496 if package.is_arch_all: 

497 return 

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

499 if not libbasedir: 

500 return 

501 libmadir = libbasedir.get(package.deb_multiarch) 

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

503 libdirs = [libmadir, libbasedir] 

504 else: 

505 libdirs = [libbasedir] 

506 targets = [] 

507 for libdir in libdirs: 

508 for path in libdir.iterdir(): 

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

510 continue 

511 target = path.readlink() 

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

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

514 continue 

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

516 

517 roots = list(context.accessible_package_roots()) 

518 if not roots: 

519 return 

520 

521 for libdir_path, target in targets: 

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

523 matches = [] 

524 for opkg, ofs_root in roots: 

525 m = ofs_root.lookup(final_path) 

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

527 continue 

528 matches.append(opkg) 

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

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

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

532 _warn( 

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

534 f" Not generating a dependency." 

535 ) 

536 else: 

537 _warn( 

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

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

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

541 ) 

542 continue 

543 pkg_dep = matches[0] 

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

545 assert pkg_dep.is_arch_all == package.is_arch_all 

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

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

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

549 

550 

551def _built_using_enabled_matches( 

552 context: PackageProcessingContext, 

553) -> Iterator["_BuiltUsingTodo"]: 

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

555 pkg = context.binary_package 

556 host = pkg.resolved_architecture 

557 table = context.dpkg_arch_query_table 

558 profiles = context.deb_options_and_profiles.deb_build_profiles 

559 condition_context = context.condition_context(pkg) 

560 for cls, field in ( 

561 (BuiltUsing, "Built-Using"), 

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

563 ): 

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

565 for is_first, relation in deps: 

566 name = relation["name"] 

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

568 _debug_log( 

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

570 ) 

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

572 _debug_log( 

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

574 ) 

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

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

577 else: 

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

579 

580 

581class _BuiltUsingTodo(typing.NamedTuple): 

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

583 

584 field: str 

585 dep: str 

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

587 attribute_path: AttributePath 

588 

589 

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

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

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

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

594 yield package_name, source_and_version 

595 

596 

597def _sources_for( 

598 deps: Iterable[str], 

599) -> Mapping[str, str]: 

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

601 relation, excluding unknown or not installed packages. 

602 

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

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

605 'dpkg (= ...)' 

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

607 'dpkg (= ...)' 

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

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

610 >>> "dummy" in r 

611 False 

612 """ 

613 cp = subprocess.run( 

614 args=( 

615 "dpkg-query", 

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

617 *deps, 

618 ), 

619 capture_output=True, 

620 check=False, 

621 text=True, 

622 ) 

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

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

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

626 # For the example above, stdout is: 

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

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

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

630 # ^: the package is (i)nstalled 

631 return dict(_split_dpkg_output(cp.stdout)) 

632 

633 

634def detect_built_using( 

635 _fs_root: VirtualPath, 

636 ctrl: BinaryCtrlAccessor, 

637 context: PackageProcessingContext, 

638) -> None: 

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

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

641 spawn per binary package and the process actually happens. 

642 """ 

643 

644 all_todos = tuple(_built_using_enabled_matches(context)) 

645 if all_todos: 

646 _built_using_process_matches(ctrl, all_todos) 

647 

648 

649def _built_using_process_matches( 

650 ctrl: BinaryCtrlAccessor, 

651 all_todos: Iterable[_BuiltUsingTodo], 

652) -> None: 

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

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

655 already_warned = set() 

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

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

658 ctrl.substvars.add_dependency( 

659 f"debputy:{field}", 

660 relevant_sources[dep], 

661 ) 

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

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

664 elif first_option and dep not in already_warned: 

665 _warn( 

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

667 ) 

668 already_warned.add(dep) 

669 else: 

670 _debug_log( 

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

672 )