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

1import dataclasses 

2import os 

3from abc import ABCMeta 

4from typing import Generic, Self, FrozenSet 

5from collections.abc import Iterable, Mapping, Callable, Sequence 

6 

7from debian.substvars import Substvars 

8 

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 

19 

20DEBPUTY_TEST_AGAINST_INSTALLED_PLUGINS = ( 

21 os.environ.get("DEBPUTY_TEST_PLUGIN_LOCATION", "uninstalled") == "installed" 

22) 

23 

24 

25@dataclasses.dataclass(slots=True, frozen=True) 

26class ADRExampleIssue: 

27 name: str 

28 example_index: int 

29 inconsistent_paths: Sequence[str] 

30 

31 

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 

37 

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: 

40 

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 

50 

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). 

54 

55 Here is a very small example of how to create some basic file system objects to get you started: 

56 

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 

74 

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) 

83 

84 

85@dataclasses.dataclass(slots=True, frozen=True) 

86class RegisteredTrigger: 

87 dpkg_trigger_type: DpkgTriggerType 

88 dpkg_trigger_target: str 

89 

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}" 

93 

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 ) 

103 

104 

105@dataclasses.dataclass(slots=True, frozen=True) 

106class RegisteredMaintscript: 

107 """Details about a maintscript registered by a plugin""" 

108 

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 

118 

119 

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 

130 

131 

132class RegisteredPackagerProvidedFile(metaclass=ABCMeta): 

133 """Record of a registered packager provided file - No instantiation 

134 

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 """ 

139 

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 

152 

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 

162 

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. 

168 

169 For the `assigned_name` parameter, then this is normally derived from the filename. Examples for 

170 how to derive it: 

171 

172 * `debian/my-pkg.stem` => `my-pkg` 

173 * `debian/my-pkg.my-custom-name.stem` => `my-custom-name` 

174 

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. 

178 

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. 

182 

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 

193 

194 

195class RegisteredMetadata: 

196 __slots__ = () 

197 

198 @property 

199 def substvars(self) -> Substvars: 

200 """Returns the Substvars 

201 

202 :return: The substvars in their current state. 

203 """ 

204 raise NotImplementedError 

205 

206 @property 

207 def triggers(self) -> list[RegisteredTrigger]: 

208 raise NotImplementedError 

209 

210 def maintscripts( 

211 self, 

212 *, 

213 maintscript: Maintscript | None = None, 

214 ) -> list[RegisteredMaintscript]: 

215 """Extract the maintscript provided by the given metadata detector 

216 

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 

225 

226 

227class InitializedPluginUnderTest: 

228 def packager_provided_files(self) -> Iterable[RegisteredPackagerProvidedFile]: 

229 """An iterable of all packager provided files registered by the plugin under test 

230 

231 If you want a particular order, please sort the result. 

232 """ 

233 return self.packager_provided_files_by_stem().values() 

234 

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 

240 

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 

248 

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 

256 

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 

264 

265 Note: Dependency processors are *not* run first. 

266 

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 

273 

274 @property 

275 def declared_manifest_variables(self) -> set[str] | frozenset[str]: 

276 """Extract the manifest variables declared by the plugin 

277 

278 :return: All manifest variables declared by the plugin 

279 """ 

280 raise NotImplementedError 

281 

282 def automatic_discard_rules_examples_with_issues(self) -> Sequence[ADRExampleIssue]: 

283 """Validate examples of the automatic discard rules 

284 

285 For any failed example, use `debputy plugin show automatic-discard-rules <name>` to see 

286 the failed example in full. 

287 

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 

292 

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 

302 

303 This method can be used to validate the service detection and integration logic of a plugin 

304 for a given service manager. 

305 

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. 

308 

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 

320 

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 

328 

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. 

332 

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. 

335 

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