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
« 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
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
30DPKG_ROOT = '"${DPKG_ROOT}"'
31DPKG_ROOT_UNQUOTED = "${DPKG_ROOT}"
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)
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
57 ctrl.dpkg_trigger("activate-noawait", "update-initramfs")
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
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
92 tmpfiles_escaped = ctrl.maintscript.escape_shell_words(*tmpfiles_confs)
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 """)
100 ctrl.maintscript.on_configure(snippet)
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
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
122 sysusers_escaped = ctrl.maintscript.escape_shell_words(*sysusers_confs)
124 snippet = textwrap.dedent(f"""\
125 systemd-sysusers ${ DPKG_ROOT:+--root="$DPKG_ROOT"} {sysusers_escaped}
126 """)
128 ctrl.substvars.add_dependency(
129 "misc:Depends", "systemd | systemd-standalone-sysusers | systemd-sysusers"
130 )
131 ctrl.maintscript.on_configure(snippet)
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)
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
171 icon_dir_list_escaped = ctrl.maintscript.escape_shell_words(*icon_dirs)
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 """)
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 """)
185 ctrl.maintscript.on_configure(postinst_snippet)
186 ctrl.maintscript.unconditionally_in_script("postrm", postrm_snippet)
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
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
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")
214 if not module_root_dir:
215 continue
217 module_version_dirs = []
219 for module_version_dir in module_root_dir.iterdir():
220 if not module_version_dir.is_dir:
221 continue
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
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 """)
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 """)
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)
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
257 cmds = []
258 cmds_postinst = []
259 cmds_postrm = []
260 escape_shell_words = ctrl.maintscript.escape_shell_words
261 package_name = context.binary_package.name
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 )
293 if not cmds:
294 return
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 """)
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 """)
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")
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+)?")
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)
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)
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
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())
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 )
369 if not public_search_dirs_has_py_files and not private_search_dirs_with_py_files:
370 return
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 """))
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
422 abs_path = maintscript.escape_shell_words(p.absolute)
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)
449 if not caps:
450 return
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 )
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 )
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))
517 roots = list(context.accessible_package_roots())
518 if not roots:
519 return
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)
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)
581class _BuiltUsingTodo(typing.NamedTuple):
582 """Data transmitted between the two passes of detect_built_using."""
584 field: str
585 dep: str
586 first_option: bool # This relation was the first in its alternative.
587 attribute_path: AttributePath
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
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.
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))
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 """
644 all_todos = tuple(_built_using_enabled_matches(context))
645 if all_todos:
646 _built_using_process_matches(ctrl, all_todos)
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 )