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

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 

16 

17from debian.substvars import Substvars 

18 

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 

29 

30DEBPUTY_TEST_AGAINST_INSTALLED_PLUGINS = ( 

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

32) 

33 

34 

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

36class ADRExampleIssue: 

37 name: str 

38 example_index: int 

39 inconsistent_paths: Sequence[str] 

40 

41 

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 

47 

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: 

50 

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 

60 

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

64 

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

66 

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 

84 

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) 

93 

94 

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

96class RegisteredTrigger: 

97 dpkg_trigger_type: DpkgTriggerType 

98 dpkg_trigger_target: str 

99 

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

103 

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 ) 

113 

114 

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

116class RegisteredMaintscript: 

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

118 

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 

124 

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 

130 

131 

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 

142 

143 

144class RegisteredPackagerProvidedFile(metaclass=ABCMeta): 

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

146 

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

151 

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 

164 

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 

174 

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. 

180 

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

182 how to derive it: 

183 

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

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

186 

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. 

190 

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. 

194 

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 

205 

206 

207class RegisteredMetadata: 

208 __slots__ = () 

209 

210 @property 

211 def substvars(self) -> Substvars: 

212 """Returns the Substvars 

213 

214 :return: The substvars in their current state. 

215 """ 

216 raise NotImplementedError 

217 

218 @property 

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

220 raise NotImplementedError 

221 

222 def maintscripts( 

223 self, 

224 *, 

225 maintscript: Maintscript | None = None, 

226 ) -> list[RegisteredMaintscript]: 

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

228 

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 

237 

238 

239class InitializedPluginUnderTest: 

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

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

242 

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

244 """ 

245 return self.packager_provided_files_by_stem().values() 

246 

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 

252 

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 

260 

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 

268 

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 

276 

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

278 

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 

285 

286 @property 

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

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

289 

290 :return: All manifest variables declared by the plugin 

291 """ 

292 raise NotImplementedError 

293 

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

295 """Validate examples of the automatic discard rules 

296 

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

298 the failed example in full. 

299 

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 

304 

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 

314 

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

316 for a given service manager. 

317 

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. 

320 

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 

332 

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 

340 

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. 

344 

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. 

347 

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