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