Coverage for src/debputy/plugin/api/spec.py: 86%

361 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-09-07 09:27 +0000

1import contextlib 

2import dataclasses 

3import os 

4import tempfile 

5import textwrap 

6from typing import ( 

7 Iterable, 

8 Optional, 

9 Callable, 

10 Literal, 

11 Union, 

12 Iterator, 

13 overload, 

14 FrozenSet, 

15 Sequence, 

16 TypeVar, 

17 Any, 

18 TYPE_CHECKING, 

19 TextIO, 

20 BinaryIO, 

21 Generic, 

22 ContextManager, 

23 List, 

24 Type, 

25 Tuple, 

26 get_args, 

27 Container, 

28 final, 

29 cast, 

30) 

31 

32from debian.substvars import Substvars 

33 

34from debputy import util 

35from debputy.exceptions import ( 

36 TestPathWithNonExistentFSPathError, 

37 PureVirtualPathError, 

38 PluginInitializationError, 

39) 

40from debputy.interpreter import Interpreter, extract_shebang_interpreter_from_file 

41from debputy.manifest_parser.tagging_types import DebputyDispatchableType 

42from debputy.manifest_parser.util import parse_symbolic_mode 

43from debputy.packages import BinaryPackage 

44from debputy.types import S 

45 

46if TYPE_CHECKING: 

47 from debputy.manifest_parser.base_types import ( 

48 StaticFileSystemOwner, 

49 StaticFileSystemGroup, 

50 ) 

51 from debputy.plugin.api.doc_parsing import ParserRefDocumentation 

52 from debputy.plugin.api.impl_types import DIPHandler 

53 from debputy.plugins.debputy.to_be_api_types import BuildSystemRule 

54 from debputy.plugin.api.impl import DebputyPluginInitializerProvider 

55 

56 # Work around it being "unused" since `cast("...", ...)` does not register as a use 

57 # for most static analysis tools. 

58 assert DebputyPluginInitializerProvider 

59 

60 

61DP = TypeVar("DP", bound=DebputyDispatchableType) 

62 

63 

64PluginInitializationEntryPoint = Callable[["DebputyPluginInitializer"], None] 

65MetadataAutoDetector = Callable[ 

66 ["VirtualPath", "BinaryCtrlAccessor", "PackageProcessingContext"], None 

67] 

68PackageProcessor = Callable[["VirtualPath", None, "PackageProcessingContext"], None] 

69DpkgTriggerType = Literal[ 

70 "activate", 

71 "activate-await", 

72 "activate-noawait", 

73 "interest", 

74 "interest-await", 

75 "interest-noawait", 

76] 

77Maintscript = Literal["postinst", "preinst", "prerm", "postrm"] 

78PackageTypeSelector = Union[Literal["deb", "udeb"], Iterable[Literal["deb", "udeb"]]] 

79ServiceUpgradeRule = Literal[ 

80 "do-nothing", 

81 "reload", 

82 "restart", 

83 "stop-then-start", 

84] 

85 

86DSD = TypeVar("DSD") 

87ServiceDetector = Callable[ 

88 ["VirtualPath", "ServiceRegistry[DSD]", "PackageProcessingContext"], 

89 None, 

90] 

91ServiceIntegrator = Callable[ 

92 [ 

93 Sequence["ServiceDefinition[DSD]"], 

94 "BinaryCtrlAccessor", 

95 "PackageProcessingContext", 

96 ], 

97 None, 

98] 

99 

100PMT = TypeVar("PMT") 

101DebputyIntegrationMode = Literal[ 

102 "full", 

103 "dh-sequence-zz-debputy", 

104 "dh-sequence-zz-debputy-rrr", 

105] 

106 

107INTEGRATION_MODE_FULL: DebputyIntegrationMode = "full" 

108INTEGRATION_MODE_DH_DEBPUTY_RRR: DebputyIntegrationMode = "dh-sequence-zz-debputy-rrr" 

109INTEGRATION_MODE_DH_DEBPUTY: DebputyIntegrationMode = "dh-sequence-zz-debputy" 

110ALL_DEBPUTY_INTEGRATION_MODES: FrozenSet[DebputyIntegrationMode] = frozenset( 

111 get_args(DebputyIntegrationMode) 

112) 

113 

114_DEBPUTY_DISPATCH_METADATA_ATTR_NAME = "_debputy_dispatch_metadata" 

115 

116 

117def only_integrations( 

118 *integrations: DebputyIntegrationMode, 

119) -> Container[DebputyIntegrationMode]: 

120 return frozenset(integrations) 

121 

122 

123def not_integrations( 

124 *integrations: DebputyIntegrationMode, 

125) -> Container[DebputyIntegrationMode]: 

126 return ALL_DEBPUTY_INTEGRATION_MODES - frozenset(integrations) 

127 

128 

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

130class PackagerProvidedFileReferenceDocumentation: 

131 description: Optional[str] = None 

132 format_documentation_uris: Sequence[str] = tuple() 

133 

134 def replace(self, **changes: Any) -> "PackagerProvidedFileReferenceDocumentation": 

135 return dataclasses.replace(self, **changes) 

136 

137 

138def packager_provided_file_reference_documentation( 

139 *, 

140 description: Optional[str] = None, 

141 format_documentation_uris: Optional[Sequence[str]] = tuple(), 

142) -> PackagerProvidedFileReferenceDocumentation: 

143 """Provide documentation for a given packager provided file. 

144 

145 :param description: Textual description presented to the user. 

146 :param format_documentation_uris: A sequence of URIs to documentation that describes 

147 the format of the file. Most relevant first. 

148 :return: 

149 """ 

150 uris = tuple(format_documentation_uris) if format_documentation_uris else tuple() 

151 return PackagerProvidedFileReferenceDocumentation( 

152 description=description, 

153 format_documentation_uris=uris, 

154 ) 

155 

156 

157class PathMetadataReference(Generic[PMT]): 

158 """An accessor to plugin provided metadata 

159 

160 This is a *short-lived* reference to a piece of metadata. It should *not* be stored beyond 

161 the boundaries of the current plugin execution context as it can be become invalid (as an 

162 example, if the path associated with this path is removed, then this reference become invalid) 

163 """ 

164 

165 @property 

166 def is_present(self) -> bool: 

167 """Determine whether the value has been set 

168 

169 If the current plugin cannot access the value, then this method unconditionally returns 

170 `False` regardless of whether the value is there. 

171 

172 :return: `True` if the value has been set to a not None value (and not been deleted). 

173 Otherwise, this property is `False`. 

174 """ 

175 raise NotImplementedError 

176 

177 @property 

178 def can_read(self) -> bool: 

179 """Test whether it is possible to read the metadata 

180 

181 Note: That the metadata being readable does *not* imply that the metadata is present. 

182 

183 :return: True if it is possible to read the metadata. This is always True for the 

184 owning plugin. 

185 """ 

186 raise NotImplementedError 

187 

188 @property 

189 def can_write(self) -> bool: 

190 """Test whether it is possible to update the metadata 

191 

192 :return: True if it is possible to update the metadata. 

193 """ 

194 raise NotImplementedError 

195 

196 @property 

197 def value(self) -> Optional[PMT]: 

198 """Fetch the currently stored value if present. 

199 

200 :return: The value previously stored if any. Returns `None` if the value was never 

201 stored, explicitly set to `None` or was deleted. 

202 """ 

203 raise NotImplementedError 

204 

205 @value.setter 

206 def value(self, value: Optional[PMT]) -> None: 

207 """Replace any current value with the provided value 

208 

209 This operation is only possible if the path is writable *and* the caller is from 

210 the owning plugin OR the owning plugin made the reference read-write. 

211 """ 

212 raise NotImplementedError 

213 

214 @value.deleter 

215 def value(self) -> None: 

216 """Delete any current value. 

217 

218 This has the same effect as setting the value to `None`. It has the same restrictions 

219 as the value setter. 

220 """ 

221 self.value = None 

222 

223 

224@dataclasses.dataclass(slots=True) 

225class PathDef: 

226 path_name: str 

227 mode: Optional[int] = None 

228 mtime: Optional[int] = None 

229 has_fs_path: Optional[bool] = None 

230 fs_path: Optional[str] = None 

231 link_target: Optional[str] = None 

232 content: Optional[str] = None 

233 materialized_content: Optional[str] = None 

234 

235 

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

237class DispatchablePluggableManifestRuleMetadata(Generic[DP]): 

238 """NOT PUBLIC API (used internally by part of the public API)""" 

239 

240 manifest_keywords: Sequence[str] 

241 dispatched_type: Type[DP] 

242 unwrapped_constructor: "DIPHandler" 

243 expected_debputy_integration_mode: Optional[Container[DebputyIntegrationMode]] = ( 

244 None 

245 ) 

246 online_reference_documentation: Optional["ParserDocumentation"] = None 

247 apply_standard_attribute_documentation: bool = False 

248 source_format: Optional[Any] = None 

249 

250 

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

252class BuildSystemManifestRuleMetadata(DispatchablePluggableManifestRuleMetadata): 

253 build_system_impl: Optional[Type["BuildSystemRule"]] = None 

254 auto_detection_shadow_build_systems: FrozenSet[str] = frozenset() 

255 

256 

257def virtual_path_def( 

258 path_name: str, 

259 /, 

260 mode: Optional[int] = None, 

261 mtime: Optional[int] = None, 

262 fs_path: Optional[str] = None, 

263 link_target: Optional[str] = None, 

264 content: Optional[str] = None, 

265 materialized_content: Optional[str] = None, 

266) -> PathDef: 

267 """Define a virtual path for use with examples or, in tests, `build_virtual_file_system` 

268 

269 :param path_name: The full path. Must start with "./". If it ends with "/", the path will be interpreted 

270 as a directory (the `is_dir` attribute will be True). Otherwise, it will be a symlink or file depending 

271 on whether a `link_target` is provided. 

272 :param mode: The mode to use for this path. Defaults to 0644 for files and 0755 for directories. The mode 

273 should be None for symlinks. 

274 :param mtime: Define the last modified time for this path. If not provided, debputy will provide a default 

275 if the mtime attribute is accessed. 

276 :param fs_path: Define a file system path for this path. This causes `has_fs_path` to return True and the 

277 `fs_path` attribute will return this value. The test is required to make this path available to the extent 

278 required. Note that the virtual file system will *not* examine the provided path in any way nor attempt 

279 to resolve defaults from the path. 

280 :param link_target: A target for the symlink. Providing a not None value for this parameter will make the 

281 path a symlink. 

282 :param content: The content of the path (if opened). The path must be a file. 

283 :param materialized_content: Same as `content` except `debputy` will put the contents into a physical file 

284 as needed. Cannot be used with `content` or `fs_path`. 

285 :return: An *opaque* object to be passed to `build_virtual_file_system`. While the exact type is provided 

286 to aid with typing, the type name and its behavior is not part of the API. 

287 """ 

288 

289 is_dir = path_name.endswith("/") 

290 is_symlink = link_target is not None 

291 

292 if is_symlink: 

293 if mode is not None: 

294 raise ValueError( 

295 f'Please do not provide mode for symlinks. Triggered by "{path_name}"' 

296 ) 

297 if is_dir: 

298 raise ValueError( 

299 "Path name looks like a directory, but a symlink target was also provided." 

300 f' Please remove the trailing slash OR the symlink_target. Triggered by "{path_name}"' 

301 ) 

302 

303 if content and (is_dir or is_symlink): 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true

304 raise ValueError( 

305 "Content was defined however, the path appears to be a directory a or a symlink" 

306 f' Please remove the content, the trailing slash OR the symlink_target. Triggered by "{path_name}"' 

307 ) 

308 

309 if materialized_content is not None: 

310 if content is not None: 310 ↛ 311line 310 didn't jump to line 311 because the condition on line 310 was never true

311 raise ValueError( 

312 "The materialized_content keyword is mutually exclusive with the content keyword." 

313 f' Triggered by "{path_name}"' 

314 ) 

315 if fs_path is not None: 315 ↛ 316line 315 didn't jump to line 316 because the condition on line 315 was never true

316 raise ValueError( 

317 "The materialized_content keyword is mutually exclusive with the fs_path keyword." 

318 f' Triggered by "{path_name}"' 

319 ) 

320 return PathDef( 

321 path_name, 

322 mode=mode, 

323 mtime=mtime, 

324 has_fs_path=bool(fs_path) or materialized_content is not None, 

325 fs_path=fs_path, 

326 link_target=link_target, 

327 content=content, 

328 materialized_content=materialized_content, 

329 ) 

330 

331 

332class PackageProcessingContext: 

333 """Context for auto-detectors of metadata and package processors (no instantiation) 

334 

335 This object holds some context related data for the metadata detector or/and package 

336 processors. It may receive new attributes in the future. 

337 """ 

338 

339 __slots__ = () 

340 

341 @property 

342 def binary_package(self) -> BinaryPackage: 

343 """The binary package stanza from `debian/control`""" 

344 raise NotImplementedError 

345 

346 @property 

347 def binary_package_version(self) -> str: 

348 """The version of the binary package 

349 

350 Note this never includes the binNMU version for arch:all packages, but it may for arch:any. 

351 """ 

352 raise NotImplementedError 

353 

354 @property 

355 def related_udeb_package(self) -> Optional[BinaryPackage]: 

356 """An udeb related to this binary package (if any)""" 

357 raise NotImplementedError 

358 

359 @property 

360 def related_udeb_package_version(self) -> Optional[str]: 

361 """The version of the related udeb package (if present) 

362 

363 Note this never includes the binNMU version for arch:all packages, but it may for arch:any. 

364 """ 

365 raise NotImplementedError 

366 

367 def accessible_package_roots(self) -> Iterable[Tuple[BinaryPackage, "VirtualPath"]]: 

368 raise NotImplementedError 

369 

370 # """The source package stanza from `debian/control`""" 

371 # source_package: SourcePackage 

372 

373 

374class DebputyPluginDefinition: 

375 """Plugin definition entity 

376 

377 The plugin definition provides a way for the plugin code to register the features it provides via 

378 accessors. 

379 """ 

380 

381 __slots__ = ("_generic_initializers",) 

382 

383 def __init__(self) -> None: 

384 self._generic_initializers: List[ 

385 Callable[["DebputyPluginInitializerProvider"], None] 

386 ] = [] 

387 

388 @staticmethod 

389 def _name2id(provided_id: Optional[str], name: str) -> str: 

390 if provided_id is not None: 390 ↛ 391line 390 didn't jump to line 391 because the condition on line 390 was never true

391 return provided_id 

392 if name.endswith("_"): 392 ↛ 393line 392 didn't jump to line 393 because the condition on line 392 was never true

393 name = name[:-1] 

394 return name.replace("_", "-") 

395 

396 def metadata_or_maintscript_detector( 

397 self, 

398 func: Optional[MetadataAutoDetector] = None, 

399 *, 

400 detector_id: Optional[str] = None, 

401 package_type: PackageTypeSelector = "deb", 

402 ) -> Union[ 

403 Callable[[MetadataAutoDetector], MetadataAutoDetector], 

404 MetadataAutoDetector, 

405 ]: 

406 """Provide a pre-assembly hook that can affect the metadata/maintscript of binary ("deb") packages 

407 

408 The provided hook will be run once per binary package to be assembled, and it can see all the content 

409 ("data.tar") planned to be included in the deb. The hook may do any *read-only* analysis of this content 

410 and provide metadata, alter substvars or inject maintscript snippets. However, the hook must *not* 

411 change the content ("data.tar") part of the deb. 

412 

413 The hook will be run unconditionally for all binary packages built. When the hook does not apply to all 

414 packages, it must provide its own (internal) logic for detecting whether it is relevant and reduce itself 

415 to a no-op if it should not apply to the current package. 

416 

417 Hooks are run in "some implementation defined order" and should not rely on being run before or after 

418 any other hook. 

419 

420 The hooks are only applied to packages defined in `debian/control`. Notably, the metadata detector will 

421 not apply to auto-generated `-dbgsym` packages (as those are not listed explicitly in `debian/control`). 

422 

423 

424 >>> plugin_definition = define_debputy_plugin() 

425 >>> @plugin_definition.metadata_or_maintscript_detector 

426 ... def gsettings_dependencies( 

427 ... fs_root: "VirtualPath", 

428 ... ctrl: "BinaryCtrlAccessor", 

429 ... context: "PackageProcessingContext", 

430 ... ) -> None: 

431 ... gsettings_schema_dir = fs_root.lookup("/usr/share/glib-2.0/schemas") 

432 ... if gsettings_schema_dir is None: 

433 ... return 

434 ... for path in gsettings_schema_dir.all_paths(): 

435 ... if path.is_file and path.name.endswith((".xml", ".override")): 

436 ... ctrl.substvars.add_dependency( 

437 ... "misc:Depends", 

438 ... "dconf-gsettings-backend | gsettings-backend", 

439 ... ) 

440 ... break 

441 

442 :param func: The function to be decorated/registered. 

443 :param detector_id: A plugin-wide unique ID for this detector. Packagers may use this ID for disabling 

444 the detector and accordingly the ID is part of the plugin's API toward the packager. 

445 :param package_type: Which kind of packages this metadata detector applies to. The package type is generally 

446 defined by `Package-Type` field in the binary package. The default is to only run for regular `deb` packages 

447 and ignore `udeb` packages. 

448 """ 

449 

450 def _decorate(f: MetadataAutoDetector) -> MetadataAutoDetector: 

451 

452 final_id = self._name2id(detector_id, f.__name__) 

453 

454 def _init(api: "DebputyPluginInitializer") -> None: 

455 api.metadata_or_maintscript_detector( 

456 final_id, 

457 f, 

458 package_type=package_type, 

459 ) 

460 

461 self._generic_initializers.append(_init) 

462 

463 return f 

464 

465 if func: 

466 return _decorate(func) 

467 return _decorate 

468 

469 def manifest_variable( 

470 self, 

471 variable_name: str, 

472 value: str, 

473 *, 

474 variable_reference_documentation: Optional[str] = None, 

475 ) -> None: 

476 """Provide a variable that can be used in the package manifest 

477 

478 >>> plugin_definition = define_debputy_plugin() 

479 >>> # Enable users to use "{{path:BASH_COMPLETION_DIR}}/foo" in their manifest. 

480 >>> plugin_definition.manifest_variable( 

481 ... "path:BASH_COMPLETION_DIR", 

482 ... "/usr/share/bash-completion/completions", 

483 ... variable_reference_documentation="Directory to install bash completions into", 

484 ... ) 

485 

486 :param variable_name: The variable name. It should be provided without the substution prefix/suffix 

487 :param value: The constant value that the variable should resolve to. It is not possible to provide 

488 dynamic / context based values at this time. 

489 :param variable_reference_documentation: A short snippet of reference documentation that explains 

490 the purpose of the variable. This will be shown to users in various contexts such as the hover 

491 docs. 

492 """ 

493 

494 def _init(api: "DebputyPluginInitializer") -> None: 

495 api.manifest_variable( 

496 variable_name, 

497 value, 

498 variable_reference_documentation=variable_reference_documentation, 

499 ) 

500 

501 self._generic_initializers.append(_init) 

502 

503 def packager_provided_file( 

504 self, 

505 stem: str, 

506 installed_path: str, 

507 *, 

508 default_mode: int = 0o0644, 

509 default_priority: Optional[int] = None, 

510 allow_name_segment: bool = True, 

511 allow_architecture_segment: bool = False, 

512 post_formatting_rewrite: Optional[Callable[[str], str]] = None, 

513 packageless_is_fallback_for_all_packages: bool = False, 

514 reservation_only: bool = False, 

515 reference_documentation: Optional[ 

516 PackagerProvidedFileReferenceDocumentation 

517 ] = None, 

518 ) -> None: 

519 """Register a packager provided file (debian/<pkg>.foo) 

520 

521 Register a packager provided file that debputy should automatically detect and install for the 

522 packager (example `debian/foo.tmpfiles` -> `debian/foo/usr/lib/tmpfiles.d/foo.conf`). A packager 

523 provided file typically identified by a package prefix and a "stem" and by convention placed 

524 in the `debian/` directory. 

525 

526 Where possible, please define these packager provided files via the JSON metadata rather than 

527 via Python code. 

528 

529 Like debhelper, debputy also supports the `foo.bar.tmpfiles` variant where the file is to be 

530 installed into the `foo` package but be named after the `bar` segment rather than the package name. 

531 This feature can be controlled via the `allow_name_segment` parameter. 

532 

533 :param stem: The "stem" of the file. This would be the `tmpfiles` part of `debian/foo.tmpfiles`. 

534 Note that this value must be unique across all registered packager provided files. 

535 :param installed_path: A format string describing where the file should be installed. Would be 

536 `/usr/lib/tmpfiles.d/{name}.conf` from the example above. 

537 

538 The caller should provide a string with one or more of the placeholders listed below (usually `{name}` 

539 should be one of them). The format affect the entire path. 

540 

541 The following placeholders are supported: 

542 * `{name}` - The name in the name segment (defaulting the package name if no name segment is given) 

543 * `{priority}` / `{priority:02}` - The priority of the file. Only provided priorities are used (that 

544 is, default_priority is not None). The latter variant ensuring that the priority takes at least 

545 two characters and the `0` character is left-padded for priorities that takes less than two 

546 characters. 

547 * `{owning_package}` - The name of the package. Should only be used when `{name}` alone is insufficient. 

548 If you do not want the "name" segment in the first place, use `allow_name_segment=False` instead. 

549 

550 The path is always interpreted as relative to the binary package root. 

551 

552 :param default_mode: The mode the installed file should have by default. Common options are 0o0644 (the default) 

553 or 0o0755 (for files that must be executable). 

554 :param allow_architecture_segment: If True, the file may have an optional "architecture" segment at the end 

555 (`foo.tmpfiles.amd64`), which marks it architecture specific. When False, debputy will detect the 

556 "architecture" segment and report the use as an error. Note the architecture segment is only allowed for 

557 arch:any packages. If a file targeting an arch:all package uses an architecture specific file it will 

558 always result in an error. 

559 :param allow_name_segment: If True, the file may have an optional "name" segment after the package name prefix. 

560 (`foo.<name-here>.tmpfiles`). When False, debputy will detect the "name" segment and report the use as an 

561 error. 

562 

563 Note that when this parameter is True, then a package can provide multiple instances with this stem. When 

564 False, then at most one file can be provided per package. 

565 :param default_priority: Special-case option for packager files that are installed into directories that have 

566 "parse ordering" or "priority". These files will generally be installed as something like `20-foo.conf` 

567 where the `20-` denotes their "priority". If the plugin is registering such a file type, then it should 

568 provide a default priority. 

569 

570 The following placeholders are supported: 

571 * `{name}` - The name in the name segment (defaulting the package name if no name segment is given) 

572 * `{priority}` - The priority of the file. Only provided priorities are used (that is, default_priority 

573 is not None) 

574 * `{owning_package}` - The name of the package. Should only be used when `{name}` alone is insufficient. 

575 If you do not want the "name" segment in the first place, use `allow_name_segment=False` instead. 

576 :param post_formatting_rewrite: An optional "name correcting" callback. It receives the formatted name and can 

577 do any transformation required. The primary use-case for this is to replace "forbidden" characters. The most 

578 common case for debputy itself is to replace "." with "_" for tools that refuse to work with files containing 

579 "." (`lambda x: x.replace(".", "_")`). The callback operates on basename of formatted version of the 

580 `installed_path` and the callback should return the basename. 

581 :param packageless_is_fallback_for_all_packages: If True, the packageless variant (such as, `debian/changelog`) 

582 is a fallback for every package. 

583 :param reference_documentation: Reference documentation for the packager provided file. Use the 

584 packager_provided_file_reference_documentation function to provide the value for this parameter. 

585 :param reservation_only: When True, tell debputy that the plugin reserves this packager provided file, but that 

586 debputy should not actually install it automatically. This is useful in the cases, where the plugin 

587 needs to process the file before installing it. The file will be marked as provided by this plugin. This 

588 enables introspection and detects conflicts if other plugins attempts to claim the file. 

589 """ 

590 

591 def _init(api: "DebputyPluginInitializer") -> None: 

592 api.packager_provided_file( 

593 stem, 

594 installed_path, 

595 default_mode=default_mode, 

596 default_priority=default_priority, 

597 allow_name_segment=allow_name_segment, 

598 allow_architecture_segment=allow_architecture_segment, 

599 post_formatting_rewrite=post_formatting_rewrite, 

600 packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages, 

601 reservation_only=reservation_only, 

602 reference_documentation=reference_documentation, 

603 ) 

604 

605 self._generic_initializers.append(_init) 

606 

607 def initialize(self, api: "DebputyPluginInitializer") -> None: 

608 """Initialize the plugin from this definition 

609 

610 Most plugins will not need this function as the plugin loading will call this function 

611 when relevant. However, a plugin can call this manually from a function-based initializer. 

612 This is mostly useful if the function-based initializer need to set up a few features 

613 that the `DebputyPluginDefinition` cannot do and the mix/match approach is not too 

614 distracting for plugin maintenance. 

615 

616 :param api: The plugin initializer provided by the `debputy` plugin system 

617 """ 

618 

619 api_impl = cast( 

620 "DebputyPluginInitializerProvider", 

621 api, 

622 ) 

623 initializers = self._generic_initializers 

624 if not initializers: 

625 plugin_name = api_impl.plugin_metadata.plugin_name 

626 raise PluginInitializationError( 

627 f"Initialization of {plugin_name}: The plugin definition was never used to register any features." 

628 " If you want to conditionally register features, please use an initializer functon instead." 

629 ) 

630 

631 for initializer in initializers: 

632 initializer(api_impl) 

633 

634 

635def define_debputy_plugin() -> DebputyPluginDefinition: 

636 return DebputyPluginDefinition() 

637 

638 

639class DebputyPluginInitializer: 

640 __slots__ = () 

641 

642 def packager_provided_file( 

643 self, 

644 stem: str, 

645 installed_path: str, 

646 *, 

647 default_mode: int = 0o0644, 

648 default_priority: Optional[int] = None, 

649 allow_name_segment: bool = True, 

650 allow_architecture_segment: bool = False, 

651 post_formatting_rewrite: Optional[Callable[[str], str]] = None, 

652 packageless_is_fallback_for_all_packages: bool = False, 

653 reservation_only: bool = False, 

654 reference_documentation: Optional[ 

655 PackagerProvidedFileReferenceDocumentation 

656 ] = None, 

657 ) -> None: 

658 """Register a packager provided file (debian/<pkg>.foo) 

659 

660 Register a packager provided file that debputy should automatically detect and install for the 

661 packager (example `debian/foo.tmpfiles` -> `debian/foo/usr/lib/tmpfiles.d/foo.conf`). A packager 

662 provided file typically identified by a package prefix and a "stem" and by convention placed 

663 in the `debian/` directory. 

664 

665 Like debhelper, debputy also supports the `foo.bar.tmpfiles` variant where the file is to be 

666 installed into the `foo` package but be named after the `bar` segment rather than the package name. 

667 This feature can be controlled via the `allow_name_segment` parameter. 

668 

669 :param stem: The "stem" of the file. This would be the `tmpfiles` part of `debian/foo.tmpfiles`. 

670 Note that this value must be unique across all registered packager provided files. 

671 :param installed_path: A format string describing where the file should be installed. Would be 

672 `/usr/lib/tmpfiles.d/{name}.conf` from the example above. 

673 

674 The caller should provide a string with one or more of the placeholders listed below (usually `{name}` 

675 should be one of them). The format affect the entire path. 

676 

677 The following placeholders are supported: 

678 * `{name}` - The name in the name segment (defaulting the package name if no name segment is given) 

679 * `{priority}` / `{priority:02}` - The priority of the file. Only provided priorities are used (that 

680 is, default_priority is not None). The latter variant ensuring that the priority takes at least 

681 two characters and the `0` character is left-padded for priorities that takes less than two 

682 characters. 

683 * `{owning_package}` - The name of the package. Should only be used when `{name}` alone is insufficient. 

684 If you do not want the "name" segment in the first place, use `allow_name_segment=False` instead. 

685 

686 The path is always interpreted as relative to the binary package root. 

687 

688 :param default_mode: The mode the installed file should have by default. Common options are 0o0644 (the default) 

689 or 0o0755 (for files that must be executable). 

690 :param allow_architecture_segment: If True, the file may have an optional "architecture" segment at the end 

691 (`foo.tmpfiles.amd64`), which marks it architecture specific. When False, debputy will detect the 

692 "architecture" segment and report the use as an error. Note the architecture segment is only allowed for 

693 arch:any packages. If a file targeting an arch:all package uses an architecture specific file it will 

694 always result in an error. 

695 :param allow_name_segment: If True, the file may have an optional "name" segment after the package name prefix. 

696 (`foo.<name-here>.tmpfiles`). When False, debputy will detect the "name" segment and report the use as an 

697 error. 

698 :param default_priority: Special-case option for packager files that are installed into directories that have 

699 "parse ordering" or "priority". These files will generally be installed as something like `20-foo.conf` 

700 where the `20-` denotes their "priority". If the plugin is registering such a file type, then it should 

701 provide a default priority. 

702 

703 The following placeholders are supported: 

704 * `{name}` - The name in the name segment (defaulting the package name if no name segment is given) 

705 * `{priority}` - The priority of the file. Only provided priorities are used (that is, default_priority 

706 is not None) 

707 * `{owning_package}` - The name of the package. Should only be used when `{name}` alone is insufficient. 

708 If you do not want the "name" segment in the first place, use `allow_name_segment=False` instead. 

709 :param post_formatting_rewrite: An optional "name correcting" callback. It receives the formatted name and can 

710 do any transformation required. The primary use-case for this is to replace "forbidden" characters. The most 

711 common case for debputy itself is to replace "." with "_" for tools that refuse to work with files containing 

712 "." (`lambda x: x.replace(".", "_")`). The callback operates on basename of formatted version of the 

713 `installed_path` and the callback should return the basename. 

714 :param packageless_is_fallback_for_all_packages: If True, the packageless variant (such as, `debian/changelog`) 

715 is a fallback for every package. 

716 :param reference_documentation: Reference documentation for the packager provided file. Use the 

717 packager_provided_file_reference_documentation function to provide the value for this parameter. 

718 :param reservation_only: When True, tell debputy that the plugin reserves this packager provided file, but that 

719 debputy should not actually install it automatically. This is useful in the cases, where the plugin 

720 needs to process the file before installing it. The file will be marked as provided by this plugin. This 

721 enables introspection and detects conflicts if other plugins attempts to claim the file. 

722 """ 

723 raise NotImplementedError 

724 

725 def metadata_or_maintscript_detector( 

726 self, 

727 auto_detector_id: str, 

728 auto_detector: MetadataAutoDetector, 

729 *, 

730 package_type: PackageTypeSelector = "deb", 

731 ) -> None: 

732 """Provide a pre-assembly hook that can affect the metadata/maintscript of binary ("deb") packages 

733 

734 The provided hook will be run once per binary package to be assembled, and it can see all the content 

735 ("data.tar") planned to be included in the deb. The hook may do any *read-only* analysis of this content 

736 and provide metadata, alter substvars or inject maintscript snippets. However, the hook must *not* 

737 change the content ("data.tar") part of the deb. 

738 

739 The hook will be run unconditionally for all binary packages built. When the hook does not apply to all 

740 packages, it must provide its own (internal) logic for detecting whether it is relevant and reduce itself 

741 to a no-op if it should not apply to the current package. 

742 

743 Hooks are run in "some implementation defined order" and should not rely on being run before or after 

744 any other hook. 

745 

746 The hooks are only applied to packages defined in `debian/control`. Notably, the metadata detector will 

747 not apply to auto-generated `-dbgsym` packages (as those are not listed explicitly in `debian/control`). 

748 

749 :param auto_detector_id: A plugin-wide unique ID for this detector. Packagers may use this ID for disabling 

750 the detector and accordingly the ID is part of the plugin's API toward the packager. 

751 :param auto_detector: The code to be called that will be run at the metadata generation state (once for each 

752 binary package). 

753 :param package_type: Which kind of packages this metadata detector applies to. The package type is generally 

754 defined by `Package-Type` field in the binary package. The default is to only run for regular `deb` packages 

755 and ignore `udeb` packages. 

756 """ 

757 raise NotImplementedError 

758 

759 def manifest_variable( 

760 self, 

761 variable_name: str, 

762 value: str, 

763 *, 

764 variable_reference_documentation: Optional[str] = None, 

765 ) -> None: 

766 """Provide a variable that can be used in the package manifest 

767 

768 >>> # Enable users to use "{{path:BASH_COMPLETION_DIR}}/foo" in their manifest. 

769 >>> api.manifest_variable( # doctest: +SKIP 

770 ... "path:BASH_COMPLETION_DIR", 

771 ... "/usr/share/bash-completion/completions", 

772 ... variable_reference_documentation="Directory to install bash completions into", 

773 ... ) 

774 

775 :param variable_name: The variable name. 

776 :param value: The value the variable should resolve to. 

777 :param variable_reference_documentation: A short snippet of reference documentation that explains 

778 the purpose of the variable. 

779 """ 

780 raise NotImplementedError 

781 

782 

783class MaintscriptAccessor: 

784 __slots__ = () 

785 

786 def on_configure( 

787 self, 

788 run_snippet: str, 

789 /, 

790 indent: Optional[bool] = None, 

791 perform_substitution: bool = True, 

792 skip_on_rollback: bool = False, 

793 ) -> None: 

794 """Provide a snippet to be run when the package is about to be "configured" 

795 

796 This condition is the most common "post install" condition and covers the two 

797 common cases: 

798 * On initial install, OR 

799 * On upgrade 

800 

801 In dpkg maintscript terms, this method roughly corresponds to postinst containing 

802 `if [ "$1" = configure ]; then <snippet>; fi` 

803 

804 Additionally, the condition will by default also include rollback/abort scenarios such as "above-remove", 

805 which is normally what you want but most people forget about. 

806 

807 :param run_snippet: The actual shell snippet to be run in the given condition. The snippet must be idempotent. 

808 The snippet may contain newlines as necessary, which will make the result more readable. Additionally, the 

809 snippet may contain '{{FOO}}' substitutions by default. 

810 :param skip_on_rollback: By default, this condition will also cover common rollback scenarios. This 

811 is normally what you want (or benign in most cases due to the idempotence requirement for maintscripts). 

812 However, you can disable the rollback cases, leaving only "On initial install OR On upgrade". 

813 :param indent: If True, the provided snippet will be indented to fit the condition provided by debputy. 

814 In most cases, this is safe to do and provides more readable scripts. However, it may cause issues 

815 with some special shell syntax (such as "Heredocs"). When False, the snippet will *not* be re-indented. 

816 You are recommended to do 4 spaces of indentation when indent is False for readability. 

817 :param perform_substitution: When True, `{{FOO}}` will be substituted in the snippet. When False, no 

818 substitution is provided. 

819 """ 

820 raise NotImplementedError 

821 

822 def on_initial_install( 

823 self, 

824 run_snippet: str, 

825 /, 

826 indent: Optional[bool] = None, 

827 perform_substitution: bool = True, 

828 ) -> None: 

829 """Provide a snippet to be run when the package is about to be "configured" for the first time 

830 

831 The snippet will only be run on the first time the package is installed (ever or since last purge). 

832 Note that "first" does not mean "exactly once" as dpkg does *not* provide such semantics. There are two 

833 common cases where this can snippet can be run multiple times for the same system (and why the snippet 

834 must still be idempotent): 

835 

836 1) The package is installed (1), then purged and then installed again (2). This can partly be mitigated 

837 by having an `on_purge` script to do clean up. 

838 

839 2) As the package is installed, the `postinst` script terminates prematurely (Disk full, power loss, etc.). 

840 The user resolves the problem and runs `dpkg --configure <pkg>`, which in turn restarts the script 

841 from the beginning. This is why scripts must be idempotent in general. 

842 

843 In dpkg maintscript terms, this method roughly corresponds to postinst containing 

844 `if [ "$1" = configure ] && [ -z "$2" ]; then <snippet>; fi` 

845 

846 :param run_snippet: The actual shell snippet to be run in the given condition. The snippet must be idempotent. 

847 The snippet may contain newlines as necessary, which will make the result more readable. Additionally, the 

848 snippet may contain '{{FOO}}' substitutions by default. 

849 :param indent: If True, the provided snippet will be indented to fit the condition provided by debputy. 

850 In most cases, this is safe to do and provides more readable scripts. However, it may cause issues 

851 with some special shell syntax (such as "Heredocs"). When False, the snippet will *not* be re-indented. 

852 You are recommended to do 4 spaces of indentation when indent is False for readability. 

853 :param perform_substitution: When True, `{{FOO}}` will be substituted in the snippet. When False, no 

854 substitution is provided. 

855 """ 

856 raise NotImplementedError 

857 

858 def on_upgrade( 

859 self, 

860 run_snippet: str, 

861 /, 

862 indent: Optional[bool] = None, 

863 perform_substitution: bool = True, 

864 ) -> None: 

865 """Provide a snippet to be run when the package is about to be "configured" after an upgrade 

866 

867 The snippet will only be run on any upgrade (that is, it will be skipped on the initial install). 

868 

869 In dpkg maintscript terms, this method roughly corresponds to postinst containing 

870 `if [ "$1" = configure ] && [ -n "$2" ]; then <snippet>; fi` 

871 

872 :param run_snippet: The actual shell snippet to be run in the given condition. The snippet must be idempotent. 

873 The snippet may contain newlines as necessary, which will make the result more readable. Additionally, the 

874 snippet may contain '{{FOO}}' substitutions by default. 

875 :param indent: If True, the provided snippet will be indented to fit the condition provided by debputy. 

876 In most cases, this is safe to do and provides more readable scripts. However, it may cause issues 

877 with some special shell syntax (such as "Heredocs"). When False, the snippet will *not* be re-indented. 

878 You are recommended to do 4 spaces of indentation when indent is False for readability. 

879 :param perform_substitution: When True, `{{FOO}}` will be substituted in the snippet. When False, no 

880 substitution is provided. 

881 """ 

882 raise NotImplementedError 

883 

884 def on_upgrade_from( 

885 self, 

886 version: str, 

887 run_snippet: str, 

888 /, 

889 indent: Optional[bool] = None, 

890 perform_substitution: bool = True, 

891 ) -> None: 

892 """Provide a snippet to be run when the package is about to be "configured" after an upgrade from a given version 

893 

894 The snippet will only be run on any upgrade (that is, it will be skipped on the initial install). 

895 

896 In dpkg maintscript terms, this method roughly corresponds to postinst containing 

897 `if [ "$1" = configure ] && dpkg --compare-versions le-nl "$2" ; then <snippet>; fi` 

898 

899 :param version: The version to upgrade from 

900 :param run_snippet: The actual shell snippet to be run in the given condition. The snippet must be idempotent. 

901 The snippet may contain newlines as necessary, which will make the result more readable. Additionally, the 

902 snippet may contain '{{FOO}}' substitutions by default. 

903 :param indent: If True, the provided snippet will be indented to fit the condition provided by debputy. 

904 In most cases, this is safe to do and provides more readable scripts. However, it may cause issues 

905 with some special shell syntax (such as "Heredocs"). When False, the snippet will *not* be re-indented. 

906 You are recommended to do 4 spaces of indentation when indent is False for readability. 

907 :param perform_substitution: When True, `{{FOO}}` will be substituted in the snippet. When False, no 

908 substitution is provided. 

909 """ 

910 raise NotImplementedError 

911 

912 def on_before_removal( 

913 self, 

914 run_snippet: str, 

915 /, 

916 indent: Optional[bool] = None, 

917 perform_substitution: bool = True, 

918 ) -> None: 

919 """Provide a snippet to be run when the package is about to be removed 

920 

921 The snippet will be run before dpkg removes any files. 

922 

923 In dpkg maintscript terms, this method roughly corresponds to prerm containing 

924 `if [ "$1" = remove ] ; then <snippet>; fi` 

925 

926 :param run_snippet: The actual shell snippet to be run in the given condition. The snippet must be idempotent. 

927 The snippet may contain newlines as necessary, which will make the result more readable. Additionally, the 

928 snippet may contain '{{FOO}}' substitutions by default. 

929 :param indent: If True, the provided snippet will be indented to fit the condition provided by debputy. 

930 In most cases, this is safe to do and provides more readable scripts. However, it may cause issues 

931 with some special shell syntax (such as "Heredocs"). When False, the snippet will *not* be re-indented. 

932 You are recommended to do 4 spaces of indentation when indent is False for readability. 

933 :param perform_substitution: When True, `{{FOO}}` will be substituted in the snippet. When False, no 

934 substitution is provided. 

935 """ 

936 raise NotImplementedError 

937 

938 def on_removed( 

939 self, 

940 run_snippet: str, 

941 /, 

942 indent: Optional[bool] = None, 

943 perform_substitution: bool = True, 

944 ) -> None: 

945 """Provide a snippet to be run when the package has been removed 

946 

947 The snippet will be run after dpkg removes the package content from the file system. 

948 

949 **WARNING**: The snippet *cannot* rely on dependencies and must rely on `Essential: yes` packages. 

950 

951 In dpkg maintscript terms, this method roughly corresponds to postrm containing 

952 `if [ "$1" = remove ] ; then <snippet>; fi` 

953 

954 :param run_snippet: The actual shell snippet to be run in the given condition. The snippet must be idempotent. 

955 The snippet may contain newlines as necessary, which will make the result more readable. Additionally, the 

956 snippet may contain '{{FOO}}' substitutions by default. 

957 :param indent: If True, the provided snippet will be indented to fit the condition provided by debputy. 

958 In most cases, this is safe to do and provides more readable scripts. However, it may cause issues 

959 with some special shell syntax (such as "Heredocs"). When False, the snippet will *not* be re-indented. 

960 You are recommended to do 4 spaces of indentation when indent is False for readability. 

961 :param perform_substitution: When True, `{{FOO}}` will be substituted in the snippet. When False, no 

962 substitution is provided. 

963 """ 

964 raise NotImplementedError 

965 

966 def on_purge( 

967 self, 

968 run_snippet: str, 

969 /, 

970 indent: Optional[bool] = None, 

971 perform_substitution: bool = True, 

972 ) -> None: 

973 """Provide a snippet to be run when the package is being purged. 

974 

975 The snippet will when the package is purged from the system. 

976 

977 **WARNING**: The snippet *cannot* rely on dependencies and must rely on `Essential: yes` packages. 

978 

979 In dpkg maintscript terms, this method roughly corresponds to postrm containing 

980 `if [ "$1" = purge ] ; then <snippet>; fi` 

981 

982 :param run_snippet: The actual shell snippet to be run in the given condition. The snippet must be idempotent. 

983 The snippet may contain newlines as necessary, which will make the result more readable. Additionally, the 

984 snippet may contain '{{FOO}}' substitutions by default. 

985 :param indent: If True, the provided snippet will be indented to fit the condition provided by debputy. 

986 In most cases, this is safe to do and provides more readable scripts. However, it may cause issues 

987 with some special shell syntax (such as "Heredocs"). When False, the snippet will *not* be re-indented. 

988 You are recommended to do 4 spaces of indentation when indent is False for readability. 

989 :param perform_substitution: When True, `{{FOO}}` will be substituted in the snippet. When False, no 

990 substitution is provided. 

991 """ 

992 raise NotImplementedError 

993 

994 def unconditionally_in_script( 

995 self, 

996 maintscript: Maintscript, 

997 run_snippet: str, 

998 /, 

999 perform_substitution: bool = True, 

1000 ) -> None: 

1001 """Provide a snippet to be run in a given script 

1002 

1003 Run a given snippet unconditionally from a given script. The snippet must contain its own conditional 

1004 for when it should be run. 

1005 

1006 :param maintscript: The maintscript to insert the snippet into. 

1007 :param run_snippet: The actual shell snippet to be run. The snippet will be run unconditionally and should 

1008 contain its own conditions as necessary. The snippet must be idempotent. The snippet may contain newlines 

1009 as necessary, which will make the result more readable. Additionally, the snippet may contain '{{FOO}}' 

1010 substitutions by default. 

1011 :param perform_substitution: When True, `{{FOO}}` will be substituted in the snippet. When False, no 

1012 substitution is provided. 

1013 """ 

1014 raise NotImplementedError 

1015 

1016 def escape_shell_words(self, *args: str) -> str: 

1017 """Provide sh-shell escape of strings 

1018 

1019 `assert escape_shell("foo", "fu bar", "baz") == 'foo "fu bar" baz'` 

1020 

1021 This is useful for ensuring file names and other "input" are considered one parameter even when they 

1022 contain spaces or shell meta-characters. 

1023 

1024 :param args: The string(s) to be escaped. 

1025 :return: Each argument escaped so that each argument becomes a single "word" and then all these words are 

1026 joined by a single space. 

1027 """ 

1028 return util.escape_shell(*args) 

1029 

1030 

1031class BinaryCtrlAccessor: 

1032 __slots__ = () 

1033 

1034 def dpkg_trigger(self, trigger_type: DpkgTriggerType, trigger_target: str) -> None: 

1035 """Register a declarative dpkg level trigger 

1036 

1037 The provided trigger will be added to the package's metadata (the triggers file of the control.tar). 

1038 

1039 If the trigger has already been added previously, a second call with the same trigger data will be ignored. 

1040 """ 

1041 raise NotImplementedError 

1042 

1043 @property 

1044 def maintscript(self) -> MaintscriptAccessor: 

1045 """Attribute for manipulating maintscripts""" 

1046 raise NotImplementedError 

1047 

1048 @property 

1049 def substvars(self) -> "FlushableSubstvars": 

1050 """Attribute for manipulating dpkg substvars (deb-substvars)""" 

1051 raise NotImplementedError 

1052 

1053 

1054class VirtualPath: 

1055 __slots__ = () 

1056 

1057 @property 

1058 def name(self) -> str: 

1059 """Basename of the path a.k.a. last segment of the path 

1060 

1061 In a path "usr/share/doc/pkg/changelog.gz" the basename is "changelog.gz". 

1062 

1063 For a directory, the basename *never* ends with a `/`. 

1064 """ 

1065 raise NotImplementedError 

1066 

1067 @property 

1068 def iterdir(self) -> Iterable["VirtualPath"]: 

1069 """Returns an iterable that iterates over all children of this path 

1070 

1071 For directories, this returns an iterable of all children. For non-directories, 

1072 the iterable is always empty. 

1073 """ 

1074 raise NotImplementedError 

1075 

1076 def lookup(self, path: str) -> Optional["VirtualPath"]: 

1077 """Perform a path lookup relative to this path 

1078 

1079 As an example `doc_dir = fs_root.lookup('./usr/share/doc')` 

1080 

1081 If the provided path starts with `/`, then the lookup is performed relative to the 

1082 file system root. That is, you can assume the following to always be True: 

1083 

1084 `fs_root.lookup("usr") == any_path_beneath_fs_root.lookup('/usr')` 

1085 

1086 Note: This method requires the path to be attached (see `is_detached`) regardless of 

1087 whether the lookup is relative or absolute. 

1088 

1089 If the path traverse a symlink, the symlink will be resolved. 

1090 

1091 :param path: The path to look. Can contain "." and ".." segments. If starting with `/`, 

1092 look up is performed relative to the file system root, otherwise the lookup is relative 

1093 to this path. 

1094 :return: The path object for the desired path if it can be found. Otherwise, None. 

1095 """ 

1096 raise NotImplementedError 

1097 

1098 def all_paths(self) -> Iterable["VirtualPath"]: 

1099 """Iterate over this path and all of its descendants (if any) 

1100 

1101 If used on the root path, then every path in the package is returned. 

1102 

1103 The iterable is ordered, so using the order in output will be produce 

1104 bit-for-bit reproducible output. Additionally, a directory will always 

1105 be seen before its descendants. Otherwise, the order is implementation 

1106 defined. 

1107 

1108 The iteration is lazy and as a side effect do account for some obvious 

1109 mutation. Like if the current path is removed, then none of its children 

1110 will be returned (provided mutation happens before the lazy iteration 

1111 was required to resolve it). Likewise, mutation of the directory will 

1112 also work (again, provided mutation happens before the lazy iteration order). 

1113 

1114 :return: An ordered iterable of this path followed by its descendants. 

1115 """ 

1116 raise NotImplementedError 

1117 

1118 @property 

1119 def is_detached(self) -> bool: 

1120 """Returns True if this path is detached 

1121 

1122 Paths that are detached from the file system will not be present in the package and 

1123 most operations are unsafe on them. This usually only happens if the path or one of 

1124 its parent directories are unlinked (rm'ed) from the file system tree. 

1125 

1126 All paths are attached by default and will only become detached as a result of 

1127 an action to mutate the virtual file system. Note that the file system may not 

1128 always be manipulated. 

1129 

1130 :return: True if the entry is detached. Detached entries should be discarded, so they 

1131 can be garbage collected. 

1132 """ 

1133 raise NotImplementedError 

1134 

1135 # The __getitem__ behaves like __getitem__ from Dict but __iter__ would ideally work like a Sequence. 

1136 # However, that does not feel compatible, so lets force people to use .children instead for the Sequence 

1137 # behavior to avoid surprises for now. 

1138 # (Maybe it is a non-issue, but it is easier to add the API later than to remove it once we have committed 

1139 # to using it) 

1140 __iter__ = None 

1141 

1142 def __getitem__(self, key: object) -> "VirtualPath": 

1143 """Lookup a (direct) child by name 

1144 

1145 Ignoring the possible `KeyError`, then the following are the same: 

1146 `fs_root["usr"] == fs_root.lookup('usr')` 

1147 

1148 Note that unlike `.lookup` this can only locate direct children. 

1149 """ 

1150 raise NotImplementedError 

1151 

1152 def __delitem__(self, key) -> None: 

1153 """Remove a child from this node if it exists 

1154 

1155 If that child is a directory, then the entire tree is removed (like `rm -fr`). 

1156 """ 

1157 raise NotImplementedError 

1158 

1159 def get(self, key: str) -> "Optional[VirtualPath]": 

1160 """Lookup a (direct) child by name 

1161 

1162 The following are the same: 

1163 `fs_root.get("usr") == fs_root.lookup('usr')` 

1164 

1165 Note that unlike `.lookup` this can only locate direct children. 

1166 """ 

1167 try: 

1168 return self[key] 

1169 except KeyError: 

1170 return None 

1171 

1172 def __contains__(self, item: object) -> bool: 

1173 """Determine if this path includes a given child (either by object or string) 

1174 

1175 Examples: 

1176 

1177 if 'foo' in dir: ... 

1178 """ 

1179 if isinstance(item, VirtualPath): 

1180 return item.parent_dir is self 

1181 if not isinstance(item, str): 

1182 return False 

1183 m = self.get(item) 

1184 return m is not None 

1185 

1186 @property 

1187 def path(self) -> str: 

1188 """Returns the "full" path for this file system entry 

1189 

1190 This is the path that debputy uses to refer to this file system entry. It is always 

1191 normalized. Use the `absolute` attribute for how the path looks 

1192 when the package is installed. Alternatively, there is also `fs_path`, which is the 

1193 path to the underlying file system object (assuming there is one). That is the one 

1194 you need if you want to read the file. 

1195 

1196 This is attribute is mostly useful for debugging or for looking up the path relative 

1197 to the "root" of the virtual file system that debputy maintains. 

1198 

1199 If the path is detached (see `is_detached`), then this method returns the path as it 

1200 was known prior to being detached. 

1201 """ 

1202 raise NotImplementedError 

1203 

1204 @property 

1205 def absolute(self) -> str: 

1206 """Returns the absolute version of this path 

1207 

1208 This is how to refer to this path when the package is installed. 

1209 

1210 If the path is detached (see `is_detached`), then this method returns the last known location 

1211 of installation (prior to being detached). 

1212 

1213 :return: The absolute path of this file as it would be on the installed system. 

1214 """ 

1215 p = self.path.lstrip(".") 

1216 if not p.startswith("/"): 

1217 return f"/{p}" 

1218 return p 

1219 

1220 @property 

1221 def parent_dir(self) -> Optional["VirtualPath"]: 

1222 """The parent directory of this path 

1223 

1224 Note this operation requires the path is "attached" (see `is_detached`). All paths are attached 

1225 by default but unlinking paths will cause them to become detached. 

1226 

1227 :return: The parent path or None for the root. 

1228 """ 

1229 raise NotImplementedError 

1230 

1231 def stat(self) -> os.stat_result: 

1232 """Attempt to do stat of the underlying path (if it exists) 

1233 

1234 *Avoid* using `stat()` whenever possible where a more specialized attribute exist. The 

1235 `stat()` call returns the data from the file system and often, `debputy` does *not* track 

1236 its state in the file system. As an example, if you want to know the file system mode of 

1237 a path, please use the `mode` attribute instead. 

1238 

1239 This never follow symlinks (it behaves like `os.lstat`). It will raise an error 

1240 if the path is not backed by a file system object (that is, `has_fs_path` is False). 

1241 

1242 :return: The stat result or an error. 

1243 """ 

1244 raise NotImplementedError() 

1245 

1246 @property 

1247 def size(self) -> int: 

1248 """Resolve the file size (`st_size`) 

1249 

1250 This may be using `stat()` and therefore `fs_path`. 

1251 

1252 :return: The size of the file in bytes 

1253 """ 

1254 return self.stat().st_size 

1255 

1256 @property 

1257 def mode(self) -> int: 

1258 """Determine the mode bits of this path object 

1259 

1260 Note that: 

1261 * like with `stat` above, this never follows symlinks. 

1262 * the mode returned by this method is not always a 1:1 with the mode in the 

1263 physical file system. As an optimization, `debputy` skips unnecessary writes 

1264 to the underlying file system in many cases. 

1265 

1266 

1267 :return: The mode bits for the path. 

1268 """ 

1269 raise NotImplementedError 

1270 

1271 @mode.setter 

1272 def mode(self, new_mode: int) -> None: 

1273 """Set the octal file mode of this path 

1274 

1275 Note that: 

1276 * this operation will fail if `path.is_read_write` returns False. 

1277 * this operation is generally *not* synced to the physical file system (as 

1278 an optimization). 

1279 

1280 :param new_mode: The new octal mode for this path. Note that `debputy` insists 

1281 that all paths have the `user read bit` and, for directories also, the 

1282 `user execute bit`. The absence of these minimal mode bits causes hard to 

1283 debug errors. 

1284 """ 

1285 raise NotImplementedError 

1286 

1287 @property 

1288 def is_executable(self) -> bool: 

1289 """Determine whether a path is considered executable 

1290 

1291 Generally, this means that at least one executable bit is set. This will 

1292 basically always be true for directories as directories need the execute 

1293 parameter to be traversable. 

1294 

1295 :return: True if the path is considered executable with its current mode 

1296 """ 

1297 return bool(self.mode & 0o0111) 

1298 

1299 def chmod(self, new_mode: Union[int, str]) -> None: 

1300 """Set the file mode of this path 

1301 

1302 This is similar to setting the `mode` attribute. However, this method accepts 

1303 a string argument, which will be parsed as a symbolic mode (example: `u+rX,go=rX`). 

1304 

1305 Note that: 

1306 * this operation will fail if `path.is_read_write` returns False. 

1307 * this operation is generally *not* synced to the physical file system (as 

1308 an optimization). 

1309 

1310 :param new_mode: The new mode for this path. 

1311 Note that `debputy` insists that all paths have the `user read bit` and, for 

1312 directories also, the `user execute bit`. The absence of these minimal mode 

1313 bits causes hard to debug errors. 

1314 """ 

1315 if isinstance(new_mode, str): 

1316 segments = parse_symbolic_mode(new_mode, None) 

1317 final_mode = self.mode 

1318 is_dir = self.is_dir 

1319 for segment in segments: 

1320 final_mode = segment.apply(final_mode, is_dir) 

1321 self.mode = final_mode 

1322 else: 

1323 self.mode = new_mode 

1324 

1325 def chown( 

1326 self, 

1327 owner: Optional["StaticFileSystemOwner"], 

1328 group: Optional["StaticFileSystemGroup"], 

1329 ) -> None: 

1330 """Change the owner/group of this path 

1331 

1332 :param owner: The desired owner definition for this path. If None, then no change of owner is performed. 

1333 :param group: The desired group definition for this path. If None, then no change of group is performed. 

1334 """ 

1335 raise NotImplementedError 

1336 

1337 @property 

1338 def mtime(self) -> float: 

1339 """Determine the mtime of this path object 

1340 

1341 Note that: 

1342 * like with `stat` above, this never follows symlinks. 

1343 * the mtime returned has *not* been clamped against ´SOURCE_DATE_EPOCH`. Timestamp 

1344 normalization is handled later by `debputy`. 

1345 * the mtime returned by this method is not always a 1:1 with the mtime in the 

1346 physical file system. As an optimization, `debputy` skips unnecessary writes 

1347 to the underlying file system in many cases. 

1348 

1349 :return: The mtime for the path. 

1350 """ 

1351 raise NotImplementedError 

1352 

1353 @mtime.setter 

1354 def mtime(self, new_mtime: float) -> None: 

1355 """Set the mtime of this path 

1356 

1357 Note that: 

1358 * this operation will fail if `path.is_read_write` returns False. 

1359 * this operation is generally *not* synced to the physical file system (as 

1360 an optimization). 

1361 

1362 :param new_mtime: The new mtime of this path. Note that the caller does not need to 

1363 account for `SOURCE_DATE_EPOCH`. Timestamp normalization is handled later. 

1364 """ 

1365 raise NotImplementedError 

1366 

1367 def readlink(self) -> str: 

1368 """Determine the link target of this path assuming it is a symlink 

1369 

1370 For paths where `is_symlink` is True, this already returns a link target even when 

1371 `has_fs_path` is False. 

1372 

1373 :return: The link target of the path or an error is this is not a symlink 

1374 """ 

1375 raise NotImplementedError() 

1376 

1377 @overload 

1378 def open( 1378 ↛ exitline 1378 didn't return from function 'open' because

1379 self, 

1380 *, 

1381 byte_io: Literal[False] = False, 

1382 buffering: int = -1, 

1383 ) -> TextIO: ... 

1384 

1385 @overload 

1386 def open( 1386 ↛ exitline 1386 didn't return from function 'open' because

1387 self, 

1388 *, 

1389 byte_io: Literal[True], 

1390 buffering: int = -1, 

1391 ) -> BinaryIO: ... 

1392 

1393 @overload 

1394 def open( 1394 ↛ exitline 1394 didn't return from function 'open' because

1395 self, 

1396 *, 

1397 byte_io: bool, 

1398 buffering: int = -1, 

1399 ) -> Union[TextIO, BinaryIO]: ... 

1400 

1401 def open( 

1402 self, 

1403 *, 

1404 byte_io: bool = False, 

1405 buffering: int = -1, 

1406 ) -> Union[TextIO, BinaryIO]: 

1407 """Open the file for reading. Usually used with a context manager 

1408 

1409 By default, the file is opened in text mode (utf-8). Binary mode can be requested 

1410 via the `byte_io` parameter. This operation is only valid for files (`is_file` returns 

1411 `True`). Usage on symlinks and directories will raise exceptions. 

1412 

1413 This method *often* requires the `fs_path` to be present. However, tests as a notable 

1414 case can inject content without having the `fs_path` point to a real file. (To be clear, 

1415 such tests are generally expected to ensure `has_fs_path` returns `True`). 

1416 

1417 

1418 :param byte_io: If True, open the file in binary mode (like `rb` for `open`) 

1419 :param buffering: Same as open(..., buffering=...) where supported. Notably during 

1420 testing, the content may be purely in memory and use a BytesIO/StringIO 

1421 (which does not accept that parameter, but then it is buffered in a different way) 

1422 :return: The file handle. 

1423 """ 

1424 

1425 if not self.is_file: 1425 ↛ 1426line 1425 didn't jump to line 1426 because the condition on line 1425 was never true

1426 raise TypeError(f"Cannot open {self.path} for reading: It is not a file") 

1427 

1428 if byte_io: 

1429 return open(self.fs_path, "rb", buffering=buffering) 

1430 return open(self.fs_path, "rt", encoding="utf-8", buffering=buffering) 

1431 

1432 @property 

1433 def fs_path(self) -> str: 

1434 """Request the underling fs_path of this path 

1435 

1436 Only available when `has_fs_path` is True. Generally this should only be used for files to read 

1437 the contents of the file and do some action based on the parsed result. 

1438 

1439 The path should only be used for read-only purposes as debputy may assume that it is safe to have 

1440 multiple paths pointing to the same file system path. 

1441 

1442 Note that: 

1443 * This is often *not* available for directories and symlinks. 

1444 * The debputy in-memory file system overrules the physical file system. Attempting to "fix" things 

1445 by using `os.chmod` or `os.unlink`'ing files, etc. will generally not do as you expect. Best case, 

1446 your actions are ignored and worst case it will cause the build to fail as it violates debputy's 

1447 internal invariants. 

1448 

1449 :return: The path to the underlying file system object on the build system or an error if no such 

1450 file exist (see `has_fs_path`). 

1451 """ 

1452 raise NotImplementedError() 

1453 

1454 @property 

1455 def is_dir(self) -> bool: 

1456 """Determine if this path is a directory 

1457 

1458 Never follows symlinks. 

1459 

1460 :return: True if this path is a directory. False otherwise. 

1461 """ 

1462 raise NotImplementedError() 

1463 

1464 @property 

1465 def is_file(self) -> bool: 

1466 """Determine if this path is a directory 

1467 

1468 Never follows symlinks. 

1469 

1470 :return: True if this path is a regular file. False otherwise. 

1471 """ 

1472 raise NotImplementedError() 

1473 

1474 @property 

1475 def is_symlink(self) -> bool: 

1476 """Determine if this path is a symlink 

1477 

1478 :return: True if this path is a symlink. False otherwise. 

1479 """ 

1480 raise NotImplementedError() 

1481 

1482 @property 

1483 def has_fs_path(self) -> bool: 

1484 """Determine whether this path is backed by a file system path 

1485 

1486 :return: True if this path is backed by a file system object on the build system. 

1487 """ 

1488 raise NotImplementedError() 

1489 

1490 @property 

1491 def is_read_write(self) -> bool: 

1492 """When true, the file system entry may be mutated 

1493 

1494 Read-write rules are: 

1495 

1496 +--------------------------+-------------------+------------------------+ 

1497 | File system | From / Inside | Read-Only / Read-Write | 

1498 +--------------------------+-------------------+------------------------+ 

1499 | Source directory | Any context | Read-Only | 

1500 | Binary staging directory | Package Processor | Read-Write | 

1501 | Binary staging directory | Metadata Detector | Read-Only | 

1502 +--------------------------+-------------------+------------------------+ 

1503 

1504 These rules apply to the virtual file system (`debputy` cannot enforce 

1505 these rules in the underlying file system). The `debputy` code relies 

1506 on these rules for its logic in multiple places to catch bugs and for 

1507 optimizations. 

1508 

1509 As an example, the reason why the file system is read-only when Metadata 

1510 Detectors are run is based the contents of the file system has already 

1511 been committed. New files will not be included, removals of existing 

1512 files will trigger a hard error when the package is assembled, etc. 

1513 To avoid people spending hours debugging why their code does not work 

1514 as intended, `debputy` instead throws a hard error if you try to mutate 

1515 the file system when it is read-only mode to "fail fast". 

1516 

1517 :return: Whether file system mutations are permitted. 

1518 """ 

1519 return False 

1520 

1521 def mkdir(self, name: str) -> "VirtualPath": 

1522 """Create a new subdirectory of the current path 

1523 

1524 :param name: Basename of the new directory. The directory must not contain a path 

1525 with this basename. 

1526 :return: The new subdirectory 

1527 """ 

1528 raise NotImplementedError 

1529 

1530 def mkdirs(self, path: str) -> "VirtualPath": 

1531 """Ensure a given path exists and is a directory. 

1532 

1533 :param path: Path to the directory to create. Any parent directories will be 

1534 created as needed. If the path already exists and is a directory, then it 

1535 is returned. If any part of the path exists and that is not a directory, 

1536 then the `mkdirs` call will raise an error. 

1537 :return: The directory denoted by the given path 

1538 """ 

1539 raise NotImplementedError 

1540 

1541 def add_file( 

1542 self, 

1543 name: str, 

1544 *, 

1545 unlink_if_exists: bool = True, 

1546 use_fs_path_mode: bool = False, 

1547 mode: int = 0o0644, 

1548 mtime: Optional[float] = None, 

1549 ) -> ContextManager["VirtualPath"]: 

1550 """Add a new regular file as a child of this path 

1551 

1552 This method will insert a new file into the virtual file system as a child 

1553 of the current path (which must be a directory). The caller must use the 

1554 return value as a context manager (see example). During the life-cycle of 

1555 the managed context, the caller can fill out the contents of the file 

1556 from the new path's `fs_path` attribute. The `fs_path` will exist as an 

1557 empty file when the context manager is entered. 

1558 

1559 Once the context manager exits, mutation of the `fs_path` is no longer permitted. 

1560 

1561 >>> import subprocess 

1562 >>> path = ... # doctest: +SKIP 

1563 >>> with path.add_file("foo") as new_file, open(new_file.fs_path, "w") as fd: # doctest: +SKIP 

1564 ... fd.writelines(["Some", "Content", "Here"]) 

1565 

1566 The caller can replace the provided `fs_path` entirely provided at the end result 

1567 (when the context manager exits) is a regular file with no hard links. 

1568 

1569 Note that this operation will fail if `path.is_read_write` returns False. 

1570 

1571 :param name: Basename of the new file 

1572 :param unlink_if_exists: If the name was already in use, then either an exception is thrown 

1573 (when `unlink_if_exists` is False) or the path will be removed via ´unlink(recursive=False)` 

1574 (when `unlink_if_exists` is True) 

1575 :param use_fs_path_mode: When True, the file created will have this mode in the physical file 

1576 system. When the context manager exists, `debputy` will refresh its mode to match the mode 

1577 in the physical file system. This is primarily useful if the caller uses a subprocess to 

1578 mutate the path and the file mode is relevant for this tool (either as input or output). 

1579 When the parameter is false, the new file is guaranteed to be readable and writable for 

1580 the current user. However, no other guarantees are given (not even that it matches the 

1581 `mode` parameter and any changes to the mode in the physical file system will be ignored. 

1582 :param mode: This is the initial file mode. Note the `use_fs_path_mode` parameter for how 

1583 this interacts with the physical file system. 

1584 :param mtime: If the caller has a more accurate mtime than the mtime of the generated file, 

1585 then it can be provided here. Note that all mtimes will later be clamped based on 

1586 `SOURCE_DATE_EPOCH`. This parameter is only for when the conceptual mtime of this path 

1587 should be earlier than `SOURCE_DATE_EPOCH`. 

1588 :return: A Context manager that upon entering provides a `VirtualPath` instance for the 

1589 new file. The instance remains valid after the context manager exits (assuming it exits 

1590 successfully), but the file denoted by `fs_path` must not be changed after the context 

1591 manager exits 

1592 """ 

1593 raise NotImplementedError 

1594 

1595 def replace_fs_path_content( 

1596 self, 

1597 *, 

1598 use_fs_path_mode: bool = False, 

1599 ) -> ContextManager[str]: 

1600 """Replace the contents of this file via inline manipulation 

1601 

1602 Used as a context manager to provide the fs path for manipulation. 

1603 

1604 Example: 

1605 >>> import subprocess 

1606 >>> path = ... # doctest: +SKIP 

1607 >>> with path.replace_fs_path_content() as fs_path: # doctest: +SKIP 

1608 ... subprocess.check_call(['strip', fs_path]) # doctest: +SKIP 

1609 

1610 The provided file system path should be manipulated inline. The debputy framework may 

1611 copy it first as necessary and therefore the provided fs_path may be different from 

1612 `path.fs_path` prior to entering the context manager. 

1613 

1614 Note that this operation will fail if `path.is_read_write` returns False. 

1615 

1616 If the mutation causes the returned `fs_path` to be a non-file or a hard-linked file 

1617 when the context manager exits, `debputy` will raise an error at that point. To preserve 

1618 the internal invariants of `debputy`, the path will be unlinked as `debputy` cannot 

1619 reliably restore the path. 

1620 

1621 :param use_fs_path_mode: If True, any changes to the mode on the physical FS path will be 

1622 recorded as the desired mode of the file when the contextmanager ends. The provided FS path 

1623 with start with the current mode when `use_fs_path_mode` is True. Otherwise, `debputy` will 

1624 ignore the mode of the file system entry and reuse its own current mode 

1625 definition. 

1626 :return: A Context manager that upon entering provides the path to a muable (copy) of 

1627 this path's `fs_path` attribute. The file on the underlying path may be mutated however 

1628 the caller wishes until the context manager exits. 

1629 """ 

1630 raise NotImplementedError 

1631 

1632 def add_symlink(self, link_name: str, link_target: str) -> "VirtualPath": 

1633 """Add a new regular file as a child of this path 

1634 

1635 This will create a new symlink inside the current path. If the path already exists, 

1636 the existing path will be unlinked via `unlink(recursive=False)`. 

1637 

1638 Note that this operation will fail if `path.is_read_write` returns False. 

1639 

1640 :param link_name: The basename of the link file entry. 

1641 :param link_target: The target of the link. Link target normalization will 

1642 be handled by `debputy`, so the caller can use relative or absolute paths. 

1643 (At the time of writing, symlink target normalization happens late) 

1644 :return: The newly created symlink. 

1645 """ 

1646 raise NotImplementedError 

1647 

1648 def unlink(self, *, recursive: bool = False) -> None: 

1649 """Unlink a file or a directory 

1650 

1651 This operation will remove the path from the file system (causing `is_detached` to return True). 

1652 

1653 When the path is a: 

1654 

1655 * symlink, then the symlink itself is removed. The target (if present) is not affected. 

1656 * *non-empty* directory, then the `recursive` parameter decides the outcome. An empty 

1657 directory will be removed regardless of the value of `recursive`. 

1658 

1659 Note that: 

1660 * the root directory cannot be deleted. 

1661 * this operation will fail if `path.is_read_write` returns False. 

1662 

1663 :param recursive: If True, then non-empty directories will be unlinked as well removing everything inside them 

1664 as well. When False, an error is raised if the path is a non-empty directory 

1665 """ 

1666 raise NotImplementedError 

1667 

1668 def interpreter(self) -> Optional[Interpreter]: 

1669 """Determine the interpreter of the file (`#!`-line details) 

1670 

1671 Note: this method is only applicable for files (`is_file` is True). 

1672 

1673 :return: The detected interpreter if present or None if no interpreter can be detected. 

1674 """ 

1675 if not self.is_file: 

1676 raise TypeError("Only files can have interpreters") 

1677 try: 

1678 with self.open(byte_io=True, buffering=4096) as fd: 

1679 return extract_shebang_interpreter_from_file(fd) 

1680 except (PureVirtualPathError, TestPathWithNonExistentFSPathError): 

1681 return None 

1682 

1683 def metadata( 

1684 self, 

1685 metadata_type: Type[PMT], 

1686 ) -> PathMetadataReference[PMT]: 

1687 """Fetch the path metadata reference to access the underlying metadata 

1688 

1689 Calling this method returns a reference to an arbitrary piece of metadata associated 

1690 with this path. Plugins can store any arbitrary data associated with a given path. 

1691 Keep in mind that the metadata is stored in memory, so keep the size in moderation. 

1692 

1693 To store / update the metadata, the path must be in read-write mode. However, 

1694 already stored metadata remains accessible even if the path becomes read-only. 

1695 

1696 Note this method is not applicable if the path is detached 

1697 

1698 :param metadata_type: Type of the metadata being stored. 

1699 :return: A reference to the metadata. 

1700 """ 

1701 raise NotImplementedError 

1702 

1703 

1704class FlushableSubstvars(Substvars): 

1705 __slots__ = () 

1706 

1707 @contextlib.contextmanager 

1708 def flush(self) -> Iterator[str]: 

1709 """Temporarily write the substvars to a file and then re-read it again 

1710 

1711 >>> s = FlushableSubstvars() 

1712 >>> 'Test:Var' in s 

1713 False 

1714 >>> with s.flush() as name, open(name, 'wt', encoding='utf-8') as fobj: 

1715 ... _ = fobj.write('Test:Var=bar\\n') # "_ = " is to ignore the return value of write 

1716 >>> 'Test:Var' in s 

1717 True 

1718 

1719 Used as a context manager to define when the file is flushed and can be 

1720 accessed via the file system. If the context terminates successfully, the 

1721 file is read and its content replaces the current substvars. 

1722 

1723 This is mostly useful if the plugin needs to interface with a third-party 

1724 tool that requires a file as interprocess communication (IPC) for sharing 

1725 the substvars. 

1726 

1727 The file may be truncated or completed replaced (change inode) as long as 

1728 the provided path points to a regular file when the context manager 

1729 terminates successfully. 

1730 

1731 Note that any manipulation of the substvars via the `Substvars` API while 

1732 the file is flushed will silently be discarded if the context manager completes 

1733 successfully. 

1734 """ 

1735 with tempfile.NamedTemporaryFile(mode="w+t", encoding="utf-8") as tmp: 

1736 self.write_substvars(tmp) 

1737 tmp.flush() # Temping to use close, but then we have to manually delete the file. 

1738 yield tmp.name 

1739 # Re-open; seek did not work when I last tried (if I did it work, feel free to 

1740 # convert back to seek - as long as it works!) 

1741 with open(tmp.name, "rt", encoding="utf-8") as fd: 

1742 self.read_substvars(fd) 

1743 

1744 def save(self) -> None: 

1745 # Promote the debputy extension over `save()` for the plugins. 

1746 if self._substvars_path is None: 

1747 raise TypeError( 

1748 "Please use `flush()` extension to temporarily write the substvars to the file system" 

1749 ) 

1750 super().save() 

1751 

1752 

1753class ServiceRegistry(Generic[DSD]): 

1754 __slots__ = () 

1755 

1756 def register_service( 

1757 self, 

1758 path: VirtualPath, 

1759 name: Union[str, List[str]], 

1760 *, 

1761 type_of_service: str = "service", # "timer", etc. 

1762 service_scope: str = "system", 

1763 enable_by_default: bool = True, 

1764 start_by_default: bool = True, 

1765 default_upgrade_rule: ServiceUpgradeRule = "restart", 

1766 service_context: Optional[DSD] = None, 

1767 ) -> None: 

1768 """Register a service detected in the package 

1769 

1770 All the details will either be provided as-is or used as default when the plugin provided 

1771 integration code is called. 

1772 

1773 Two services from different service managers are considered related when: 

1774 

1775 1) They are of the same type (`type_of_service`) and has the same scope (`service_scope`), AND 

1776 2) Their plugin provided names has an overlap 

1777 

1778 Related services can be covered by the same service definition in the manifest. 

1779 

1780 :param path: The path defining this service. 

1781 :param name: The name of the service. Multiple ones can be provided if the service has aliases. 

1782 Note that when providing multiple names, `debputy` will use the first name in the list as the 

1783 default name if it has to choose. Any alternative name provided can be used by the packager 

1784 to identify this service. 

1785 :param type_of_service: The type of service. By default, this is "service", but plugins can 

1786 provide other types (such as "timer" for the systemd timer unit). 

1787 :param service_scope: The scope for this service. By default, this is "system" meaning the 

1788 service is a system-wide service. Service managers can define their own scopes such as 

1789 "user" (which is used by systemd for "per-user" services). 

1790 :param enable_by_default: Whether the service should be enabled by default, assuming the 

1791 packager does not explicitly override this setting. 

1792 :param start_by_default: Whether the service should be started by default on install, assuming 

1793 the packager does not explicitly override this setting. 

1794 :param default_upgrade_rule: The default value for how the service should be processed during 

1795 upgrades. Options are: 

1796 * `do-nothing`: The plugin should not interact with the running service (if any) 

1797 (maintenance of the enabled start, start on install, etc. are still applicable) 

1798 * `reload`: The plugin should attempt to reload the running service (if any). 

1799 Note: In combination with `auto_start_in_install == False`, be careful to not 

1800 start the service if not is not already running. 

1801 * `restart`: The plugin should attempt to restart the running service (if any). 

1802 Note: In combination with `auto_start_in_install == False`, be careful to not 

1803 start the service if not is not already running. 

1804 * `stop-then-start`: The plugin should stop the service during `prerm upgrade` 

1805 and start it against in the `postinst` script. 

1806 

1807 :param service_context: Any custom data that the detector want to pass along to the 

1808 integrator for this service. 

1809 """ 

1810 raise NotImplementedError 

1811 

1812 

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

1814class ParserAttributeDocumentation: 

1815 attributes: FrozenSet[str] 

1816 description: Optional[str] 

1817 

1818 @property 

1819 def is_hidden(self) -> bool: 

1820 return False 

1821 

1822 

1823@final 

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

1825class StandardParserAttributeDocumentation(ParserAttributeDocumentation): 

1826 sort_category: int = 0 

1827 

1828 

1829def undocumented_attr(attr: str) -> ParserAttributeDocumentation: 

1830 """Describe an attribute as undocumented 

1831 

1832 If you for some reason do not want to document a particular attribute, you can mark it as 

1833 undocumented. This is required if you are only documenting a subset of the attributes, 

1834 because `debputy` assumes any omission to be a mistake. 

1835 

1836 :param attr: Name of the attribute 

1837 """ 

1838 return ParserAttributeDocumentation( 

1839 frozenset({attr}), 

1840 None, 

1841 ) 

1842 

1843 

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

1845class ParserDocumentation: 

1846 synopsis: Optional[str] = None 

1847 title: Optional[str] = None 

1848 description: Optional[str] = None 

1849 attribute_doc: Optional[Sequence[ParserAttributeDocumentation]] = None 

1850 alt_parser_description: Optional[str] = None 

1851 documentation_reference_url: Optional[str] = None 

1852 

1853 def replace(self, **changes: Any) -> "ParserDocumentation": 

1854 return dataclasses.replace(self, **changes) 

1855 

1856 @classmethod 

1857 def from_ref_doc(cls, ref_doc: "ParserRefDocumentation") -> "ParserDocumentation": 

1858 attr = [ 

1859 documented_attr(d["attr"], d["description"]) 

1860 for d in ref_doc.get("attributes", []) 

1861 ] 

1862 undoc_attr = ref_doc.get("undocumented_attributes") 

1863 if undoc_attr: 1863 ↛ 1864line 1863 didn't jump to line 1864 because the condition on line 1863 was never true

1864 attr.extend(undocumented_attr(attr) for attr in undoc_attr) 

1865 

1866 return reference_documentation( 

1867 title=ref_doc["title"], 

1868 description=ref_doc["description"], 

1869 attributes=attr, 

1870 non_mapping_description=ref_doc.get("non_mapping_description"), 

1871 reference_documentation_url=ref_doc.get("ref_doc_url"), 

1872 synopsis=ref_doc.get("synopsis"), 

1873 ) 

1874 

1875 

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

1877class TypeMappingExample(Generic[S]): 

1878 source_input: S 

1879 

1880 

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

1882class TypeMappingDocumentation(Generic[S]): 

1883 description: Optional[str] = None 

1884 examples: Sequence[TypeMappingExample[S]] = tuple() 

1885 

1886 

1887def type_mapping_example(source_input: S) -> TypeMappingExample[S]: 

1888 return TypeMappingExample(source_input) 

1889 

1890 

1891def type_mapping_reference_documentation( 

1892 *, 

1893 description: Optional[str] = None, 

1894 examples: Union[TypeMappingExample[S], Iterable[TypeMappingExample[S]]] = tuple(), 

1895) -> TypeMappingDocumentation[S]: 

1896 e = ( 

1897 tuple([examples]) 

1898 if isinstance(examples, TypeMappingExample) 

1899 else tuple(examples) 

1900 ) 

1901 return TypeMappingDocumentation( 

1902 description=description, 

1903 examples=e, 

1904 ) 

1905 

1906 

1907def documented_attr( 

1908 attr: Union[str, Iterable[str]], 

1909 description: str, 

1910) -> ParserAttributeDocumentation: 

1911 """Describe an attribute or a group of attributes 

1912 

1913 :param attr: A single attribute or a sequence of attributes. The attribute must be the 

1914 attribute name as used in the source format version of the TypedDict. 

1915 

1916 If multiple attributes are provided, they will be documented together. This is often 

1917 useful if these attributes are strongly related (such as different names for the same 

1918 target attribute). 

1919 :param description: The description the user should see for this attribute / these 

1920 attributes. This parameter can be a Python format string with variables listed in 

1921 the description of `reference_documentation`. 

1922 :return: An opaque representation of the documentation, 

1923 """ 

1924 attributes = [attr] if isinstance(attr, str) else attr 

1925 return ParserAttributeDocumentation( 

1926 frozenset(attributes), 

1927 description, 

1928 ) 

1929 

1930 

1931def reference_documentation( 

1932 title: str = "Auto-generated reference documentation for ${RULE_NAME}", 

1933 description: Optional[str] = textwrap.dedent( 

1934 """\ 

1935 This is an automatically generated reference documentation for ${RULE_NAME}. It is generated 

1936 from input provided by ${PLUGIN_NAME} via the debputy API. 

1937 

1938 (If you are the provider of the ${PLUGIN_NAME} plugin, you can replace this text with 

1939 your own documentation by providing the `inline_reference_documentation` when registering 

1940 the manifest rule.) 

1941 """ 

1942 ), 

1943 attributes: Optional[Sequence[ParserAttributeDocumentation]] = None, 

1944 non_mapping_description: Optional[str] = None, 

1945 reference_documentation_url: Optional[str] = None, 

1946 synopsis: Optional[str] = None, 

1947) -> ParserDocumentation: 

1948 """Provide inline reference documentation for the manifest snippet 

1949 

1950 For parameters that mention that they are a Python format, the following template variables 

1951 are available (`${FOO}`): 

1952 

1953 * RULE_NAME: Name of the rule. If manifest snippet has aliases, this will be the name of 

1954 the alias provided by the user. 

1955 * MANIFEST_FORMAT_DOC: Path OR URL to the "MANIFEST-FORMAT" reference documentation from 

1956 `debputy`. By using the MANIFEST_FORMAT_DOC variable, you ensure that you point to the 

1957 file that matches the version of `debputy` itself. 

1958 * PLUGIN_NAME: Name of the plugin providing this rule. 

1959 

1960 :param title: The text you want the user to see as for your rule. A placeholder is provided by default. 

1961 This parameter can be a Python format string with the above listed variables. 

1962 :param description: The text you want the user to see as a description for the rule. An auto-generated 

1963 placeholder is provided by default saying that no human written documentation was provided. 

1964 This parameter can be a Python format string with the above listed variables. 

1965 :param synopsis: One-line plain-text description used for describing the feature during completion. 

1966 :param attributes: A sequence of attribute-related documentation. Each element of the sequence should 

1967 be the result of `documented_attr` or `undocumented_attr`. The sequence must cover all source 

1968 attributes exactly once. 

1969 :param non_mapping_description: The text you want the user to see as the description for your rule when 

1970 `debputy` describes its non-mapping format. Must not be provided for rules that do not have an 

1971 (optional) non-mapping format as source format. This parameter can be a Python format string with 

1972 the above listed variables. 

1973 :param reference_documentation_url: A URL to the reference documentation. 

1974 :return: An opaque representation of the documentation, 

1975 """ 

1976 return ParserDocumentation( 

1977 synopsis, 

1978 title, 

1979 description, 

1980 attributes, 

1981 non_mapping_description, 

1982 reference_documentation_url, 

1983 ) 

1984 

1985 

1986class ServiceDefinition(Generic[DSD]): 

1987 __slots__ = () 

1988 

1989 @property 

1990 def name(self) -> str: 

1991 """Name of the service registered by the plugin 

1992 

1993 This is always a plugin provided name for this service (that is, `x.name in x.names` 

1994 will always be `True`). Where possible, this will be the same as the one that the 

1995 packager provided when they provided any configuration related to this service. 

1996 When not possible, this will be the first name provided by the plugin (`x.names[0]`). 

1997 

1998 If all the aliases are equal, then using this attribute will provide traceability 

1999 between the manifest and the generated maintscript snippets. When the exact name 

2000 used is important, the plugin should ignore this attribute and pick the name that 

2001 is needed. 

2002 """ 

2003 raise NotImplementedError 

2004 

2005 @property 

2006 def names(self) -> Sequence[str]: 

2007 """All *plugin provided* names and aliases of the service 

2008 

2009 This is the name/sequence of names that the plugin provided when it registered 

2010 the service earlier. 

2011 """ 

2012 raise NotImplementedError 

2013 

2014 @property 

2015 def path(self) -> VirtualPath: 

2016 """The registered path for this service 

2017 

2018 :return: The path that was associated with this service when it was registered 

2019 earlier. 

2020 """ 

2021 raise NotImplementedError 

2022 

2023 @property 

2024 def type_of_service(self) -> str: 

2025 """Type of the service such as "service" (daemon), "timer", etc. 

2026 

2027 :return: The type of service scope. It is the same value as the one as the plugin provided 

2028 when registering the service (if not explicitly provided, it defaults to "service"). 

2029 """ 

2030 raise NotImplementedError 

2031 

2032 @property 

2033 def service_scope(self) -> str: 

2034 """Service scope such as "system" or "user" 

2035 

2036 :return: The service scope. It is the same value as the one as the plugin provided 

2037 when registering the service (if not explicitly provided, it defaults to "system") 

2038 """ 

2039 raise NotImplementedError 

2040 

2041 @property 

2042 def auto_enable_on_install(self) -> bool: 

2043 """Whether the service should be auto-enabled on install 

2044 

2045 :return: True if the service should be enabled automatically, false if not. 

2046 """ 

2047 raise NotImplementedError 

2048 

2049 @property 

2050 def auto_start_on_install(self) -> bool: 

2051 """Whether the service should be auto-started on install 

2052 

2053 :return: True if the service should be started automatically, false if not. 

2054 """ 

2055 raise NotImplementedError 

2056 

2057 @property 

2058 def on_upgrade(self) -> ServiceUpgradeRule: 

2059 """How to handle the service during an upgrade 

2060 

2061 Options are: 

2062 * `do-nothing`: The plugin should not interact with the running service (if any) 

2063 (maintenance of the enabled start, start on install, etc. are still applicable) 

2064 * `reload`: The plugin should attempt to reload the running service (if any). 

2065 Note: In combination with `auto_start_in_install == False`, be careful to not 

2066 start the service if not is not already running. 

2067 * `restart`: The plugin should attempt to restart the running service (if any). 

2068 Note: In combination with `auto_start_in_install == False`, be careful to not 

2069 start the service if not is not already running. 

2070 * `stop-then-start`: The plugin should stop the service during `prerm upgrade` 

2071 and start it against in the `postinst` script. 

2072 

2073 Note: In all cases, the plugin should still consider what to do in 

2074 `prerm remove`, which is the last point in time where the plugin can rely on the 

2075 service definitions in the file systems to stop the services when the package is 

2076 being uninstalled. 

2077 

2078 :return: The service restart rule 

2079 """ 

2080 raise NotImplementedError 

2081 

2082 @property 

2083 def definition_source(self) -> str: 

2084 """Describes where this definition came from 

2085 

2086 If the definition is provided by the packager, then this will reference the part 

2087 of the manifest that made this definition. Otherwise, this will be a reference 

2088 to the plugin providing this definition. 

2089 

2090 :return: The source of this definition 

2091 """ 

2092 raise NotImplementedError 

2093 

2094 @property 

2095 def is_plugin_provided_definition(self) -> bool: 

2096 """Whether the definition source points to the plugin or a package provided definition 

2097 

2098 :return: True if definition is 100% from the plugin. False if the definition is partially 

2099 or fully from another source (usually, the packager via the manifest). 

2100 """ 

2101 raise NotImplementedError 

2102 

2103 @property 

2104 def service_context(self) -> Optional[DSD]: 

2105 """Custom service context (if any) provided by the detector code of the plugin 

2106 

2107 :return: If the detection code provided a custom data when registering the 

2108 service, this attribute will reference that data. If nothing was provided, 

2109 then this attribute will be None. 

2110 """ 

2111 raise NotImplementedError