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