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

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) 

19 

20from debian.substvars import Substvars 

21 

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 

32 

33DEBPUTY_TEST_AGAINST_INSTALLED_PLUGINS = ( 

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

35) 

36 

37 

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

39class ADRExampleIssue: 

40 name: str 

41 example_index: int 

42 inconsistent_paths: Sequence[str] 

43 

44 

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 

50 

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: 

53 

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 

63 

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

67 

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

69 

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 

87 

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) 

96 

97 

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

99class RegisteredTrigger: 

100 dpkg_trigger_type: DpkgTriggerType 

101 dpkg_trigger_target: str 

102 

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

106 

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 ) 

116 

117 

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

119class RegisteredMaintscript: 

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

121 

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 

127 

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 

133 

134 

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] 

145 

146 

147class RegisteredPackagerProvidedFile(metaclass=ABCMeta): 

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

149 

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

154 

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

167 

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 

177 

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. 

183 

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

185 how to derive it: 

186 

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

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

189 

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. 

193 

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. 

197 

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 

208 

209 

210class RegisteredMetadata: 

211 __slots__ = () 

212 

213 @property 

214 def substvars(self) -> Substvars: 

215 """Returns the Substvars 

216 

217 :return: The substvars in their current state. 

218 """ 

219 raise NotImplementedError 

220 

221 @property 

222 def triggers(self) -> List[RegisteredTrigger]: 

223 raise NotImplementedError 

224 

225 def maintscripts( 

226 self, 

227 *, 

228 maintscript: Optional[Maintscript] = None, 

229 ) -> List[RegisteredMaintscript]: 

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

231 

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 

240 

241 

242class InitializedPluginUnderTest: 

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

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

245 

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

247 """ 

248 return self.packager_provided_files_by_stem().values() 

249 

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 

255 

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 

263 

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 

271 

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 

279 

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

281 

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 

288 

289 @property 

290 def declared_manifest_variables(self) -> Union[Set[str], FrozenSet[str]]: 

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

292 

293 :return: All manifest variables declared by the plugin 

294 """ 

295 raise NotImplementedError 

296 

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

298 """Validate examples of the automatic discard rules 

299 

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

301 the failed example in full. 

302 

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 

307 

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 

317 

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

319 for a given service manager. 

320 

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. 

323 

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 

335 

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 

343 

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. 

347 

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. 

350 

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