Coverage for src/debputy/plugin/api/test_api/test_spec.py: 100%
76 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
1import dataclasses
2import os
3from abc import ABCMeta
4from typing import Generic, Self, FrozenSet
5from collections.abc import Iterable, Mapping, Callable, Sequence
7from debian.substvars import Substvars
9from debputy import filesystem_scan
10from debputy.plugin.api import (
11 VirtualPath,
12 PackageProcessingContext,
13 DpkgTriggerType,
14 Maintscript,
15)
16from debputy.plugin.api.impl_types import PluginProvidedTrigger
17from debputy.plugin.api.spec import DSD, ServiceUpgradeRule, PathDef
18from debputy.substitution import VariableContext
20DEBPUTY_TEST_AGAINST_INSTALLED_PLUGINS = (
21 os.environ.get("DEBPUTY_TEST_PLUGIN_LOCATION", "uninstalled") == "installed"
22)
25@dataclasses.dataclass(slots=True, frozen=True)
26class ADRExampleIssue:
27 name: str
28 example_index: int
29 inconsistent_paths: Sequence[str]
32def build_virtual_file_system(
33 paths: Iterable[str | PathDef],
34 read_write_fs: bool = True,
35) -> VirtualPath:
36 """Create a pure-virtual file system for use with metadata detectors
38 This method will generate a virtual file system a list of path names or virtual path definitions. It will
39 also insert any implicit path required to make the file system connected. As an example:
41 >>> fs_root = build_virtual_file_system(['./usr/share/doc/package/copyright'])
42 >>> # The file we explicitly requested is obviously there
43 >>> fs_root.lookup('./usr/share/doc/package/copyright') is not None
44 True
45 >>> # but so is every directory up to that point
46 >>> all(fs_root.lookup(d).is_dir
47 ... for d in ['./usr', './usr/share', './usr/share/doc', './usr/share/doc/package']
48 ... )
49 True
51 Any string provided will be passed to `virtual_path` using all defaults for other parameters, making `str`
52 arguments a nice easy shorthand if you just want a path to exist, but do not really care about it otherwise
53 (or `virtual_path_def` defaults happens to work for you).
55 Here is a very small example of how to create some basic file system objects to get you started:
57 >>> from debputy.plugin.api import virtual_path_def
58 >>> path_defs = [
59 ... './usr/share/doc/', # Create a directory
60 ... virtual_path_def("./bin/zcat", link_target="/bin/gzip"), # Create a symlink
61 ... virtual_path_def("./bin/gzip", mode=0o755), # Create a file (with a custom mode)
62 ... ]
63 >>> fs_root = build_virtual_file_system(path_defs)
64 >>> fs_root.lookup('./usr/share/doc').is_dir
65 True
66 >>> fs_root.lookup('./bin/zcat').is_symlink
67 True
68 >>> fs_root.lookup('./bin/zcat').readlink() == '/bin/gzip'
69 True
70 >>> fs_root.lookup('./bin/gzip').is_file
71 True
72 >>> fs_root.lookup('./bin/gzip').mode == 0o755
73 True
75 :param paths: An iterable any mix of path names (str) and virtual_path_def definitions
76 (results from `virtual_path_def`).
77 :param read_write_fs: Whether the file system is read-write (True) or read-only (False).
78 Note that this is the default permission; the plugin test API may temporarily turn a
79 read-write to read-only temporarily (when running a metadata detector, etc.).
80 :return: The root of the generated file system
81 """
82 return filesystem_scan.build_virtual_fs(paths, read_write_fs=read_write_fs)
85@dataclasses.dataclass(slots=True, frozen=True)
86class RegisteredTrigger:
87 dpkg_trigger_type: DpkgTriggerType
88 dpkg_trigger_target: str
90 def serialized_format(self) -> str:
91 """The semantic contents of the DEBIAN/triggers file"""
92 return f"{self.dpkg_trigger_type} {self.dpkg_trigger_target}"
94 @classmethod
95 def from_plugin_provided_trigger(
96 cls,
97 plugin_provided_trigger: PluginProvidedTrigger,
98 ) -> "Self":
99 return cls(
100 plugin_provided_trigger.dpkg_trigger_type,
101 plugin_provided_trigger.dpkg_trigger_target,
102 )
105@dataclasses.dataclass(slots=True, frozen=True)
106class RegisteredMaintscript:
107 """Details about a maintscript registered by a plugin"""
109 # Which maintscript is applies to (e.g., "postinst")
110 maintscript: Maintscript
111 # Which method was used to trigger the script (e.g., "on_configure")
112 registration_method: str
113 # The snippet provided by the plugin as it was provided
114 # That is, no indentation/conditions/substitutions have been applied to this text
115 plugin_provided_script: str
116 # Whether substitutions would have been applied in a production run
117 requested_substitution: bool
120@dataclasses.dataclass(slots=True, frozen=True)
121class DetectedService(Generic[DSD]):
122 path: VirtualPath
123 names: Sequence[str]
124 type_of_service: str
125 service_scope: str
126 enable_by_default: bool
127 start_by_default: bool
128 default_upgrade_rule: ServiceUpgradeRule
129 service_context: DSD | None
132class RegisteredPackagerProvidedFile(metaclass=ABCMeta):
133 """Record of a registered packager provided file - No instantiation
135 New "mandatory" attributes may be added in minor versions, which means instantiation will break tests.
136 Plugin providers should therefore not create instances of this dataclass. It is visible only to aid
137 test writing by providing type-safety / auto-completion.
138 """
140 """The name stem used for generating the file"""
141 stem: str
142 """The recorded directory these file should be installed into"""
143 installed_path: str
144 """The mode that debputy will give these files when installed (unless overridden)"""
145 default_mode: int
146 """The default priority assigned to files unless overridden (if priories are assigned at all)"""
147 default_priority: int | None
148 """The filename format to be used"""
149 filename_format: str | None
150 """The formatting correcting callback"""
151 post_formatting_rewrite: Callable[[str], str] | None
153 def compute_dest(
154 self,
155 assigned_name: str,
156 *,
157 assigned_priority: int | None = None,
158 owning_package: str | None = None,
159 path: VirtualPath | None = None,
160 ) -> tuple[str, str]:
161 """Determine the basename of this packager provided file
163 This method is useful for verifying that the `installed_path` and `post_formatting_rewrite` works
164 as intended. As example, some programs do not support "." in their configuration files, so you might
165 have a post_formatting_rewrite à la `lambda x: x.replace(".", "_")`. Then you can test it by
166 calling `assert rppf.compute_dest("python3.11")[1] == "python3_11"` to verify that if a package like
167 `python3.11` were to use this packager provided file, it would still generate a supported file name.
169 For the `assigned_name` parameter, then this is normally derived from the filename. Examples for
170 how to derive it:
172 * `debian/my-pkg.stem` => `my-pkg`
173 * `debian/my-pkg.my-custom-name.stem` => `my-custom-name`
175 Note that all parts (`my-pkg`, `my-custom-name` and `stem`) can contain periods (".") despite
176 also being a delimiter. Additionally, `my-custom-name` is not restricted to being a valid package
177 name, so it can have any file-system valid character in it.
179 For the 0.01% case, where the plugin is using *both* `{name}` *and* `{owning_package}` in the
180 installed_path, then you can separately *also* set the `owning_package` attribute. However, by
181 default the `assigned_named` is used for both when `owning_package` is not provided.
183 :param assigned_name: The name assigned. Usually this is the name of the package containing the file.
184 :param assigned_priority: Optionally a priority override for the file (if priority is supported). Must be
185 omitted/None if priorities are not supported.
186 :param owning_package: Optionally the name of the owning package. It is only needed for those exceedingly
187 rare cases where the `installed_path` contains both `{owning_package}` (usually in addition to `{name}`).
188 :param path: Special-case param, only needed for when testing a special `debputy` PPF..
189 :return: A tuple of the directory name and the basename (in that order) that combined makes up that path
190 that debputy would use.
191 """
192 raise NotImplementedError
195class RegisteredMetadata:
196 __slots__ = ()
198 @property
199 def substvars(self) -> Substvars:
200 """Returns the Substvars
202 :return: The substvars in their current state.
203 """
204 raise NotImplementedError
206 @property
207 def triggers(self) -> list[RegisteredTrigger]:
208 raise NotImplementedError
210 def maintscripts(
211 self,
212 *,
213 maintscript: Maintscript | None = None,
214 ) -> list[RegisteredMaintscript]:
215 """Extract the maintscript provided by the given metadata detector
217 :param maintscript: If provided, only snippet registered for the given maintscript is returned. Can be
218 used to say "Give me all the 'postinst' snippets by this metadata detector", which can simplify
219 verification in some cases.
220 :return: A list of all matching maintscript registered by the metadata detector. If the detector has
221 not been run, then the list will be empty. If the metadata detector has been run multiple times,
222 then this is the aggregation of all the runs.
223 """
224 raise NotImplementedError
227class InitializedPluginUnderTest:
228 def packager_provided_files(self) -> Iterable[RegisteredPackagerProvidedFile]:
229 """An iterable of all packager provided files registered by the plugin under test
231 If you want a particular order, please sort the result.
232 """
233 return self.packager_provided_files_by_stem().values()
235 def packager_provided_files_by_stem(
236 self,
237 ) -> Mapping[str, RegisteredPackagerProvidedFile]:
238 """All packager provided files registered by the plugin under test grouped by name stem"""
239 raise NotImplementedError
241 def run_metadata_detector(
242 self,
243 metadata_detector_id: str,
244 fs_root: VirtualPath,
245 context: PackageProcessingContext | None = None,
246 ) -> RegisteredMetadata:
247 """Run a metadata detector (by its ID) against a given file system
249 :param metadata_detector_id: The ID of the metadata detector to run
250 :param fs_root: The file system the metadata detector should see (must be the root of the file system)
251 :param context: The context the metadata detector should see. If not provided, one will be mock will be
252 provided to the extent possible.
253 :return: The metadata registered by the metadata detector
254 """
255 raise NotImplementedError
257 def run_package_processor(
258 self,
259 package_processor_id: str,
260 fs_root: VirtualPath,
261 context: PackageProcessingContext | None = None,
262 ) -> None:
263 """Run a package processor (by its ID) against a given file system
265 Note: Dependency processors are *not* run first.
267 :param package_processor_id: The ID of the package processor to run
268 :param fs_root: The file system the package processor should see (must be the root of the file system)
269 :param context: The context the package processor should see. If not provided, one will be mock will be
270 provided to the extent possible.
271 """
272 raise NotImplementedError
274 @property
275 def declared_manifest_variables(self) -> set[str] | frozenset[str]:
276 """Extract the manifest variables declared by the plugin
278 :return: All manifest variables declared by the plugin
279 """
280 raise NotImplementedError
282 def automatic_discard_rules_examples_with_issues(self) -> Sequence[ADRExampleIssue]:
283 """Validate examples of the automatic discard rules
285 For any failed example, use `debputy plugin show automatic-discard-rules <name>` to see
286 the failed example in full.
288 :return: If any examples have issues, this will return a non-empty sequence with an
289 entry with each issue.
290 """
291 raise NotImplementedError
293 def run_service_detection_and_integrations(
294 self,
295 service_manager: str,
296 fs_root: VirtualPath,
297 context: PackageProcessingContext | None = None,
298 *,
299 service_context_type_hint: type[DSD] | None = None,
300 ) -> tuple[list[DetectedService[DSD]], RegisteredMetadata]:
301 """Run the service manager's detection logic and return the results
303 This method can be used to validate the service detection and integration logic of a plugin
304 for a given service manager.
306 First the service detector is run and if it finds any services, the integrator code is then
307 run on those services with their default values.
309 :param service_manager: The name of the service manager as provided during the initialization
310 :param fs_root: The file system the system detector should see (must be the root of
311 the file system)
312 :param context: The context the service detector should see. If not provided, one will be mock
313 will be provided to the extent possible.
314 :param service_context_type_hint: Unused; but can be used as a type hint for `mypy` (etc.)
315 to align the return type.
316 :return: A tuple of the list of all detected services in the provided file system and the
317 metadata generated by the integrator (if any services were detected).
318 """
319 raise NotImplementedError
321 def manifest_variables(
322 self,
323 *,
324 resolution_context: VariableContext | None = None,
325 mocked_variables: Mapping[str, str] | None = None,
326 ) -> Mapping[str, str]:
327 """Provide a table of the manifest variables registered by the plugin
329 Each key is a manifest variable and the value of said key is the value of the manifest
330 variable. Lazy loaded variables are resolved when accessed for the first time and may
331 raise exceptions if the preconditions are not correct.
333 Note this method can be called multiple times with different parameters to provide
334 different contexts. Lazy loaded variables are resolved at most once per context.
336 :param resolution_context: An optional context for lazy loaded manifest variables.
337 Create an instance of it via `manifest_variable_resolution_context`.
338 :param mocked_variables: An optional mapping that provides values for certain manifest
339 variables. This can be used if you want a certain variable to have a certain value
340 for the test to be stable (or because the manifest variable you are mocking is from
341 another plugin, and you do not want to deal with the implementation details of how
342 it is set). Any variable that depends on the mocked variable will use the mocked
343 variable in the given context.
344 :return: A table of the manifest variables provided by the plugin. Note this table
345 only contains manifest variables registered by the plugin. Attempting to resolve
346 other variables (directly), such as mocked variables or from other plugins, will
347 trigger a `KeyError`.
348 """
349 raise NotImplementedError