Coverage for src/debputy/maintscript_snippet.py: 77%
117 statements
« prev ^ index » next coverage.py v7.8.2, created at 2026-06-16 19:34 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2026-06-16 19:34 +0000
1import collections
2import dataclasses
3import functools
4from typing import Literal, Self
5from collections.abc import Sequence, Iterable
7from debputy.manifest_parser.tagging_types import DebputyDispatchableType
8from debputy.manifest_parser.util import AttributePath
10STD_CONTROL_SCRIPTS = frozenset(
11 {
12 "preinst",
13 "prerm",
14 "postinst",
15 "postrm",
16 }
17)
18UDEB_CONTROL_SCRIPTS = frozenset(
19 {
20 "postinst",
21 "menutest",
22 "isinstallable",
23 }
24)
25ALL_CONTROL_SCRIPTS = STD_CONTROL_SCRIPTS | UDEB_CONTROL_SCRIPTS | {"config"}
28class SnippetResolver:
30 def _resolve_snippet(self) -> str:
31 raise NotImplementedError
33 def resolve(self) -> str:
34 snippet = self._resolve_snippet()
35 if not snippet.endswith("\n"): 35 ↛ 36line 35 didn't jump to line 36 because the condition on line 35 was never true
36 return snippet + "\n"
37 return snippet
39 @classmethod
40 def snippet_template(cls, snippet: str) -> "SnippetResolver":
41 return ConstantSnippetResolver(snippet)
43 @classmethod
44 def lazy_snippet_template(
45 cls, impl: collections.abc.Callable[[], str]
46 ) -> "SnippetResolver":
47 return LazySnippetResolver(impl)
50class ConstantSnippetResolver(SnippetResolver):
51 def __init__(self, snippet: str) -> None:
52 self._snippet = snippet
54 def _resolve_snippet(self) -> str:
55 return self._snippet
58class LazySnippetResolver(SnippetResolver):
60 def __init__(self, resolver: collections.abc.Callable[[], str]) -> None:
61 self._resolver = resolver
63 @functools.lru_cache
64 def _resolve_snippet(self) -> str:
65 return self._resolver()
68@dataclasses.dataclass(slots=True, frozen=True)
69class MaintscriptSnippet:
70 definition_source: str
71 snippet: SnippetResolver
72 snippet_order: Literal["service"] | None = None
73 uses_debconf: bool = False
75 def script_content(self) -> str:
76 snippet = self.snippet.resolve()
77 lines = [
78 f"# Snippet source: {self.definition_source}\n",
79 snippet,
80 ]
81 return "".join(lines)
84class MaintscriptSnippetContainer:
85 def __init__(self) -> None:
86 self._generic_snippets: list[MaintscriptSnippet] = []
87 self._snippets_by_order: dict[Literal["service"], list[MaintscriptSnippet]] = {}
89 def copy(self) -> "MaintscriptSnippetContainer":
90 instance = self.__class__()
91 instance._generic_snippets = self._generic_snippets.copy()
92 instance._snippets_by_order = self._snippets_by_order.copy()
93 return instance
95 def append(self, maintscript_snippet: MaintscriptSnippet) -> None:
96 if maintscript_snippet.snippet_order is None:
97 self._generic_snippets.append(maintscript_snippet)
98 else:
99 if maintscript_snippet.snippet_order not in self._snippets_by_order: 99 ↛ 101line 99 didn't jump to line 101 because the condition on line 99 was always true
100 self._snippets_by_order[maintscript_snippet.snippet_order] = []
101 self._snippets_by_order[maintscript_snippet.snippet_order].append(
102 maintscript_snippet
103 )
105 def has_content(self, snippet_order: Literal["service"] | None = None) -> bool:
106 if snippet_order is None:
107 return bool(self._generic_snippets)
108 if snippet_order not in self._snippets_by_order:
109 return False
110 return bool(self._snippets_by_order[snippet_order])
112 def all_snippets(self) -> Iterable[MaintscriptSnippet]:
113 yield from self._generic_snippets
114 for snippets in self._snippets_by_order.values():
115 yield from snippets
117 def needs_debconf(self) -> bool:
118 return any(s.uses_debconf for s in self._generic_snippets) or any(
119 s.uses_debconf
120 for snippet_lists in self._snippets_by_order.values()
121 for s in snippet_lists
122 )
124 def generate_snippet(
125 self,
126 tool_with_version: str | None = None,
127 snippet_order: Literal["service"] | None = None,
128 reverse: bool = False,
129 ) -> str | None:
130 inner_content = ""
131 if snippet_order is None:
132 snippets = (
133 reversed(self._generic_snippets) if reverse else self._generic_snippets
134 )
135 inner_content = "".join(s.script_content() for s in snippets)
136 elif snippet_order in self._snippets_by_order:
137 snippets = self._snippets_by_order[snippet_order]
138 if reverse: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 snippets = reversed(snippets)
140 inner_content = "".join(s.script_content() for s in snippets)
142 if not inner_content:
143 return None
145 if tool_with_version: 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true
146 return (
147 f"# Automatically added by {tool_with_version}\n"
148 + inner_content
149 + "# End automatically added section"
150 )
151 return inner_content
154class DpkgMaintscriptHelperCommand(DebputyDispatchableType):
155 __slots__ = ("cmdline", "definition_source")
157 def __init__(self, cmdline: Sequence[str], definition_source: str) -> None:
158 super().__init__()
159 self.cmdline = cmdline
160 self.definition_source = definition_source
162 @classmethod
163 def _finish_cmd(
164 cls,
165 definition_source: str,
166 cmdline: list[str],
167 prior_version: str | None,
168 owning_package: str | None,
169 ) -> Self:
170 if prior_version is not None:
171 cmdline.append(prior_version)
172 if owning_package is not None:
173 if prior_version is None: 173 ↛ 175line 173 didn't jump to line 175 because the condition on line 173 was never true
174 # Empty is allowed according to `man dpkg-maintscript-helper`
175 cmdline.append("")
176 cmdline.append(owning_package)
177 return cls(
178 tuple(cmdline),
179 definition_source,
180 )
182 @classmethod
183 def rm_conffile(
184 cls,
185 definition_source: AttributePath,
186 conffile: str,
187 prior_version: str | None = None,
188 owning_package: str | None = None,
189 ) -> Self:
190 cmdline = ["rm_conffile", conffile]
191 return cls._finish_cmd(
192 definition_source.path, cmdline, prior_version, owning_package
193 )
195 @classmethod
196 def mv_conffile(
197 cls,
198 definition_source: AttributePath,
199 old_conffile: str,
200 new_confile: str,
201 prior_version: str | None = None,
202 owning_package: str | None = None,
203 ) -> Self:
204 cmdline = ["mv_conffile", old_conffile, new_confile]
205 return cls._finish_cmd(
206 definition_source.path, cmdline, prior_version, owning_package
207 )
209 @classmethod
210 def symlink_to_dir(
211 cls,
212 definition_source: AttributePath,
213 pathname: str,
214 old_target: str,
215 prior_version: str | None = None,
216 owning_package: str | None = None,
217 ) -> Self:
218 cmdline = ["symlink_to_dir", pathname, old_target]
219 return cls._finish_cmd(
220 definition_source.path, cmdline, prior_version, owning_package
221 )
223 @classmethod
224 def dir_to_symlink(
225 cls,
226 definition_source: AttributePath,
227 pathname: str,
228 new_target: str,
229 prior_version: str | None = None,
230 owning_package: str | None = None,
231 ) -> Self:
232 cmdline = ["dir_to_symlink", pathname, new_target]
233 return cls._finish_cmd(
234 definition_source.path, cmdline, prior_version, owning_package
235 )