--- Begin Message ---
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("../..")
--- End Message ---