Package: dh-debputy
Version: 0.1.78
Severity: wishlist
Tags: patch

Hello.

The attachment lets debputy generate the (Static-)Built-Using fields
from manifest rules, replacing the dh-builtusing debhelper plugin.
It seems ready for a first review, if you are interested.

'bug1120283.py' should only exist until the patch with the same
contents is applied to the python3-debian package.

I have made the source_package and dpkg_arch_query_table
HighLevelManifest attributes public.  Their value is accessible via
various tricks like manifest.condition_context(pkg).source_package
anyway.

'test_built_using.py' demonstrates various possible use cases. Advices
about the way to merge it into the test suite would be welcome.
>From 3939d179b55a695df2f4d927d7b3344e02387b94 Mon Sep 17 00:00:00 2001
From: Nicolas Boulenguez <[email protected]>
Date: Mon, 24 Nov 2025 11:35:59 +0100
Subject: built-using: initial suggestion


diff --git a/MANIFEST-FORMAT.md b/MANIFEST-FORMAT.md
index d7f18d0e..a697b866 100644
--- a/MANIFEST-FORMAT.md
+++ b/MANIFEST-FORMAT.md
@@ -2523,6 +2523,64 @@ exists and is a directory, it will also be checked for 
"not-installed" paths.
 
 Integration mode availability: dh-sequence-zz-debputy, full
 
+## Built-Using dependency relations (`built-using`)
+
+Generate a `Built-Using` dependency relation on the
+build dependency selected by the `sources-for`, which
+may contain a `*` wildcard matching any number of
+arbitrary characters.
+
+packages:
+  PKG:
+    built-using:
+    - sources-for: foo-*-source   # foo-3.1.0-source
+    - sources-for: foo
+      when:                       # foo is always installed
+        arch-matches: amd64       # but only used on amd64
+    static-built-using:
+    - sources-for: librust-*-dev  # several relations
+
+Either of these conditions prevents the generation:
+* PKG is not part of the current build because of its
+  `Architecture` or `Build-Profiles` fields.
+* The match in `Build-Depends` carries an invalid
+  architecture or build profile restriction.
+* The match in `Build-Depends` is not installed.
+  This should only happen inside alternatives, see below.
+* The manifest item carries an invalid `when:` condition.
+  This may be useful when the match must be installed
+  for unrelated reasons.
+
+Matches are searched in the `Build-Depends` field of
+the source package, and either `Build-Depends-Indep`
+or `Build-Depends-Arch` depending on PKG.
+
+In alternatives like `a|b`, each option may match
+separately.  This is a compromise between
+reproducibility on automatic builders (where the set
+of installed package is constant), and least surprise
+during local builds (where `b` may be installed
+alone).  There seems to be no one-size fits all
+solution when both are installed.
+
+Architecture qualifiers and version restrictions in
+`Build-Depends` are ignored. The only allowed
+co-installations require a common source and version.
+
+List where each element has the following attributes:
+
+Integration mode availability: any integration mode
+
+## Static-Built-Using dependency relations (`static-built-using`)
+
+This rule behaves like `built-using`, except that the
+affected field in the eventual binary package is
+`Static-Built-Using`.
+
+List where each element has the following attributes:
+
+Integration mode availability: any integration mode
+
 
 # Remove paths during clean (`remove-during-clean`)
 
diff --git a/docs/MANIFEST-FORMAT.md.j2 b/docs/MANIFEST-FORMAT.md.j2
index bf2b0d3e..f8017004 100644
--- a/docs/MANIFEST-FORMAT.md.j2
+++ b/docs/MANIFEST-FORMAT.md.j2
@@ -512,6 +512,8 @@ To show concrete examples:
 <<render_pmr('packages.{{PACKAGE}}::binary-version', 2)>>
 <<render_pmr('packages.{{PACKAGE}}::clean-after-removal', 2)>>
 <<render_pmr('packages.{{PACKAGE}}::installation-search-dirs', 2)>>
+<<render_pmr('packages.{{PACKAGE}}::built-using', 2)>>
+<<render_pmr('packages.{{PACKAGE}}::static-built-using', 2)>>
 
 <<render_pmr('::remove-during-clean', 1)>>
 
diff --git a/src/debputy/bug1120283.py b/src/debputy/bug1120283.py
new file mode 100644
index 00000000..ba7c5bce
--- /dev/null
+++ b/src/debputy/bug1120283.py
@@ -0,0 +1,54 @@
+"""Version 2 of patch in bug #1120283 for python-debian.
+
+A new debian.deb822 will hopefully one day replace this file.
+"""
+import collections.abc
+
+from debian.debian_support import DpkgArchTable
+from debian.deb822 import PkgRelation
+
+
+def holds_on_arch(
+    relation: "PkgRelation.ParsedRelation",
+    arch: str,
+    table: DpkgArchTable,
+) -> bool:
+    """Is relation active on the given architecture?
+
+    >>> table = DpkgArchTable.load_arch_table()
+    >>> rel = PkgRelation.parse_relations("foo [armel linux-any]")[0][0]
+    >>> holds_on_arch(rel, "amd64", table)
+    True
+    >>> holds_on_arch(rel, "hurd-i386", table)
+    False
+    """
+    archs = relation["arch"]
+    return (archs is None
+            or table.architecture_is_concerned(
+                arch,
+                tuple(("" if a.enabled else "!") + a.arch for a in archs)))
+
+
+def holds_with_profiles(
+    relation: "PkgRelation.ParsedRelation",
+    profiles: collections.abc.Container[str],
+) -> bool:
+    """Is relation active under the given profiles?
+
+    >>> relation = PkgRelation.parse_relations("foo <a !b> <c>")[0][0]
+    >>> holds_with_profiles(relation, ("a", "b"))
+    False
+    >>> holds_with_profiles(relation, ("c", ))
+    True
+    """
+    restrictions = relation["restrictions"]
+    return (restrictions is None
+            or any(all(term.enabled == (term.profile in profiles)
+                       for term in restriction_list)
+                   for restriction_list in restrictions))
+
+
+# (cd src && PYTHONPATH=. python3 -m debputy.bug1120283 -v)
+if __name__ == "__main__":
+    import doctest
+    doctest.testmod()
diff --git a/src/debputy/built_using.py b/src/debputy/built_using.py
new file mode 100644
index 00000000..b9c8261c
--- /dev/null
+++ b/src/debputy/built_using.py
@@ -0,0 +1,172 @@
+"""Generate `Built-Using: foo-src (= 1.2)` in the control file for `pkg`
+from a `packages.pkg.built-using.source-for: foo` stanza in the manifest.
+"""
+import collections.abc
+import re
+import subprocess
+import typing
+
+import debian.deb822
+
+import debputy.bug1120283
+import debputy.highlevel_manifest
+import debputy.packages
+import debputy.plugin.api.impl_types
+import debputy.plugins.debputy.binary_package_rules
+import debputy.util
+
+_VALID_GLOB = re.compile("[a-z*][a-z0-9.+*-]*")
+_GLOB_TO_RE = str.maketrans({".": "[.]", "+": "[+]", "*": ".*"})
+_Field = typing.Literal["Built-Using", "Static-Built-Using"]
+
+
+def _d(pkg: debputy.packages.BinaryPackage, field: _Field, msg: str) -> None:
+    # pylint: disable=protected-access
+    debputy.util._debug_log(f"packages.{pkg.name}.{field.lower()}: {msg}")
+
+
+def _e(pkg: debputy.packages.BinaryPackage, field: _Field, msg: str
+       ) -> typing.NoReturn:
+    # pylint: disable=protected-access
+    debputy.util._error(f"packages.{pkg.name}.{field.lower()}: {msg}")
+
+
+def _w(pkg: debputy.packages.BinaryPackage, field: _Field, msg: str) -> None:
+    # pylint: disable=protected-access
+    debputy.util._warn(f"packages.{pkg.name}.{field.lower()}: {msg}")
+
+
+def _sources_for(
+    deps: typing.Iterable[str],
+) -> collections.abc.Mapping[str, str]:
+    """Map installed packages among deps to a "source (= version)"
+    relation, excluding unknown or not installed packages.
+
+    >>> r = _sources_for(("dpkg", "dpkg", "dpkg-dev", "gcc", "dummy"))
+    >>> r["dpkg"]  # doctest: +ELLIPSIS
+    'dpkg (= ...)'
+    >>> r["dpkg-dev"]  # doctest: +ELLIPSIS
+    'dpkg (= ...)'
+    >>> r["gcc"]  # doctest: +ELLIPSIS
+    'gcc-defaults (= ...)'
+    >>> "dummy" in r
+    False
+    """
+    cp = subprocess.run(
+        args=(
+            "dpkg-query", "-Wf${db:Status-Abbrev}${Package}:"
+            "${source:Package} (= ${source:Version})\n", *deps,
+        ),
+        capture_output=True,
+        check=False,
+        text=True,
+    )
+    # 0: OK    1: unknown package    2: other
+    assert cp.returncode in (0, 1)
+    # For the example above, stdout is:
+    # "ii dpkg:dpkg (= 1.22.21)\n"
+    # "ii dpkg-dev:dpkg (= 1.22.21)\n"
+    # "ii gcc:gcc-defaults (= 1.220)\n"
+    #   ^: the package is (i)nstalled
+    # The regular expression is used once in the program lifetime,
+    # so precompiling it has no benefit.
+    return dict(m.groups() for m in re.finditer(".i.([^:].+):(.+)", cp.stdout))
+
+
+class _Todo(typing.NamedTuple):
+    """For efficiency, a first pass constructs a todo list, then at
+    most one dpkg-query subprocess is spawn."""
+    pkg: debputy.packages.BinaryPackage
+    field: _Field
+    dep: str
+    first_option: bool  # This relation was the first in its alternative.
+
+
+def _enabled_match(
+    manifest: debputy.highlevel_manifest.HighLevelManifest,
+    pkg: debputy.packages.BinaryPackage,
+    field: _Field,
+    bu: debputy.plugins.debputy.binary_package_rules.BuiltUsingParsedFormat,
+    relation: "debian.deb822.PkgRelation.ParsedRelation",
+) -> bool:
+    # A logical conjunction of restrictions, with logging.
+    name = relation["name"]
+    host = manifest.dpkg_architecture_variables.current_host_arch
+    table = manifest.dpkg_arch_query_table
+    if not debputy.bug1120283.holds_on_arch(relation, host, table):
+        _d(pkg, field, f"{name} is disabled by host architecture {host}.")
+        return False
+    profiles = manifest.deb_options_and_profiles.deb_build_profiles
+    if not debputy.bug1120283.holds_with_profiles(relation, profiles):
+        _d(pkg, field, f"{name} is disabled by profiles {' '.join(profiles)}.")
+        return False
+    if "when" in bu \
+       and not bu["when"].evaluate(manifest.condition_context(pkg)):
+        _d(pkg, field, f"{name} is disabled by its manifest condition.")
+        return False
+    return True
+
+
+def _pattern(
+    manifest: debputy.highlevel_manifest.HighLevelManifest,
+    pkg: debputy.packages.BinaryPackage,
+    bu: debputy.plugins.debputy.binary_package_rules.BuiltUsingParsedFormat,
+    field: _Field,
+) -> typing.Iterator[_Todo]:
+    # Process an item in a (static-)built-using list.
+    glob = bu["sources_for"]
+    if _VALID_GLOB.fullmatch(glob) is None:
+        _e(pkg, field, f"invalid characters in '{glob}'.")
+    regex = re.compile(glob.translate(_GLOB_TO_RE))
+    if pkg.is_arch_all:
+        other = "Build-Depends-Indep"
+    else:
+        other = "Build-Depends-Arch"
+    at_least_a_match = False
+    # pylint: disable=too-many-nested-blocks
+    for bd_field in ("Build-Depends", other):
+        raw = manifest.source_package.fields.get(bd_field)
+        if raw is not None:
+            for options in debian.deb822.PkgRelation.parse_relations(raw):
+                for idx, relation in enumerate(options):
+                    name = relation["name"]
+                    if regex.fullmatch(name):
+                        at_least_a_match = True
+                        if _enabled_match(manifest, pkg, field, bu, relation):
+                            yield _Todo(pkg, field, name, not idx)
+    if not at_least_a_match:
+        _w(pkg, field, f"{glob} matches in neither Build-Depends nor {other}.")
+
+
+def add_relations(
+    manifest: debputy.highlevel_manifest.HighLevelManifest,
+    package_data_table: debputy.plugin.api.impl_types.PackageDataTable,
+) -> None:
+    """The documentation is in
+    plugins.debputy.binary_package_rules.register_binary_package_rules."""
+    todo: list[_Todo] = []
+    for pkg in manifest.active_packages:
+        for bu in manifest.package_state_for(pkg.name).built_using:
+            todo.extend(_pattern(manifest, pkg, bu, "Built-Using"))
+        for bu in manifest.package_state_for(pkg.name).static_built_using:
+            todo.extend(_pattern(manifest, pkg, bu, "Static-Built-Using"))
+    if todo:
+        # Run the costly dpkg-query subprocess.
+        relations = _sources_for(t.dep for t in todo)
+        for t in todo:
+            if t.dep in relations:
+                package_data_table[t.pkg.name].substvars.add_dependency(
+                    f"debputy:{t.field}", relations[t.dep],
+                )
+            # With Build-Depends: a | b, in usual configurations,
+            # a is installed but b might not be.
+            elif t.first_option:
+                _w(t.pkg, t.field, f"{t.dep} is not installed")
+            else:
+                _d(t.pkg, t.field, f"{t.dep} is not installed")
+
+
+# (cd src && PYTHONPATH=. python3 -m debputy.built_using -v)
+if __name__ == "__main__":
+    import doctest
+    doctest.testmod()
diff --git a/src/debputy/commands/debputy_cmd/__main__.py 
b/src/debputy/commands/debputy_cmd/__main__.py
index c67a38ff..a0899b52 100644
--- a/src/debputy/commands/debputy_cmd/__main__.py
+++ b/src/debputy/commands/debputy_cmd/__main__.py
@@ -22,6 +22,7 @@ from typing import (
 )
 from collections.abc import Sequence
 
+import debputy.built_using
 from debputy import DEBPUTY_ROOT_DIR, DEBPUTY_PLUGIN_ROOT_DIR
 from debputy.analysis import REFERENCE_DATA_TABLE
 from debputy.build_support import perform_clean, perform_builds
@@ -930,6 +931,7 @@ def assemble(
                 )
             run_package_processors(manifest, package_metadata_context, fs_root)
 
+        debputy.built_using.add_relations(manifest, package_data_table)
         cross_package_control_files(package_data_table, manifest)
     for binary_data in package_data_table:
         if not binary_data.binary_package.should_be_acted_on:
diff --git a/src/debputy/highlevel_manifest.py 
b/src/debputy/highlevel_manifest.py
index c5255626..6da58ec7 100644
--- a/src/debputy/highlevel_manifest.py
+++ b/src/debputy/highlevel_manifest.py
@@ -72,7 +72,10 @@ from .plugin.api.spec import (
     INTEGRATION_MODE_DH_DEBPUTY_RRR,
     INTEGRATION_MODE_FULL,
 )
-from debputy.plugins.debputy.binary_package_rules import ServiceRule
+from debputy.plugins.debputy.binary_package_rules import (
+    BuiltUsingParsedFormat,
+    ServiceRule,
+)
 from debputy.plugins.debputy.build_system_rules import BuildRule
 from .plugin.plugin_state import run_in_context_of_plugin
 from .substitution import Substitution
@@ -179,6 +182,8 @@ class PackageTransformationDefinition:
     )
     install_rules: list[InstallRule] = field(default_factory=list)
     requested_service_rules: list[ServiceRule] = field(default_factory=list)
+    built_using: Sequence[BuiltUsingParsedFormat] = 
field(default_factory=tuple)
+    static_built_using: Sequence[BuiltUsingParsedFormat] = 
field(default_factory=tuple)
 
 
 def _path_to_tar_member(
@@ -1206,12 +1211,12 @@ class HighLevelManifest:
             remove_during_clean_rules
         )
         self._install_rules = install_rules
-        self._source_package = source_package
+        self.source_package = source_package
         self._binary_packages = binary_packages
         self.substitution = substitution
         self.package_transformations = package_transformations
         self._dpkg_architecture_variables = dpkg_architecture_variables
-        self._dpkg_arch_query_table = dpkg_arch_query_table
+        self.dpkg_arch_query_table = dpkg_arch_query_table
         self._build_env = build_env
         self._used_for: set[str] = set()
         self.build_environments = build_environments
@@ -1223,7 +1228,7 @@ class HighLevelManifest:
             substitution=self.substitution,
             deb_options_and_profiles=self._build_env,
             dpkg_architecture_variables=self._dpkg_architecture_variables,
-            dpkg_arch_query_table=self._dpkg_arch_query_table,
+            dpkg_arch_query_table=self.dpkg_arch_query_table,
         )
 
     def source_version(self, include_binnmu_version: bool = True) -> str:
@@ -1574,7 +1579,7 @@ class HighLevelManifest:
                 )
 
             package_data_dict[package] = BinaryPackageData(
-                self._source_package,
+                self.source_package,
                 dctrl_bin,
                 build_system_pkg_staging_dir,
                 fs_root,
@@ -1622,7 +1627,7 @@ class HighLevelManifest:
             substitution=package_transformation.substitution,
             deb_options_and_profiles=self._build_env,
             dpkg_architecture_variables=self._dpkg_architecture_variables,
-            dpkg_arch_query_table=self._dpkg_arch_query_table,
+            dpkg_arch_query_table=self.dpkg_arch_query_table,
         )
         norm_rules = list(
             builtin_mode_normalization_rules(
diff --git a/src/debputy/highlevel_manifest_parser.py 
b/src/debputy/highlevel_manifest_parser.py
index 73280956..2a9350c8 100644
--- a/src/debputy/highlevel_manifest_parser.py
+++ b/src/debputy/highlevel_manifest_parser.py
@@ -609,6 +609,9 @@ class YAMLManifestParser(HighLevelManifestParser):
                     )
                 if service_rules:
                     package_state.requested_service_rules.extend(service_rules)
+                package_state.built_using = parsed.get("built-using", ())
+                package_state.static_built_using = parsed.get(
+                    "static-built-using", ())
         self._build_rules = parsed_data.get("builds")
 
         return self.build_manifest()
diff --git a/src/debputy/plugins/debputy/binary_package_rules.py 
b/src/debputy/plugins/debputy/binary_package_rules.py
index e6cd1169..d9d1d2fe 100644
--- a/src/debputy/plugins/debputy/binary_package_rules.py
+++ b/src/debputy/plugins/debputy/binary_package_rules.py
@@ -1,5 +1,6 @@
 import dataclasses
 import os
+import textwrap
 from typing import (
     Any,
     List,
@@ -20,7 +21,10 @@ from debputy._manifest_constants import (
     MK_SERVICES,
 )
 from debputy.maintscript_snippet import DpkgMaintscriptHelperCommand, 
MaintscriptSnippet
-from debputy.manifest_parser.base_types import FileSystemExactMatchRule
+from debputy.manifest_parser.base_types import (
+    DebputyParsedContentStandardConditional,
+    FileSystemExactMatchRule,
+)
 from debputy.manifest_parser.declarative_parser import ParserGenerator
 from debputy.manifest_parser.exceptions import ManifestParseException
 from debputy.manifest_parser.parse_hints import DebputyParseHint
@@ -132,6 +136,78 @@ def register_binary_package_rules(api: 
DebputyPluginInitializerProvider) -> None
         ),
     )
 
+    api.pluggable_manifest_rule(
+        rule_type=OPARSER_PACKAGES,
+        rule_name="built-using",
+        parsed_format=list[BuiltUsingParsedFormat],
+        handler=_unpack_list,
+        inline_reference_documentation=reference_documentation(
+            title="Built-Using dependency relations (`built-using`)",
+            description=textwrap.dedent(
+                f"""\
+                Generate a `Built-Using` dependency relation on the
+                build dependency selected by the `sources-for`, which
+                may contain a `*` wildcard matching any number of
+                arbitrary characters.
+
+                packages:
+                  PKG:
+                    built-using:
+                    - sources-for: foo-*-source   # foo-3.1.0-source
+                    - sources-for: foo
+                      when:                       # foo is always installed
+                        arch-matches: amd64       # but only used on amd64
+                    static-built-using:
+                    - sources-for: librust-*-dev  # several relations
+
+                Either of these conditions prevents the generation:
+                * PKG is not part of the current build because of its
+                  `Architecture` or `Build-Profiles` fields.
+                * The match in `Build-Depends` carries an invalid
+                  architecture or build profile restriction.
+                * The match in `Build-Depends` is not installed.
+                  This should only happen inside alternatives, see below.
+                * The manifest item carries an invalid `when:` condition.
+                  This may be useful when the match must be installed
+                  for unrelated reasons.
+
+                Matches are searched in the `Build-Depends` field of
+                the source package, and either `Build-Depends-Indep`
+                or `Build-Depends-Arch` depending on PKG.
+
+                In alternatives like `a|b`, each option may match
+                separately.  This is a compromise between
+                reproducibility on automatic builders (where the set
+                of installed package is constant), and least surprise
+                during local builds (where `b` may be installed
+                alone).  There seems to be no one-size fits all
+                solution when both are installed.
+
+                Architecture qualifiers and version restrictions in
+                `Build-Depends` are ignored. The only allowed
+                co-installations require a common source and version.
+                """,
+            ),
+        ),
+    )
+
+    api.pluggable_manifest_rule(
+        rule_type=OPARSER_PACKAGES,
+        rule_name="static-built-using",
+        parsed_format=list[BuiltUsingParsedFormat],
+        handler=_unpack_list,
+        inline_reference_documentation=reference_documentation(
+            title="Static-Built-Using dependency relations 
(`static-built-using`)",
+            description=textwrap.dedent(
+                f"""\
+                This rule behaves like `built-using`, except that the
+                affected field in the eventual binary package is
+                `Static-Built-Using`.
+                """,
+            ),
+        ),
+    )
+
 
 class ServiceRuleSourceFormat(TypedDict):
     service: str
@@ -224,6 +300,11 @@ class BinaryVersionParsedFormat(DebputyParsedContent):
     binary_version: str
 
 
+class BuiltUsingParsedFormat(DebputyParsedContentStandardConditional):
+    """Also used for static-built-using."""
+    sources_for: str
+
+
 class ListParsedFormat(DebputyParsedContent):
     elements: list[Any]
 
diff --git a/src/debputy/util.py b/src/debputy/util.py
index 36cf2a47..d3aa8226 100644
--- a/src/debputy/util.py
+++ b/src/debputy/util.py
@@ -478,6 +478,7 @@ def glob_escape(replacement_value: str) -> str:
 
 
 # TODO: This logic should probably be moved to `python-debian`
+# See bug1120283.py.
 def active_profiles_match(
     profiles_raw: str,
     active_build_profiles: set[str] | frozenset[str],
diff --git a/test_built_using.py b/test_built_using.py
new file mode 100644
index 00000000..74ad2b2d
--- /dev/null
+++ b/test_built_using.py
@@ -0,0 +1,371 @@
+"debputy: test generation of the (static-)built-using field."
+
+import os
+import re
+import subprocess
+import typing
+
+ARCH = subprocess.check_output(
+    ("dpkg-architecture", "-qDEB_BUILD_ARCH"),
+    encoding="ascii",
+).rstrip()
+RELATION_RE = re.compile(r"([a-z0-9.+*-]+) \(= [^)]+\)")
+
+
+def create(path: str, contents: str) -> None:
+    "Write a file to disk."
+    with open(path, "w", encoding="ascii") as f:
+        f.write(contents)
+
+
+def slurp(path: str) -> typing.Iterator[str]:
+    "Read a file from disk."
+    with open(path, encoding="ascii") as f:
+        yield from f
+
+
+def cat(path: str) -> None:
+    "Read, indent and output a file."
+    if os.path.exists(path):
+        print(f"-- {path}:")
+        with open(path, encoding="ascii") as f:
+            for line in f:
+                print(f"  {line}", end='')
+    else:
+        print(f"-- {path} does not exist")
+
+
+class BinPkg(typing.NamedTuple):
+    "Fake binary package built by a Test."
+    name: str
+    binary_paragraph: str
+    manifest: str
+    expected_bu: set[str] | None = set()  # None means not built
+    expected_sbu: set[str] | None = set()
+
+
+class Test(typing.NamedTuple):
+    "Fake source package used by a test."
+    name: str
+    source_paragraph: str
+    packages: typing.Sequence[BinPkg]
+
+
+# More or less in sync with dh-builtusing unit-tests.
+tests = (
+    Test(
+        name="01basic",
+        source_paragraph=f"""\
+Build-Depends: debputy (>= 0.1.45~), dpkg-dev (>= 1.22.7~),
+  autotools-dev <dummy>, gcc [!{ARCH}], libc6, make
+Build-Depends-Arch: libdpkg-perl
+Build-Depends-Indep: libbinutils""",
+        packages=(
+            BinPkg(
+                name="foo",
+                binary_paragraph="Architecture: all",
+                manifest="""\
+    built-using:
+    - sources-for: autotools-dev  # disabled by profile restriction
+    - sources-for: gcc            # disabled by architecture restriction
+    - sources-for: 'lib*'         # match in BD and BD-Indep, not BD-Arch
+    static-built-using:           # also check static-built-using
+    - sources-for: make""",
+                expected_bu={"binutils", "glibc"},
+                expected_sbu={"make-dfsg"},
+            ),
+            BinPkg(
+                name="bar",
+                binary_paragraph="Architecture: any",
+                manifest="""\
+    built-using:
+    - sources-for: 'lib*'         # match in BD and BD-Arch, not BD-Indep""",
+                expected_bu={"dpkg", "glibc"},
+            ),
+            BinPkg(
+                name="package-disabled-by-arch",
+                binary_paragraph=f"Architecture: !{ARCH}",
+                manifest="""\
+    built-using:
+    - sources-for: libc6          # package disabled by architecture""",
+                expected_bu=None,
+                expected_sbu=None,
+            ),
+            BinPkg(
+                name="package-disabled-by-profile",
+                binary_paragraph="""\
+Architecture: all
+Build-Profiles: <dummy>""",
+                manifest="""\
+    built-using:
+    - sources-for: make           # package disabled by build profile""",
+                expected_bu=None,
+                expected_sbu=None,
+            ),
+        ),
+    ),
+    Test(
+        name="30or-dependency",
+        source_paragraph="""\
+Build-Depends: debputy (>= 0.1.45~), dpkg-dev (>= 1.22.7~),
+  cpp | dummy1, dummy2 | binutils""",
+        packages=(
+            BinPkg(
+                name="foo",
+                binary_paragraph="Architecture: all",
+                manifest="""\
+    built-using:
+    - sources-for: cpp
+    - sources-for: binutils""",
+                expected_bu={"gcc-defaults", "binutils"},
+            ),
+        ),
+    ),
+    Test(
+        name="40pattern",
+        source_paragraph="""\
+Build-Depends: debputy (>= 0.1.45~), dpkg-dev (>= 1.22.7~), gcc, g++
+Build-Depends-Arch: libbinutils
+Build-Depends-Indep: libc6""",
+        packages=(
+            BinPkg(
+                name="initial",
+                binary_paragraph="Architecture: all",
+                manifest="""\
+    built-using:
+    - sources-for: '*c'""",
+                expected_bu={"gcc-defaults"},
+            ),
+            BinPkg(
+                name="final",
+                binary_paragraph="Architecture: all",
+                manifest="""\
+    built-using:
+    - sources-for: 'gc*'""",
+                expected_bu={"gcc-defaults"},
+            ),
+            BinPkg(
+                name="empty",
+                binary_paragraph="Architecture: all",
+                manifest="""\
+    built-using:
+    - sources-for: 'g*cc'""",
+                expected_bu={"gcc-defaults"},
+            ),
+            BinPkg(
+                name="one-char",
+                binary_paragraph="Architecture: all",
+                manifest="""\
+    built-using:
+    - sources-for: 'g*c'""",
+                expected_bu={"gcc-defaults"},
+            ),
+            BinPkg(
+                name="encoding-star-plus",  # + is common re character
+                binary_paragraph="Architecture: all",
+                manifest="""\
+    built-using:
+    - sources-for: 'g*+'""",
+                expected_bu={"gcc-defaults"},
+            ),
+            BinPkg(
+                name="encoding-plus",
+                binary_paragraph="Architecture: all",
+                manifest="""\
+    built-using:
+    - sources-for: g++            # match despite regex characters""",
+                expected_bu={"gcc-defaults"},
+            ),
+            BinPkg(
+                name="ambiguous-all",
+                binary_paragraph="Architecture: all",
+                manifest="""\
+    built-using:
+    - sources-for: 'lib*'""",
+                expected_bu={"glibc"},
+            ),
+            BinPkg(
+                name="ambiguous-any",
+                binary_paragraph="Architecture: any",
+                manifest="""\
+    built-using:
+    - sources-for: 'lib*'""",
+                expected_bu={"binutils"},
+            ),
+        ),
+    ),
+    Test(
+        name="50same-source",
+        source_paragraph="""\
+Build-Depends: debputy (>= 0.1.45~), dpkg-dev (>= 1.22.7~),
+  autotools-dev, cpp, gcc, libc6, make""",
+        packages=(
+            BinPkg(
+                name="foo",
+                binary_paragraph="Architecture: any",
+                manifest=f"""\
+    built-using:
+    - sources-for: '*-dev'        # multiple matches
+    - sources-for: cpp
+    - sources-for: gcc            # same source than cpp
+    - sources-for: libc6          # architecture manifest condition
+      when:
+        arch-matches: '!{ARCH}'
+    - sources-for: make           # profile manifest condition
+      when:
+        build-profiles-matches: <dummyprofile>""",
+                expected_bu={"autotools-dev", "dpkg", "gcc-defaults"},
+            ),
+        ),
+    ),
+    Test(
+        name="70arch-suffix",
+        source_paragraph=f"""\
+Build-Depends: debputy (>= 0.1.45~), dpkg-dev (>= 1.22.7~),
+  debhelper:all, gcc:{ARCH}, libc6:{ARCH}""",
+        packages=(
+            BinPkg(
+                name="foo",
+                binary_paragraph="Architecture: all",
+                manifest="""\
+    built-using:
+    - sources-for: debhelper      # all
+    - sources-for: gcc            # native
+    - sources-for: libc6          # same""",
+                expected_bu={"debhelper", "gcc-defaults", "glibc"},
+            ),
+        ),
+    ),
+    Test(
+        name="70multiarch",
+        source_paragraph="""\
+Build-Depends: debputy (>= 0.1.45~), dpkg-dev (>= 1.22.7~),
+  gcc, libc6, make""",
+        packages=(
+            BinPkg(
+                name="foo",
+                binary_paragraph="Architecture: all",
+                manifest="""\
+    built-using:
+    - sources-for: gcc            # no
+    - sources-for: libc6          # same
+    - sources-for: dpkg-dev       # foreign
+    - sources-for: make           # allowed""",
+                expected_bu={"gcc-defaults", "glibc", "dpkg", "make-dfsg"},
+            ),
+        ),
+    ),
+)
+
+os.mkdir("test-built-using-tmpdir")
+os.chdir("test-built-using-tmpdir")
+create("format", """\
+3.0 (native)
+""")
+create("changelog", """\
+foo (0) unstable; urgency=medium
+
+  * For testing purposes.  Closes: #0.
+
+ -- Test <testing@nowhere>  Sun, 12 Oct 2025 11:44:43 +0000
+""")
+create("Makefile", """\
+all:
+\ttouch $(files)
+install: all
+\tinstall -Dt$(DESTDIR)/usr/share/foo $(files)
+""")
+create("copyright", """\
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+
+Files: *
+Copyright: 2025 Someone <[email protected]>
+License: GPL-3+
+""")
+for test in tests:
+    print(f"Testing {test.name}")
+    os.makedirs(f"{test.name}/foo/debian/source")
+    os.chdir(f"{test.name}/foo")
+    os.symlink("../../../../format", "debian/source/format")
+    os.symlink("../../../changelog", "debian/changelog")
+    os.symlink("../../../copyright", "debian/copyright")
+    os.symlink("../../Makefile", "Makefile")
+
+    with open("debian/control", "w", encoding="ascii") as control_file:
+        control_file.write(f"""\
+Source: foo
+Section: misc
+Priority: optional
+Maintainer: Test <testing@nowhere>
+Standards-Version: 4.7.2
+Build-Driver: debputy
+{test.source_paragraph}
+""")
+        for p in test.packages:
+            control_file.write(f"""
+Package: {p.name}
+Description: short
+ Long.
+{p.binary_paragraph}
+""")
+
+    with open("debian/debputy.manifest", "w", encoding="ascii") as manifest:
+        manifest.write(f"""\
+manifest-version: '0.1'
+default-build-environment:
+  set:
+    files: {' '.join(p.name for p in test.packages)}
+""")
+        if len(test.packages) != 1:
+            manifest.write("installations:\n")
+            for p in test.packages:
+                manifest.write(f"""\
+- install:
+    source: usr/share/foo/{p.name}
+    into: {p.name}
+""")
+        manifest.write("packages:\n")
+        for p in test.packages:
+            manifest.write(f"  {p.name}:\n{p.manifest}\n")
+
+    subprocess.check_output(
+        args=(
+            "../../../debputy.sh",
+            "internal-command",
+            "dpkg-build-driver-run-task",
+            "binary",
+            "--debug",
+         ),
+        encoding="utf-8",
+        env={"DEBPUTY_DEBUG": "1"},
+    )
+
+    for p in test.packages:
+
+        # This can obviously be improved.
+        control = "debian/.debputy/scratch-dir/materialization-dirs/" \
+            + p.name + "/deb-root/DEBIAN/control"
+
+        built = os.path.exists(control)
+        for expected, field in (
+            (p.expected_bu, "Built-Using"),
+            (p.expected_sbu, "Static-Built-Using"),
+        ):
+            got: set[str] | None = None
+            if built:
+                got = set()
+                r = re.compile(f"^{field}: ")
+                ls = tuple(line for line in slurp(control) if r.match(line))
+                assert len(ls) in (0, 1)
+                if ls:
+                    for relation in ls[0][len(field) + 2:-1].split(", "):
+                        m = RELATION_RE.fullmatch(relation)
+                        assert m is not None
+                        got.add(m.group(1))
+            if expected != got:
+                print(f"FAIL: {p.name}.{field} expected {expected}, got {got}")
+                cat(control)
+                cat("debian/control")
+                cat("debian/debputy.manifest")
+
+    os.chdir("../..")

Reply via email to