Coverage for src/debputy/maintscript_snippet.py: 64%
252 statements
« prev ^ index » next coverage.py v7.8.2, created at 2026-08-23 18:25 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2026-08-23 18:25 +0000
1import collections
2import dataclasses
3import enum
4import typing
5from typing import Self, TYPE_CHECKING
6from collections.abc import Sequence, Iterable
8import debian.debian_support
10from debputy.manifest_parser.tagging_types import DebputyDispatchableType
11from debputy.manifest_parser.util import AttributePath
12from debputy.substitution import Substitution
13from debputy.util import POSTINST_DEFAULT_CONDITION
15if TYPE_CHECKING:
16 from debputy.plugins.debputy.binary_package_rules import (
17 MaintainerProvidedMaintscriptSnippetContainer,
18 )
20# The standard ctrl scripts from `dpkg` (`config` is from `debconf`)
21DPKG_DEB_CONTROL_SCRIPTS = frozenset(
22 {
23 "preinst",
24 "prerm",
25 "postinst",
26 "postrm",
27 }
28)
29# all control scripts for debs and udebs respectively.
30ALL_DEB_CONTROL_SCRIPTS = DPKG_DEB_CONTROL_SCRIPTS | {"config"}
31ALL_UDEB_CONTROL_SCRIPTS = frozenset(
32 {
33 "postinst",
34 "menutest",
35 "isinstallable",
36 "config",
37 }
38)
39ALL_CONTROL_SCRIPTS = DPKG_DEB_CONTROL_SCRIPTS | ALL_UDEB_CONTROL_SCRIPTS
40# TODO: Unclear what is missing to support the remaining `udeb` scripts.
41#
42# Note depending on support, this might change into scripts that we do something useful
43# for vs. scripts we just handle as a PPF.
44SUPPORTED_UDEB_SCRIPTS = frozenset({"postinst"})
47@dataclasses.dataclass(slots=True, frozen=True)
48class MaintscriptForBinary:
49 name: str
52class SnippetAnchor(enum.StrEnum):
53 CUSTOM_SCRIPT_PLACEHOLDER = "custom-script-placeholder"
54 """
55 Placeholder.
57 Custom scripts should not have a specific place but rather a "before X/after X" logic.
58 """
59 USER_CREATION = "user-creation"
60 CONFIGURATION_MANAGEMENT = "configuration-management"
61 # TODO: Rename before it becomes public
62 _BETWEEN_CONFIGURATION_MANAGEMENT_AND_SERVICE = (
63 "between-configuration-maagement-and-service"
64 )
65 SERVICE = "service"
68class SnippetResolver:
70 def _resolve_snippet(self) -> str:
71 raise NotImplementedError
73 def resolve(self) -> str:
74 snippet = self._resolve_snippet()
75 if not snippet.endswith("\n"): 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true
76 return snippet + "\n"
77 return snippet
79 def apply_manifest_substitution(
80 self,
81 definition_source: str,
82 substitution: Substitution,
83 ) -> "SnippetResolver":
84 return SubstitutingSnippetResolver(self, definition_source, substitution)
86 @classmethod
87 def snippet(cls, snippet: str) -> "SnippetResolver":
88 return ConstantSnippetResolver(snippet, "IGNORED", None)
90 @classmethod
91 def lazy_snippet_no_manifest_substitution(
92 cls,
93 impl: collections.abc.Callable[[], str],
94 ) -> "SnippetResolver":
95 return LazySnippetResolver(impl)
97 @classmethod
98 def lazy_snippet(
99 cls,
100 impl: collections.abc.Callable[[], str],
101 ) -> "SnippetResolver":
102 return LazySnippetResolver(impl)
105class ConstantSnippetResolver(SnippetResolver):
106 def __init__(
107 self, snippet: str, definition_source: str, substitution: Substitution | None
108 ) -> None:
109 self._snippet = snippet
110 self._definition_source = definition_source
111 self._substitution = substitution
113 @property
114 def provides_substitution(self) -> bool:
115 return self._substitution is not None
117 def _resolve_snippet(self) -> str:
118 template = self._snippet
119 substitution = self._substitution
120 if substitution: 120 ↛ 121line 120 didn't jump to line 121 because the condition on line 120 was never true
121 return substitution.substitute(
122 template,
123 self._definition_source,
124 )
125 return template
128class CachingSnippetResolver(SnippetResolver):
130 def __init__(self) -> None:
131 self._resolved: str | None = None
133 def _resolve_snippet_uncached(self) -> str:
134 raise NotImplementedError
136 def _resolve_snippet(self) -> str:
137 resolved = self._resolved
138 if resolved is not None:
139 return resolved
140 resolved = self._resolve_snippet_uncached()
141 self._resolved = resolved
142 return resolved
145class LazySnippetResolver(CachingSnippetResolver):
147 def __init__(self, resolver: collections.abc.Callable[[], str]) -> None:
148 super().__init__()
149 self._resolver = resolver
151 def _resolve_snippet_uncached(self) -> str:
152 return self._resolver()
155class SubstitutingSnippetResolver(CachingSnippetResolver):
157 def __init__(
158 self,
159 wrapped_resolver: SnippetResolver,
160 definition_source: str,
161 substitution: Substitution,
162 ) -> None:
163 super().__init__()
164 self._wrapped_resolver = wrapped_resolver
165 self._definition_source = definition_source
166 self._substitution = substitution
168 def _resolve_snippet_uncached(self) -> str:
169 template = self._wrapped_resolver.resolve()
170 return self._substitution.substitute(template, self._definition_source)
173@dataclasses.dataclass(slots=True, frozen=True)
174class UnboundMaintscriptSnippet:
175 definition_source: str
176 snippet: SnippetResolver
177 snippet_anchor: SnippetAnchor = (
178 SnippetAnchor._BETWEEN_CONFIGURATION_MANAGEMENT_AND_SERVICE
179 )
180 uses_debconf: bool = False
182 def script_content(self) -> str:
183 snippet = self.snippet.resolve()
184 lines = [
185 f"# Snippet source: {self.definition_source}\n",
186 snippet,
187 ]
188 return "".join(lines)
191@dataclasses.dataclass(slots=True, frozen=True)
192class MaintscriptSnippetForNamedScript:
193 definition_source: str
194 maintscript_name: str
195 snippet: SnippetResolver
196 snippet_anchor: SnippetAnchor = (
197 SnippetAnchor._BETWEEN_CONFIGURATION_MANAGEMENT_AND_SERVICE
198 )
199 uses_debconf: bool = False
201 def apply_to_container(
202 self,
203 maintscript_container: "PackageMaintscriptSnippetContainer",
204 ) -> None:
205 maintscript_container[self.maintscript_name].append(self)
207 def script_content(self) -> str:
208 snippet = self.snippet.resolve()
209 lines = [
210 f"# Snippet source: {self.definition_source}\n",
211 snippet,
212 ]
213 return "".join(lines)
216MaintscriptSnippet: typing.TypeAlias = (
217 UnboundMaintscriptSnippet | MaintscriptSnippetForNamedScript
218)
221class MaintscriptSnippetContainer:
222 def __init__(self, script_name: str) -> None:
223 self._script_name = script_name
224 self._snippets_by_order: dict[SnippetAnchor, list[MaintscriptSnippet]] = {}
226 def copy(self) -> "MaintscriptSnippetContainer":
227 instance = self.__class__(self._script_name)
228 instance._snippets_by_order = self._snippets_by_order.copy()
229 return instance
231 def append(self, maintscript_snippet: MaintscriptSnippet) -> None:
232 assert maintscript_snippet.snippet_anchor
233 if ( 233 ↛ 237line 233 didn't jump to line 237 because the condition on line 233 was never true
234 isinstance(maintscript_snippet, MaintscriptSnippetForNamedScript)
235 and maintscript_snippet.maintscript_name != self._script_name
236 ):
237 raise ValueError(
238 f"Cannot apply snippet for {maintscript_snippet.maintscript_name}"
239 f" to {self._script_name}"
240 )
241 if maintscript_snippet.snippet_anchor not in self._snippets_by_order:
242 self._snippets_by_order[maintscript_snippet.snippet_anchor] = []
243 self._snippets_by_order[maintscript_snippet.snippet_anchor].append(
244 maintscript_snippet
245 )
247 def all_snippets(self) -> Iterable[MaintscriptSnippet]:
248 for sa in SnippetAnchor:
249 if sa not in self._snippets_by_order:
250 continue
251 yield from self._snippets_by_order[sa]
253 def needs_debconf(self) -> bool:
254 return any(
255 s.uses_debconf
256 for snippet_lists in self._snippets_by_order.values()
257 for s in snippet_lists
258 )
260 def generate_snippet(
261 self,
262 *,
263 tool_with_version: str | None = None,
264 snippet_anchor: SnippetAnchor = SnippetAnchor._BETWEEN_CONFIGURATION_MANAGEMENT_AND_SERVICE,
265 reverse: bool = False,
266 ) -> str | None:
267 snippets = self._snippets_by_order.get(snippet_anchor)
268 if not snippets:
269 return None
270 if reverse:
271 resulting_snippets: collections.abc.Iterable[MaintscriptSnippet] = reversed(
272 snippets
273 )
274 else:
275 resulting_snippets = snippets
276 inner_content = "".join(s.script_content() for s in resulting_snippets)
278 if not inner_content: 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true
279 return None
281 if tool_with_version: 281 ↛ 282line 281 didn't jump to line 282 because the condition on line 281 was never true
282 return (
283 f"# Automatically added by {tool_with_version}\n"
284 + inner_content
285 + "# End automatically added section"
286 )
287 return inner_content
290class PackageMaintscriptSnippetContainer(
291 collections.abc.Mapping[str, MaintscriptSnippetContainer]
292):
294 def __init__(self, supported_scripts: frozenset[str] | None = None) -> None:
295 self._supported_scripts = (
296 supported_scripts if supported_scripts is not None else ALL_CONTROL_SCRIPTS
297 )
298 self._script_containers: dict[str, MaintscriptSnippetContainer] = {}
300 def __len__(self):
301 return len(self._script_containers)
303 def __iter__(self) -> collections.abc.Iterator[str]:
304 yield from self._script_containers
306 def __getitem__(self, key: str, /) -> MaintscriptSnippetContainer:
307 if key not in self._supported_scripts: 307 ↛ 308line 307 didn't jump to line 308 because the condition on line 307 was never true
308 raise KeyError(key)
309 try:
310 return self._script_containers[key]
311 except KeyError:
312 pass
313 c = MaintscriptSnippetContainer(key)
314 self._script_containers[key] = c
315 return c
317 def apply_maintainer_provided_snippets(
318 self,
319 maintainer_provided_maintscript_snippets: "MaintainerProvidedMaintscriptSnippetContainer",
320 ) -> None:
321 for snippet in maintainer_provided_maintscript_snippets.snippets:
322 snippet.apply_to_container(self)
325class DpkgMaintscriptHelperCommand(DebputyDispatchableType):
326 __slots__ = ("cmdline", "definition_source")
328 def __init__(self, cmdline: Sequence[str], definition_source: str) -> None:
329 super().__init__()
330 self.cmdline = cmdline
331 self.definition_source = definition_source
333 @classmethod
334 def _finish_cmd(
335 cls,
336 definition_source: str,
337 cmdline: list[str],
338 prior_version: str | None,
339 owning_package: str | None,
340 ) -> Self:
341 if prior_version is not None:
342 cmdline.append(prior_version)
343 if owning_package is not None:
344 if prior_version is None: 344 ↛ 346line 344 didn't jump to line 346 because the condition on line 344 was never true
345 # Empty is allowed according to `man dpkg-maintscript-helper`
346 cmdline.append("")
347 cmdline.append(owning_package)
348 return cls(
349 tuple(cmdline),
350 definition_source,
351 )
353 @classmethod
354 def rm_conffile(
355 cls,
356 definition_source: AttributePath,
357 conffile: str,
358 prior_version: str | None = None,
359 owning_package: str | None = None,
360 ) -> Self:
361 cmdline = ["rm_conffile", conffile]
362 return cls._finish_cmd(
363 definition_source.path, cmdline, prior_version, owning_package
364 )
366 @classmethod
367 def mv_conffile(
368 cls,
369 definition_source: AttributePath,
370 old_conffile: str,
371 new_confile: str,
372 prior_version: str | None = None,
373 owning_package: str | None = None,
374 ) -> Self:
375 cmdline = ["mv_conffile", old_conffile, new_confile]
376 return cls._finish_cmd(
377 definition_source.path, cmdline, prior_version, owning_package
378 )
380 @classmethod
381 def symlink_to_dir(
382 cls,
383 definition_source: AttributePath,
384 pathname: str,
385 old_target: str,
386 prior_version: str | None = None,
387 owning_package: str | None = None,
388 ) -> Self:
389 cmdline = ["symlink_to_dir", pathname, old_target]
390 return cls._finish_cmd(
391 definition_source.path, cmdline, prior_version, owning_package
392 )
394 @classmethod
395 def dir_to_symlink(
396 cls,
397 definition_source: AttributePath,
398 pathname: str,
399 new_target: str,
400 prior_version: str | None = None,
401 owning_package: str | None = None,
402 ) -> Self:
403 cmdline = ["dir_to_symlink", pathname, new_target]
404 return cls._finish_cmd(
405 definition_source.path, cmdline, prior_version, owning_package
406 )
409class MaintscriptCondition(DebputyDispatchableType):
410 __slots__ = ("_maintscript_name", "_condition")
412 def __init__(self, maintscript_name: str, condition: str) -> None:
413 super().__init__()
414 if maintscript_name not in ALL_CONTROL_SCRIPTS:
415 raise ValueError(f"Invalid control script name: {maintscript_name}")
416 self._maintscript_name = maintscript_name
417 self._condition = condition
419 @property
420 def maintscript_name(self) -> str:
421 return self._maintscript_name
423 @classmethod
424 def on_purge(cls) -> typing.Self:
425 condition = '[ "$1" = "purge" ]'
426 return cls("postrm", condition)
428 @classmethod
429 def on_configure(cls) -> typing.Self:
430 condition = POSTINST_DEFAULT_CONDITION
431 return cls("postinst", condition)
433 @classmethod
434 def on_initial_install(cls) -> typing.Self:
435 condition = '[ "$1" = "configure" -a -z "$2" ]'
436 return cls("postinst", condition)
438 @classmethod
439 def on_upgrade(cls) -> typing.Self:
440 condition = '[ "$1" = "configure" -a -n "$2" ]'
441 return cls("postinst", condition)
443 @classmethod
444 def on_upgrade_from(
445 cls,
446 from_version: debian.debian_support.Version,
447 ) -> typing.Self:
448 condition = (
449 f'[ "$1" = "configure" ] && dpkg --compare-versions le-nl "{from_version}"'
450 )
451 return cls("postinst", condition)
453 @classmethod
454 def on_before_upgrade(cls) -> typing.Self:
455 condition = '[ "$1" = "upgrade" ]'
456 return cls("preinst", condition)
458 @classmethod
459 def on_before_removal(cls) -> typing.Self:
460 condition = '[ "$1" = "remove" ]'
461 return cls("prerm", condition)
463 @classmethod
464 def on_after_removal(cls) -> typing.Self:
465 condition = '[ "$1" = "remove" ]'
466 return cls("postrm", condition)
468 @classmethod
469 def on_unconditionally_in_script(cls, script_name: str) -> typing.Self:
470 return cls(script_name, "true")
472 def _apply_condition_to_script(
473 self,
474 condition: str,
475 run_snippet: str,
476 /,
477 indent: bool | None = None,
478 ) -> str:
479 if indent is None:
480 # We auto-determine this based on heredocs currently
481 indent = "<<" not in run_snippet
483 if indent:
484 run_snippet = "".join(" " + x for x in run_snippet.splitlines(True))
485 if not run_snippet.endswith("\n"):
486 run_snippet += "\n"
487 condition_line = f"if {condition}; then\n"
488 end_line = "fi\n"
489 return "".join((condition_line, run_snippet, end_line))
491 def _wrap_resolver(
492 self,
493 snippet_resolver: SnippetResolver,
494 *,
495 indent: bool | None = None,
496 ) -> SnippetResolver:
497 def _wrap() -> str:
498 unconditional_snippet = snippet_resolver.resolve()
499 return self._apply_condition_to_script(
500 self._condition,
501 unconditional_snippet,
502 indent=indent,
503 )
505 return SnippetResolver.lazy_snippet(_wrap)
507 def as_maintscript_snippet(
508 self,
509 definition_source: str,
510 snippet_resolver,
511 *,
512 snippet_anchor: SnippetAnchor = SnippetAnchor._BETWEEN_CONFIGURATION_MANAGEMENT_AND_SERVICE,
513 uses_debconf: bool = False,
514 indent: bool | None = None,
515 ) -> MaintscriptSnippetForNamedScript:
516 return MaintscriptSnippetForNamedScript(
517 definition_source,
518 self.maintscript_name,
519 self._wrap_resolver(snippet_resolver, indent=indent),
520 snippet_anchor=snippet_anchor,
521 uses_debconf=uses_debconf,
522 )