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

