Coverage for src/debputy/packages.py: 72%
203 statements
« prev ^ index » next coverage.py v7.8.2, created at 2026-01-16 17:20 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2026-01-16 17:20 +0000
1from typing import Optional, cast, overload
2from collections.abc import Mapping, Iterable
4from debian.debian_support import DpkgArchTable
6from ._deb_options_profiles import DebBuildOptionsAndProfiles
7from .architecture_support import DpkgArchitectureBuildProcessValuesTable
8from .lsp.vendoring._deb822_repro import (
9 parse_deb822_file,
10 Deb822ParagraphElement,
11 Deb822FileElement,
12)
13from .util import PackageTypeSelector, _error, active_profiles_match
15_MANDATORY_BINARY_PACKAGE_FIELD = [
16 "Package",
17 "Architecture",
18]
21class DctrlParser:
23 def __init__(
24 self,
25 selected_packages: set[str] | frozenset[str],
26 excluded_packages: set[str] | frozenset[str],
27 select_arch_all: bool,
28 select_arch_any: bool,
29 dpkg_architecture_variables: None | (
30 DpkgArchitectureBuildProcessValuesTable
31 ) = None,
32 dpkg_arch_query_table: DpkgArchTable | None = None,
33 deb_options_and_profiles: DebBuildOptionsAndProfiles | None = None,
34 ignore_errors: bool = False,
35 ) -> None:
36 if dpkg_architecture_variables is None: 36 ↛ 37line 36 didn't jump to line 37 because the condition on line 36 was never true
37 dpkg_architecture_variables = DpkgArchitectureBuildProcessValuesTable()
38 if dpkg_arch_query_table is None: 38 ↛ 39line 38 didn't jump to line 39 because the condition on line 38 was never true
39 dpkg_arch_query_table = DpkgArchTable.load_arch_table()
40 if deb_options_and_profiles is None: 40 ↛ 41line 40 didn't jump to line 41 because the condition on line 40 was never true
41 deb_options_and_profiles = DebBuildOptionsAndProfiles()
43 # If no selection option is set, then all packages are acted on (except the
44 # excluded ones)
45 if not selected_packages and not select_arch_all and not select_arch_any:
46 select_arch_all = True
47 select_arch_any = True
49 self.selected_packages = selected_packages
50 self.excluded_packages = excluded_packages
51 self.select_arch_all = select_arch_all
52 self.select_arch_any = select_arch_any
53 self.dpkg_architecture_variables = dpkg_architecture_variables
54 self.dpkg_arch_query_table = dpkg_arch_query_table
55 self.deb_options_and_profiles = deb_options_and_profiles
56 self.ignore_errors = ignore_errors
58 @overload
59 def parse_source_debian_control( 59 ↛ exitline 59 didn't return from function 'parse_source_debian_control' because
60 self,
61 debian_control_lines: Iterable[str],
62 ) -> tuple[Deb822FileElement, "SourcePackage", dict[str, "BinaryPackage"]]: ...
64 @overload
65 def parse_source_debian_control( 65 ↛ exitline 65 didn't return from function 'parse_source_debian_control' because
66 self,
67 debian_control_lines: Iterable[str],
68 *,
69 ignore_errors: bool = False,
70 ) -> tuple[
71 Deb822FileElement,
72 Optional["SourcePackage"],
73 dict[str, "BinaryPackage"] | None,
74 ]: ...
76 def parse_source_debian_control(
77 self,
78 debian_control_lines: Iterable[str],
79 *,
80 ignore_errors: bool = False,
81 ) -> tuple[
82 Deb822FileElement | None,
83 Optional["SourcePackage"],
84 dict[str, "BinaryPackage"] | None,
85 ]:
86 deb822_file = parse_deb822_file(
87 debian_control_lines,
88 accept_files_with_error_tokens=ignore_errors,
89 accept_files_with_duplicated_fields=ignore_errors,
90 )
91 dctrl_paragraphs = list(deb822_file)
92 if len(dctrl_paragraphs) < 2: 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true
93 if not ignore_errors:
94 _error(
95 "debian/control must contain at least two stanza (1 Source + 1-N Package stanza)"
96 )
97 source_package = (
98 SourcePackage(dctrl_paragraphs[0]) if dctrl_paragraphs else None
99 )
100 return deb822_file, source_package, None
102 source_package = SourcePackage(dctrl_paragraphs[0])
103 bin_pkgs = []
104 for i, p in enumerate(dctrl_paragraphs[1:], 1):
105 if ignore_errors:
106 if "Package" not in p:
107 continue
108 missing_field = any(f not in p for f in _MANDATORY_BINARY_PACKAGE_FIELD)
109 if missing_field:
110 # In the LSP context, it is problematic if we "add" fields as it ranges and provides invalid
111 # results. However, `debputy` also needs the mandatory fields to be there, so we clone the
112 # stanzas that `debputy` (build) will see to add missing fields.
113 # p = Deb822(p) works but mypy refuses it.
114 copy = p.new_empty_paragraph()
115 for k in p.iter_keys():
116 v = p.get_kvpair_element(k)
117 assert v is not None
118 copy.set_kvpair_element(k, v)
120 for f in _MANDATORY_BINARY_PACKAGE_FIELD:
121 if f not in p:
122 copy[f] = "unknown"
123 p = copy
124 bin_pkgs.append(
125 _create_binary_package(
126 p,
127 self.selected_packages,
128 self.excluded_packages,
129 self.select_arch_all,
130 self.select_arch_any,
131 self.dpkg_architecture_variables,
132 self.dpkg_arch_query_table,
133 self.deb_options_and_profiles,
134 i,
135 )
136 )
137 bin_pkgs_table = {p.name: p for p in bin_pkgs}
139 if not ignore_errors:
140 if not self.selected_packages.issubset(bin_pkgs_table.keys()): 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true
141 unknown = self.selected_packages - bin_pkgs_table.keys()
142 _error(
143 f"The following *selected* packages (-p) are not listed in debian/control: {sorted(unknown)}"
144 )
145 if not self.excluded_packages.issubset(bin_pkgs_table.keys()): 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true
146 unknown = self.selected_packages - bin_pkgs_table.keys()
147 _error(
148 f"The following *excluded* packages (-N) are not listed in debian/control: {sorted(unknown)}"
149 )
151 return deb822_file, source_package, bin_pkgs_table
154def _check_package_sets(
155 provided_packages: set[str],
156 valid_package_names: set[str],
157 option_name: str,
158) -> None:
159 # SonarLint proposes to use `provided_packages > valid_package_names`, which is valid for boolean
160 # logic, but not for set logic. We want to assert that provided_packages is a proper subset
161 # of valid_package_names. The rewrite would cause no errors for {'foo'} > {'bar'} - in set logic,
162 # neither is a superset / subset of the other, but we want an error for this case.
163 #
164 # Bug filed:
165 # https://community.sonarsource.com/t/sonarlint-python-s1940-rule-does-not-seem-to-take-set-logic-into-account/79718
166 if not provided_packages <= valid_package_names:
167 non_existing_packages = sorted(provided_packages - valid_package_names)
168 invalid_package_list = ", ".join(non_existing_packages)
169 msg = (
170 f"Invalid package names passed to {option_name}: {invalid_package_list}: "
171 f'Valid package names are: {", ".join(valid_package_names)}'
172 )
173 _error(msg)
176def _create_binary_package(
177 paragraph: Deb822ParagraphElement | dict[str, str],
178 selected_packages: set[str] | frozenset[str],
179 excluded_packages: set[str] | frozenset[str],
180 select_arch_all: bool,
181 select_arch_any: bool,
182 dpkg_architecture_variables: DpkgArchitectureBuildProcessValuesTable,
183 dpkg_arch_query_table: DpkgArchTable,
184 build_env: DebBuildOptionsAndProfiles,
185 paragraph_index: int,
186) -> "BinaryPackage":
187 try:
188 package_name = paragraph["Package"]
189 except KeyError:
190 _error(f'Missing mandatory field "Package" in stanza number {paragraph_index}')
191 # The raise is there to help PyCharm type-checking (which fails at "NoReturn")
192 raise
194 for mandatory_field in _MANDATORY_BINARY_PACKAGE_FIELD:
195 if mandatory_field not in paragraph: 195 ↛ 196line 195 didn't jump to line 196 because the condition on line 195 was never true
196 _error(
197 f'Missing mandatory field "{mandatory_field}" for binary package {package_name}'
198 f" (stanza number {paragraph_index})"
199 )
201 architecture = paragraph["Architecture"]
203 if paragraph_index < 1: 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true
204 raise ValueError("stanza index must be 1-indexed (1, 2, ...)")
205 is_main_package = paragraph_index == 1
207 if package_name in excluded_packages: 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true
208 should_act_on = False
209 elif package_name in selected_packages: 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true
210 should_act_on = True
211 elif architecture == "all":
212 should_act_on = select_arch_all
213 else:
214 should_act_on = select_arch_any
216 profiles_raw = paragraph.get("Build-Profiles", "").strip()
217 if should_act_on and profiles_raw: 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true
218 try:
219 should_act_on = active_profiles_match(
220 profiles_raw, build_env.deb_build_profiles
221 )
222 except ValueError as e:
223 _error(f"Invalid Build-Profiles field for {package_name}: {e.args[0]}")
225 return BinaryPackage(
226 paragraph,
227 dpkg_architecture_variables,
228 dpkg_arch_query_table,
229 should_be_acted_on=should_act_on,
230 is_main_package=is_main_package,
231 )
234def _check_binary_arch(
235 arch_table: DpkgArchTable,
236 binary_arch: str,
237 declared_arch: str,
238) -> bool:
239 if binary_arch == "all":
240 return True
241 arch_wildcards = declared_arch.split()
242 for arch_wildcard in arch_wildcards:
243 if arch_table.matches_architecture(binary_arch, arch_wildcard):
244 return True
245 return False
248class BinaryPackage:
249 __slots__ = [
250 "_package_fields",
251 "_dbgsym_binary_package",
252 "_should_be_acted_on",
253 "_dpkg_architecture_variables",
254 "_declared_arch_matches_output_arch",
255 "_is_main_package",
256 "_substvars",
257 "_maintscript_snippets",
258 ]
260 def __init__(
261 self,
262 fields: Mapping[str, str] | Deb822ParagraphElement,
263 dpkg_architecture_variables: DpkgArchitectureBuildProcessValuesTable,
264 dpkg_arch_query: DpkgArchTable,
265 *,
266 is_main_package: bool = False,
267 should_be_acted_on: bool = True,
268 ) -> None:
269 super().__init__()
270 # Typing-wise, Deb822ParagraphElement is *not* a Mapping[str, str] but it behaves enough
271 # like one that we rely on it and just cast it.
272 self._package_fields = cast("Mapping[str, str]", fields)
273 self._dbgsym_binary_package = None
274 self._should_be_acted_on = should_be_acted_on
275 self._dpkg_architecture_variables = dpkg_architecture_variables
276 self._is_main_package = is_main_package
277 self._declared_arch_matches_output_arch = _check_binary_arch(
278 dpkg_arch_query, self.resolved_architecture, self.declared_architecture
279 )
281 @property
282 def name(self) -> str:
283 return self.fields["Package"]
285 @property
286 def archive_section(self) -> str:
287 value = self.fields.get("Section")
288 if value is None: 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true
289 return "Unknown"
290 return value
292 @property
293 def archive_component(self) -> str:
294 component = ""
295 section = self.archive_section
296 if "/" in section:
297 component = section.rsplit("/", 1)[0]
298 # The "main" component is always shortened to ""
299 if component == "main":
300 component = ""
301 return component
303 @property
304 def is_essential(self) -> bool:
305 return self._package_fields.get("Essential") == "yes"
307 @property
308 def is_udeb(self) -> bool:
309 return self.package_type is PackageTypeSelector.UDEB
311 @property
312 def should_be_acted_on(self) -> bool:
313 return self._should_be_acted_on and self._declared_arch_matches_output_arch
315 @property
316 def fields(self) -> Mapping[str, str]:
317 return self._package_fields
319 @property
320 def resolved_architecture(self) -> str:
321 arch = self.declared_architecture
322 if arch == "all":
323 return arch
324 if self._x_dh_build_for_type == "target": 324 ↛ 325line 324 didn't jump to line 325 because the condition on line 324 was never true
325 return self._dpkg_architecture_variables["DEB_TARGET_ARCH"]
326 return self._dpkg_architecture_variables.current_host_arch
328 def package_deb_architecture_variable(self, variable_suffix: str) -> str:
329 if self._x_dh_build_for_type == "target": 329 ↛ 330line 329 didn't jump to line 330 because the condition on line 329 was never true
330 return self._dpkg_architecture_variables[f"DEB_TARGET_{variable_suffix}"]
331 return self._dpkg_architecture_variables[f"DEB_HOST_{variable_suffix}"]
333 @property
334 def deb_multiarch(self) -> str:
335 return self.package_deb_architecture_variable("MULTIARCH")
337 @property
338 def _x_dh_build_for_type(self) -> str:
339 v = self._package_fields.get("X-DH-Build-For-Type")
340 if v is None: 340 ↛ 342line 340 didn't jump to line 342 because the condition on line 340 was always true
341 return "host"
342 return v.lower()
344 @property
345 def package_type(self) -> PackageTypeSelector.Singleton:
346 """Short for Package-Type (with proper default if absent)"""
347 v = self.fields.get("Package-Type", default="deb")
348 try:
349 return PackageTypeSelector.singleton(v)
350 except KeyError:
351 _error(f"invalid Package-Type: {v}, expected: {PackageTypeSelector.ALL})")
353 @property
354 def is_main_package(self) -> bool:
355 return self._is_main_package
357 def cross_command(self, command: str) -> str:
358 arch_table = self._dpkg_architecture_variables
359 if self._x_dh_build_for_type == "target":
360 target_gnu_type = arch_table["DEB_TARGET_GNU_TYPE"]
361 if arch_table["DEB_HOST_GNU_TYPE"] != target_gnu_type:
362 return f"{target_gnu_type}-{command}"
363 if arch_table.is_cross_compiling:
364 return f"{arch_table['DEB_HOST_GNU_TYPE']}-{command}"
365 return command
367 @property
368 def declared_architecture(self) -> str:
369 return self.fields["Architecture"]
371 @property
372 def is_arch_all(self) -> bool:
373 return self.declared_architecture == "all"
376class SourcePackage:
377 __slots__ = ("_package_fields",)
379 def __init__(self, fields: Mapping[str, str] | Deb822ParagraphElement):
380 # Typing-wise, Deb822ParagraphElement is *not* a Mapping[str, str] but it behaves enough
381 # like one that we rely on it and just cast it.
382 self._package_fields = cast("Mapping[str, str]", fields)
384 @property
385 def fields(self) -> Mapping[str, str]:
386 return self._package_fields
388 @property
389 def name(self) -> str:
390 return self._package_fields["Source"]