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

376 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-02-14 10:41 +0000

1import contextlib 

2import dataclasses 

3import io 

4import tempfile 

5import textwrap 

6from typing import ( 

7 Optional, 

8 Literal, 

9 overload, 

10 TypeVar, 

11 Any, 

12 TYPE_CHECKING, 

13 TextIO, 

14 Generic, 

15 ContextManager, 

16 get_args, 

17 final, 

18 cast, 

19) 

20from collections.abc import Iterable, Callable, Iterator, Sequence, Container 

21 

22from debian.debian_support import DpkgArchTable 

23from debian.substvars import Substvars 

24 

25from debputy import util 

26from debputy._deb_options_profiles import DebBuildOptionsAndProfiles 

27from debputy.exceptions import ( 

28 TestPathWithNonExistentFSPathError, 

29 PureVirtualPathError, 

30 PluginInitializationError, 

31) 

32from debputy.interpreter import Interpreter, extract_shebang_interpreter_from_file 

33from debputy.manifest_conditions import ConditionContext 

34from debputy.manifest_parser.tagging_types import DebputyDispatchableType 

35from debputy.manifest_parser.util import parse_symbolic_mode 

36from debputy.packages import BinaryPackage, SourcePackage 

37from debputy.types import S 

38from debputy.util import PackageTypeSelector 

39 

40if TYPE_CHECKING: 

41 from debputy.manifest_parser.base_types import ( 

42 StaticFileSystemOwner, 

43 StaticFileSystemGroup, 

44 ) 

45 from debputy.plugin.api.doc_parsing import ParserRefDocumentation 

46 from debputy.plugin.api.impl_types import DIPHandler 

47 from debputy.plugins.debputy.to_be_api_types import BuildSystemRule 

48 from debputy.plugin.api.impl import DebputyPluginInitializerProvider 

49 

50 

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

52 

53 

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

55MetadataAutoDetector = Callable[ 

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

57] 

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

59DpkgTriggerType = Literal[ 

60 "activate", 

61 "activate-await", 

62 "activate-noawait", 

63 "interest", 

64 "interest-await", 

65 "interest-noawait", 

66] 

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

68 

69ServiceUpgradeRule = Literal[ 

70 "do-nothing", 

71 "reload", 

72 "restart", 

73 "stop-then-start", 

74] 

75 

76DSD = TypeVar("DSD") 

77ServiceDetector = Callable[ 

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

79 None, 

80] 

81ServiceIntegrator = Callable[ 

82 [ 

83 Sequence["ServiceDefinition[DSD]"], 

84 "BinaryCtrlAccessor", 

85 "PackageProcessingContext", 

86 ], 

87 None, 

88] 

89 

90PMT = TypeVar("PMT") 

91DebputyIntegrationMode = Literal[ 

92 "full", 

93 "dh-sequence-zz-debputy", 

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

95] 

96 

97INTEGRATION_MODE_FULL: DebputyIntegrationMode = "full" 

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

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

100ALL_DEBPUTY_INTEGRATION_MODES: frozenset[DebputyIntegrationMode] = frozenset( 

101 get_args(DebputyIntegrationMode) 

102) 

103 

104_DEBPUTY_DISPATCH_METADATA_ATTR_NAME = "_debputy_dispatch_metadata" 

105 

106 

107def only_integrations( 

108 *integrations: DebputyIntegrationMode, 

109) -> Container[DebputyIntegrationMode]: 

110 return frozenset(integrations) 

111 

112 

113def not_integrations( 

114 *integrations: DebputyIntegrationMode, 

115) -> Container[DebputyIntegrationMode]: 

116 return ALL_DEBPUTY_INTEGRATION_MODES - frozenset(integrations) 

117 

118 

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

120class PackagerProvidedFileReferenceDocumentation: 

121 description: str | None = None 

122 format_documentation_uris: Sequence[str] = tuple() 

123 

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

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

126 

127 

128def packager_provided_file_reference_documentation( 

129 *, 

130 description: str | None = None, 

131 format_documentation_uris: Sequence[str] | None = tuple(), 

132) -> PackagerProvidedFileReferenceDocumentation: 

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

134 

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

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

137 the format of the file. Most relevant first. 

138 :return: 

139 """ 

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

141 return PackagerProvidedFileReferenceDocumentation( 

142 description=description, 

143 format_documentation_uris=uris, 

144 ) 

145 

146 

147class PathMetadataReference(Generic[PMT]): 

148 """An accessor to plugin provided metadata 

149 

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

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

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

153 """ 

154 

155 @property 

156 def is_present(self) -> bool: 

157 """Determine whether the value has been set 

158 

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

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

161 

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

163 Otherwise, this property is `False`. 

164 """ 

165 raise NotImplementedError 

166 

167 @property 

168 def can_read(self) -> bool: 

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

170 

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

172 

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

174 owning plugin. 

175 """ 

176 raise NotImplementedError 

177 

178 @property 

179 def can_write(self) -> bool: 

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

181 

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

183 """ 

184 raise NotImplementedError 

185 

186 @property 

187 def value(self) -> PMT | None: 

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

189 

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

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

192 """ 

193 raise NotImplementedError 

194 

195 @value.setter 

196 def value(self, value: PMT | None) -> None: 

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

198 

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

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

201 """ 

202 raise NotImplementedError 

203 

204 @value.deleter 

205 def value(self) -> None: 

206 """Delete any current value. 

207 

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

209 as the value setter. 

210 """ 

211 self.value = None 

212 

213 

214@dataclasses.dataclass(slots=True) 

215class PathDef: 

216 path_name: str 

217 mode: int | None = None 

218 mtime: int | None = None 

219 has_fs_path: bool | None = None 

220 fs_path: str | None = None 

221 link_target: str | None = None 

222 content: str | None = None 

223 materialized_content: str | None = None 

224 

225 

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

227class DispatchablePluggableManifestRuleMetadata(Generic[DP]): 

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

229 

230 manifest_keywords: Sequence[str] 

231 dispatched_type: type[DP] 

232 unwrapped_constructor: "DIPHandler" 

233 expected_debputy_integration_mode: Container[DebputyIntegrationMode] | None = None 

234 online_reference_documentation: Optional["ParserDocumentation"] = None 

235 apply_standard_attribute_documentation: bool = False 

236 source_format: Any | None = None 

237 

238 

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

240class BuildSystemManifestRuleMetadata(DispatchablePluggableManifestRuleMetadata): 

241 build_system_impl: type["BuildSystemRule"] | None = None 

242 auto_detection_shadow_build_systems: frozenset[str] = frozenset() 

243 

244 

245def virtual_path_def( 

246 path_name: str, 

247 /, 

248 mode: int | None = None, 

249 mtime: int | None = None, 

250 fs_path: str | None = None, 

251 link_target: str | None = None, 

252 content: str | None = None, 

253 materialized_content: str | None = None, 

254) -> PathDef: 

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

256 

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

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

259 on whether a `link_target` is provided. 

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

261 should be None for symlinks. 

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

263 if the mtime attribute is accessed. 

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

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

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

267 to resolve defaults from the path. 

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

269 path a symlink. 

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

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

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

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

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

275 """ 

276 

277 is_dir = path_name.endswith("/") 

278 is_symlink = link_target is not None 

279 

280 if is_symlink: 

281 if mode is not None: 

282 raise ValueError( 

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

284 ) 

285 if is_dir: 

286 raise ValueError( 

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

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

289 ) 

290 

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

292 raise ValueError( 

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

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

295 ) 

296 

297 if materialized_content is not None: 

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

299 raise ValueError( 

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

301 f' Triggered by "{path_name}"' 

302 ) 

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

304 raise ValueError( 

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

306 f' Triggered by "{path_name}"' 

307 ) 

308 return PathDef( 

309 path_name, 

310 mode=mode, 

311 mtime=mtime, 

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

313 fs_path=fs_path, 

314 link_target=link_target, 

315 content=content, 

316 materialized_content=materialized_content, 

317 ) 

318 

319 

320class PackageProcessingContext: 

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

322 

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

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

325 """ 

326 

327 __slots__ = () 

328 

329 @property 

330 def source_package(self) -> SourcePackage: 

331 """The source package stanza from `debian/control`""" 

332 raise NotImplementedError 

333 

334 @property 

335 def binary_package(self) -> BinaryPackage: 

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

337 raise NotImplementedError 

338 

339 @property 

340 def binary_package_version(self) -> str: 

341 """The version of the binary package 

342 

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

344 """ 

345 raise NotImplementedError 

346 

347 @property 

348 def related_udeb_package(self) -> BinaryPackage | None: 

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

350 raise NotImplementedError 

351 

352 @property 

353 def related_udeb_package_version(self) -> str | None: 

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

355 

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

357 """ 

358 raise NotImplementedError 

359 

360 def accessible_package_roots(self) -> Iterable[tuple[BinaryPackage, "VirtualPath"]]: 

361 raise NotImplementedError 

362 

363 def manifest_configuration[T]( 

364 self, 

365 context_package: SourcePackage | BinaryPackage, 

366 value_type: type[T], 

367 ) -> T | None: 

368 """Request access to configuration from the manifest 

369 

370 This method will return the value associated with a pluggable manifest rule assuming 

371 said configuration was provided. 

372 

373 

374 :param context_package: The context in which the configuration will be. Generally, it will be 

375 the binary package for anything under `packages:` and the source package otherwise. 

376 :param value_type: The type used during registered (return type of the parser/unpack function) 

377 :return: The value from the manifest if present or `None` otherwise 

378 """ 

379 raise NotImplementedError 

380 

381 @property 

382 def dpkg_arch_query_table(self) -> DpkgArchTable: 

383 raise NotImplementedError 

384 

385 @property 

386 def deb_options_and_profiles(self) -> DebBuildOptionsAndProfiles: 

387 raise NotImplementedError 

388 

389 @property 

390 def source_condition_context(self) -> ConditionContext: 

391 raise NotImplementedError 

392 

393 def condition_context( 

394 self, binary_package: BinaryPackage | None 

395 ) -> ConditionContext: 

396 raise NotImplementedError 

397 

398 @property 

399 def binary_condition_context(self) -> ConditionContext: 

400 return self.condition_context(self.binary_package) 

401 

402 

403class DebputyPluginDefinition: 

404 """Plugin definition entity 

405 

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

407 accessors. 

408 """ 

409 

410 __slots__ = ("_generic_initializers",) 

411 

412 def __init__(self) -> None: 

413 self._generic_initializers: list[ 

414 Callable[["DebputyPluginInitializerProvider"], None] 

415 ] = [] 

416 

417 @staticmethod 

418 def _name2id(provided_id: str | None, name: str) -> str: 

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

420 return provided_id 

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

422 name = name[:-1] 

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

424 

425 def metadata_or_maintscript_detector( 

426 self, 

427 func: MetadataAutoDetector | None = None, 

428 *, 

429 detector_id: str | None = None, 

430 package_types: PackageTypeSelector = PackageTypeSelector.DEB, 

431 ) -> Callable[[MetadataAutoDetector], MetadataAutoDetector] | MetadataAutoDetector: 

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

433 

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

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

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

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

438 

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

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

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

442 

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

444 any other hook. 

445 

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

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

448 

449 

450 >>> plugin_definition = define_debputy_plugin() 

451 >>> @plugin_definition.metadata_or_maintscript_detector 

452 ... def gsettings_dependencies( 

453 ... fs_root: "VirtualPath", 

454 ... ctrl: "BinaryCtrlAccessor", 

455 ... context: "PackageProcessingContext", 

456 ... ) -> None: 

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

458 ... if gsettings_schema_dir is None: 

459 ... return 

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

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

462 ... ctrl.substvars.add_dependency( 

463 ... "misc:Depends", 

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

465 ... ) 

466 ... break 

467 

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

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

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

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

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

473 and ignore `udeb` packages. 

474 """ 

475 

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

477 

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

479 

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

481 api.metadata_or_maintscript_detector( 

482 final_id, 

483 f, 

484 package_types=package_types, 

485 ) 

486 

487 self._generic_initializers.append(_init) 

488 

489 return f 

490 

491 if func: 

492 return _decorate(func) 

493 return _decorate 

494 

495 def manifest_variable( 

496 self, 

497 variable_name: str, 

498 value: str, 

499 *, 

500 variable_reference_documentation: str | None = None, 

501 ) -> None: 

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

503 

504 >>> plugin_definition = define_debputy_plugin() 

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

506 >>> plugin_definition.manifest_variable( 

507 ... "path:BASH_COMPLETION_DIR", 

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

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

510 ... ) 

511 

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

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

514 dynamic / context based values at this time. 

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

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

517 docs. 

518 """ 

519 

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

521 api.manifest_variable( 

522 variable_name, 

523 value, 

524 variable_reference_documentation=variable_reference_documentation, 

525 ) 

526 

527 self._generic_initializers.append(_init) 

528 

529 def packager_provided_file( 

530 self, 

531 stem: str, 

532 installed_path: str, 

533 *, 

534 default_mode: int = 0o0644, 

535 default_priority: int | None = None, 

536 allow_name_segment: bool = True, 

537 allow_architecture_segment: bool = False, 

538 post_formatting_rewrite: Callable[[str], str] | None = None, 

539 packageless_is_fallback_for_all_packages: bool = False, 

540 reservation_only: bool = False, 

541 reference_documentation: None | ( 

542 PackagerProvidedFileReferenceDocumentation 

543 ) = None, 

544 ) -> None: 

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

546 

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

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

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

550 in the `debian/` directory. 

551 

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

553 via Python code. 

554 

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

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

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

558 

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

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

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

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

563 

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

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

566 

567 The following placeholders are supported: 

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

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

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

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

572 characters. 

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

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

575 

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

577 

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

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

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

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

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

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

584 always result in an error. 

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

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

587 error. 

588 

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

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

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

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

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

594 provide a default priority. 

595 

596 The following placeholders are supported: 

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

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

599 is not None) 

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

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

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

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

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

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

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

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

608 is a fallback for every package. 

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

610 packager_provided_file_reference_documentation function to provide the value for this parameter. 

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

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

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

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

615 """ 

616 

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

618 api.packager_provided_file( 

619 stem, 

620 installed_path, 

621 default_mode=default_mode, 

622 default_priority=default_priority, 

623 allow_name_segment=allow_name_segment, 

624 allow_architecture_segment=allow_architecture_segment, 

625 post_formatting_rewrite=post_formatting_rewrite, 

626 packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages, 

627 reservation_only=reservation_only, 

628 reference_documentation=reference_documentation, 

629 ) 

630 

631 self._generic_initializers.append(_init) 

632 

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

634 """Initialize the plugin from this definition 

635 

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

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

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

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

640 distracting for plugin maintenance. 

641 

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

643 """ 

644 

645 api_impl = cast( 

646 "DebputyPluginInitializerProvider", 

647 api, 

648 ) 

649 initializers = self._generic_initializers 

650 if not initializers: 

651 plugin_name = api_impl.plugin_metadata.plugin_name 

652 raise PluginInitializationError( 

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

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

655 ) 

656 

657 for initializer in initializers: 

658 initializer(api_impl) 

659 

660 

661def define_debputy_plugin() -> DebputyPluginDefinition: 

662 return DebputyPluginDefinition() 

663 

664 

665class DebputyPluginInitializer: 

666 __slots__ = () 

667 

668 def packager_provided_file( 

669 self, 

670 stem: str, 

671 installed_path: str, 

672 *, 

673 default_mode: int = 0o0644, 

674 default_priority: int | None = None, 

675 allow_name_segment: bool = True, 

676 allow_architecture_segment: bool = False, 

677 post_formatting_rewrite: Callable[[str], str] | None = None, 

678 packageless_is_fallback_for_all_packages: bool = False, 

679 reservation_only: bool = False, 

680 reference_documentation: None | ( 

681 PackagerProvidedFileReferenceDocumentation 

682 ) = None, 

683 ) -> None: 

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

685 

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

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

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

689 in the `debian/` directory. 

690 

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

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

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

694 

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

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

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

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

699 

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

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

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}` / `{priority:02}` - The priority of the file. Only provided priorities are used (that 

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

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

708 characters. 

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

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

711 

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

713 

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

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

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

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

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

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

720 always result in an error. 

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

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

723 error. 

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

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

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

727 provide a default priority. 

728 

729 The following placeholders are supported: 

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

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

732 is not None) 

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

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

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

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

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

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

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

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

741 is a fallback for every package. 

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

743 packager_provided_file_reference_documentation function to provide the value for this parameter. 

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

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

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

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

748 """ 

749 raise NotImplementedError 

750 

751 def metadata_or_maintscript_detector( 

752 self, 

753 auto_detector_id: str, 

754 auto_detector: MetadataAutoDetector, 

755 *, 

756 package_types: PackageTypeSelector = PackageTypeSelector.DEB, 

757 ) -> None: 

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

759 

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

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

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

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

764 

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

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

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

768 

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

770 any other hook. 

771 

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

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

774 

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

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

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

778 binary package). 

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

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

781 and ignore `udeb` packages. 

782 """ 

783 raise NotImplementedError 

784 

785 def manifest_variable( 

786 self, 

787 variable_name: str, 

788 value: str, 

789 *, 

790 variable_reference_documentation: str | None = None, 

791 ) -> None: 

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

793 

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

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

796 ... "path:BASH_COMPLETION_DIR", 

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

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

799 ... ) 

800 

801 :param variable_name: The variable name. 

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

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

804 the purpose of the variable. 

805 """ 

806 raise NotImplementedError 

807 

808 

809class MaintscriptAccessor: 

810 __slots__ = () 

811 

812 def on_configure( 

813 self, 

814 run_snippet: str, 

815 /, 

816 indent: bool | None = None, 

817 perform_substitution: bool = True, 

818 skip_on_rollback: bool = False, 

819 ) -> None: 

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

821 

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

823 common cases: 

824 * On initial install, OR 

825 * On upgrade 

826 

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

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

829 

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

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

832 

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

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

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

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

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

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

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

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

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

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

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

844 substitution is provided. 

845 """ 

846 raise NotImplementedError 

847 

848 def on_initial_install( 

849 self, 

850 run_snippet: str, 

851 /, 

852 indent: bool | None = None, 

853 perform_substitution: bool = True, 

854 ) -> None: 

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

856 

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

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

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

860 must still be idempotent): 

861 

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

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

864 

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

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

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

868 

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

870 `if [ "$1" = configure ] && [ -z "$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( 

885 self, 

886 run_snippet: str, 

887 /, 

888 indent: bool | None = None, 

889 perform_substitution: bool = True, 

890 ) -> None: 

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

892 

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

894 

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

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

897 

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

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

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

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

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

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

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

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

906 substitution is provided. 

907 """ 

908 raise NotImplementedError 

909 

910 def on_upgrade_from( 

911 self, 

912 version: str, 

913 run_snippet: str, 

914 /, 

915 indent: bool | None = None, 

916 perform_substitution: bool = True, 

917 ) -> None: 

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

919 

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

921 

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

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

924 

925 :param version: The version to upgrade from 

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_before_removal( 

939 self, 

940 run_snippet: str, 

941 /, 

942 indent: bool | None = None, 

943 perform_substitution: bool = True, 

944 ) -> None: 

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

946 

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

948 

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

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

951 

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

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

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

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

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

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

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

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

960 substitution is provided. 

961 """ 

962 raise NotImplementedError 

963 

964 def on_removed( 

965 self, 

966 run_snippet: str, 

967 /, 

968 indent: bool | None = None, 

969 perform_substitution: bool = True, 

970 ) -> None: 

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

972 

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

974 

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

976 

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

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

979 

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

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

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

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

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

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

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

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

988 substitution is provided. 

989 """ 

990 raise NotImplementedError 

991 

992 def on_purge( 

993 self, 

994 run_snippet: str, 

995 /, 

996 indent: bool | None = None, 

997 perform_substitution: bool = True, 

998 ) -> None: 

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

1000 

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

1002 

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

1004 

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

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

1007 

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

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

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

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

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

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

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

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

1016 substitution is provided. 

1017 """ 

1018 raise NotImplementedError 

1019 

1020 def unconditionally_in_script( 

1021 self, 

1022 maintscript: Maintscript, 

1023 run_snippet: str, 

1024 /, 

1025 perform_substitution: bool = True, 

1026 ) -> None: 

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

1028 

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

1030 for when it should be run. 

1031 

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

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

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

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

1036 substitutions by default. 

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

1038 substitution is provided. 

1039 """ 

1040 raise NotImplementedError 

1041 

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

1043 """Provide sh-shell escape of strings 

1044 

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

1046 

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

1048 contain spaces or shell meta-characters. 

1049 

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

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

1052 joined by a single space. 

1053 """ 

1054 return util.escape_shell(*args) 

1055 

1056 

1057class BinaryCtrlAccessor: 

1058 __slots__ = () 

1059 

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

1061 """Register a declarative dpkg level trigger 

1062 

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

1064 

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

1066 """ 

1067 raise NotImplementedError 

1068 

1069 @property 

1070 def maintscript(self) -> MaintscriptAccessor: 

1071 """Attribute for manipulating maintscripts""" 

1072 raise NotImplementedError 

1073 

1074 @property 

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

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

1077 raise NotImplementedError 

1078 

1079 

1080class VirtualPath: 

1081 __slots__ = () 

1082 

1083 @property 

1084 def name(self) -> str: 

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

1086 

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

1088 

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

1090 """ 

1091 raise NotImplementedError 

1092 

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

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

1095 

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

1097 the iterable is always empty. 

1098 """ 

1099 raise NotImplementedError 

1100 

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

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

1103 

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

1105 

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

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

1108 

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

1110 

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

1112 whether the lookup is relative or absolute. 

1113 

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

1115 

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

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

1118 to this path. 

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

1120 """ 

1121 raise NotImplementedError 

1122 

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

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

1125 

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

1127 

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

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

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

1131 defined. 

1132 

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

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

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

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

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

1138 

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

1140 """ 

1141 raise NotImplementedError 

1142 

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

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

1145 # behavior to avoid surprises for now. 

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

1147 # to using it) 

1148 __iter__ = None 

1149 

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

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

1152 

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

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

1155 

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

1157 """ 

1158 raise NotImplementedError 

1159 

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

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

1162 

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

1164 """ 

1165 raise NotImplementedError 

1166 

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

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

1169 

1170 The following are the same: 

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

1172 

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

1174 """ 

1175 try: 

1176 return self[key] 

1177 except KeyError: 

1178 return None 

1179 

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

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

1182 

1183 Examples: 

1184 

1185 if 'foo' in dir: ... 

1186 """ 

1187 if isinstance(item, VirtualPath): 

1188 return item.parent_dir is self 

1189 if not isinstance(item, str): 

1190 return False 

1191 m = self.get(item) 

1192 return m is not None 

1193 

1194 @property 

1195 def path(self) -> str: 

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

1197 

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

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

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

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

1202 you need if you want to read the file. 

1203 

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

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

1206 

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

1208 was known prior to being detached. 

1209 """ 

1210 raise NotImplementedError 

1211 

1212 @property 

1213 def absolute(self) -> str: 

1214 """Returns the absolute version of this path 

1215 

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

1217 

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

1219 of installation (prior to being detached). 

1220 

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

1222 """ 

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

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

1225 return f"/{p}" 

1226 return p 

1227 

1228 def is_root_dir(self) -> bool: 

1229 """Whether the current path is the root directory 

1230 

1231 :return: True if this path is the root directory. False otherwise 

1232 """ 

1233 # The root directory is never detachable in the current setup 

1234 raise NotImplementedError 

1235 

1236 @property 

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

1238 """The parent directory of this path 

1239 

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

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

1242 

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

1244 """ 

1245 raise NotImplementedError 

1246 

1247 @property 

1248 def size(self) -> int: 

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

1250 

1251 :return: The size of the file in bytes 

1252 """ 

1253 raise NotImplementedError 

1254 

1255 @property 

1256 def mode(self) -> int: 

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

1258 

1259 Note that: 

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

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

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

1263 to the underlying file system in many cases. 

1264 

1265 

1266 :return: The mode bits for the path. 

1267 """ 

1268 raise NotImplementedError 

1269 

1270 @mode.setter 

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

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

1273 

1274 Note that: 

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

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

1277 an optimization). 

1278 

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

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

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

1282 debug errors. 

1283 """ 

1284 raise NotImplementedError 

1285 

1286 @property 

1287 def is_executable(self) -> bool: 

1288 """Determine whether a path is considered executable 

1289 

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

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

1292 parameter to be traversable. 

1293 

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

1295 """ 

1296 return bool(self.mode & 0o0111) 

1297 

1298 def chmod(self, new_mode: int | str) -> None: 

1299 """Set the file mode of this path 

1300 

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

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

1303 

1304 Note that: 

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

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

1307 an optimization). 

1308 

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

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

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

1312 bits causes hard to debug errors. 

1313 """ 

1314 if isinstance(new_mode, str): 

1315 segments = parse_symbolic_mode(new_mode, None) 

1316 final_mode = self.mode 

1317 is_dir = self.is_dir 

1318 for segment in segments: 

1319 final_mode = segment.apply(final_mode, is_dir) 

1320 self.mode = final_mode 

1321 else: 

1322 self.mode = new_mode 

1323 

1324 def chown( 

1325 self, 

1326 owner: Optional["StaticFileSystemOwner"], 

1327 group: Optional["StaticFileSystemGroup"], 

1328 ) -> None: 

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

1330 

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

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

1333 """ 

1334 raise NotImplementedError 

1335 

1336 @property 

1337 def mtime(self) -> float: 

1338 """Determine the mtime of this path object 

1339 

1340 Note that: 

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

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

1343 normalization is handled later by `debputy`. 

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

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

1346 to the underlying file system in many cases. 

1347 

1348 :return: The mtime for the path. 

1349 """ 

1350 raise NotImplementedError 

1351 

1352 @mtime.setter 

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

1354 """Set the mtime of this path 

1355 

1356 Note that: 

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

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

1359 an optimization). 

1360 

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

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

1363 """ 

1364 raise NotImplementedError 

1365 

1366 def readlink(self) -> str: 

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

1368 

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

1370 `has_fs_path` is False. 

1371 

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

1373 """ 

1374 raise NotImplementedError() 

1375 

1376 @overload 

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

1378 self, 

1379 *, 

1380 byte_io: Literal[False] = False, 

1381 buffering: int = -1, 

1382 ) -> TextIO: ... 

1383 

1384 @overload 

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

1386 self, 

1387 *, 

1388 byte_io: Literal[True], 

1389 buffering: Literal[0] = ..., 

1390 ) -> io.FileIO: ... 

1391 

1392 @overload 

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

1394 self, 

1395 *, 

1396 byte_io: Literal[True], 

1397 buffering: int = -1, 

1398 ) -> io.BufferedReader: ... 

1399 

1400 def open(self, *, byte_io=False, buffering=-1): 

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

1402 

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

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

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

1406 

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

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

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

1410 

1411 

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

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

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

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

1416 :return: The file handle. 

1417 """ 

1418 

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

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

1421 

1422 if byte_io: 

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

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

1425 

1426 @property 

1427 def fs_path(self) -> str: 

1428 """Request the underling fs_path of this path 

1429 

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

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

1432 

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

1434 multiple paths pointing to the same file system path. 

1435 

1436 Note that: 

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

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

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

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

1441 internal invariants. 

1442 

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

1444 file exist (see `has_fs_path`). 

1445 """ 

1446 raise NotImplementedError() 

1447 

1448 @property 

1449 def is_dir(self) -> bool: 

1450 """Determine if this path is a directory 

1451 

1452 Never follows symlinks. 

1453 

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

1455 """ 

1456 raise NotImplementedError() 

1457 

1458 @property 

1459 def is_file(self) -> bool: 

1460 """Determine if this path is a directory 

1461 

1462 Never follows symlinks. 

1463 

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

1465 """ 

1466 raise NotImplementedError() 

1467 

1468 @property 

1469 def is_symlink(self) -> bool: 

1470 """Determine if this path is a symlink 

1471 

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

1473 """ 

1474 raise NotImplementedError() 

1475 

1476 @property 

1477 def has_fs_path(self) -> bool: 

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

1479 

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

1481 """ 

1482 raise NotImplementedError() 

1483 

1484 @property 

1485 def is_read_write(self) -> bool: 

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

1487 

1488 Read-write rules are: 

1489 

1490 +--------------------------+-------------------+------------------------+ 

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

1492 +--------------------------+-------------------+------------------------+ 

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

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

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

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

1497 

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

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

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

1501 optimizations. 

1502 

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

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

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

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

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

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

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

1510 

1511 :return: Whether file system mutations are permitted. 

1512 """ 

1513 return False 

1514 

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

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

1517 

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

1519 with this basename. 

1520 :return: The new subdirectory 

1521 """ 

1522 raise NotImplementedError 

1523 

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

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

1526 

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

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

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

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

1531 :return: The directory denoted by the given path 

1532 """ 

1533 raise NotImplementedError 

1534 

1535 def add_file( 

1536 self, 

1537 name: str, 

1538 *, 

1539 unlink_if_exists: bool = True, 

1540 use_fs_path_mode: bool = False, 

1541 mode: int = 0o0644, 

1542 mtime: float | None = None, 

1543 ) -> ContextManager["VirtualPath"]: 

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

1545 

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

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

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

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

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

1551 empty file when the context manager is entered. 

1552 

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

1554 

1555 >>> import subprocess 

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

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

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

1559 

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

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

1562 

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

1564 

1565 :param name: Basename of the new file 

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

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

1568 (when `unlink_if_exists` is True) 

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

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

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

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

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

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

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

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

1577 this interacts with the physical file system. 

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

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

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

1581 should be earlier than `SOURCE_DATE_EPOCH`. 

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

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

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

1585 manager exits 

1586 """ 

1587 raise NotImplementedError 

1588 

1589 def replace_fs_path_content( 

1590 self, 

1591 *, 

1592 use_fs_path_mode: bool = False, 

1593 ) -> ContextManager[str]: 

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

1595 

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

1597 

1598 Example: 

1599 >>> import subprocess 

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

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

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

1603 

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

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

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

1607 

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

1609 

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

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

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

1613 reliably restore the path. 

1614 

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

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

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

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

1619 definition. 

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

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

1622 the caller wishes until the context manager exits. 

1623 """ 

1624 raise NotImplementedError 

1625 

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

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

1628 

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

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

1631 

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

1633 

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

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

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

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

1638 :return: The newly created symlink. 

1639 """ 

1640 raise NotImplementedError 

1641 

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

1643 """Unlink a file or a directory 

1644 

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

1646 

1647 When the path is a: 

1648 

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

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

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

1652 

1653 Note that: 

1654 * the root directory cannot be deleted. 

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

1656 

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

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

1659 """ 

1660 raise NotImplementedError 

1661 

1662 def interpreter(self) -> Interpreter | None: 

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

1664 

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

1666 

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

1668 """ 

1669 if not self.is_file: 

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

1671 try: 

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

1673 return extract_shebang_interpreter_from_file(fd) 

1674 except (PureVirtualPathError, TestPathWithNonExistentFSPathError): 

1675 return None 

1676 

1677 def metadata( 

1678 self, 

1679 metadata_type: type[PMT], 

1680 ) -> PathMetadataReference[PMT]: 

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

1682 

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

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

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

1686 

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

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

1689 

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

1691 

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

1693 :return: A reference to the metadata. 

1694 """ 

1695 raise NotImplementedError 

1696 

1697 

1698class FlushableSubstvars(Substvars): 

1699 __slots__ = () 

1700 

1701 @contextlib.contextmanager 

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

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

1704 

1705 >>> s = FlushableSubstvars() 

1706 >>> 'Test:Var' in s 

1707 False 

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

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

1710 >>> 'Test:Var' in s 

1711 True 

1712 

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

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

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

1716 

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

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

1719 the substvars. 

1720 

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

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

1723 terminates successfully. 

1724 

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

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

1727 successfully. 

1728 """ 

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

1730 self.write_substvars(tmp) 

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

1732 yield tmp.name 

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

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

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

1736 self.read_substvars(fd) 

1737 

1738 def save(self) -> None: 

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

1740 if self._substvars_path is None: 

1741 raise TypeError( 

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

1743 ) 

1744 super().save() 

1745 

1746 

1747class ServiceRegistry(Generic[DSD]): 

1748 __slots__ = () 

1749 

1750 def register_service( 

1751 self, 

1752 path: VirtualPath, 

1753 name: str | list[str], 

1754 *, 

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

1756 service_scope: str = "system", 

1757 enable_by_default: bool = True, 

1758 start_by_default: bool = True, 

1759 default_upgrade_rule: ServiceUpgradeRule = "restart", 

1760 service_context: DSD | None = None, 

1761 ) -> None: 

1762 """Register a service detected in the package 

1763 

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

1765 integration code is called. 

1766 

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

1768 

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

1770 2) Their plugin provided names has an overlap 

1771 

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

1773 

1774 :param path: The path defining this service. 

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

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

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

1778 to identify this service. 

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

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

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

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

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

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

1785 packager does not explicitly override this setting. 

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

1787 the packager does not explicitly override this setting. 

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

1789 upgrades. Options are: 

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

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

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

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

1794 start the service if not is not already running. 

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

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

1797 start the service if not is not already running. 

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

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

1800 

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

1802 integrator for this service. 

1803 """ 

1804 raise NotImplementedError 

1805 

1806 

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

1808class ParserAttributeDocumentation: 

1809 attributes: frozenset[str] 

1810 description: str | None 

1811 

1812 @property 

1813 def is_hidden(self) -> bool: 

1814 return False 

1815 

1816 

1817@final 

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

1819class StandardParserAttributeDocumentation(ParserAttributeDocumentation): 

1820 sort_category: int = 0 

1821 

1822 

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

1824 """Describe an attribute as undocumented 

1825 

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

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

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

1829 

1830 :param attr: Name of the attribute 

1831 """ 

1832 return ParserAttributeDocumentation( 

1833 frozenset({attr}), 

1834 None, 

1835 ) 

1836 

1837 

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

1839class ParserDocumentation: 

1840 synopsis: str | None = None 

1841 title: str | None = None 

1842 description: str | None = None 

1843 attribute_doc: Sequence[ParserAttributeDocumentation] | None = None 

1844 alt_parser_description: str | None = None 

1845 documentation_reference_url: str | None = None 

1846 

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

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

1849 

1850 @classmethod 

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

1852 attr = [ 

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

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

1855 ] 

1856 undoc_attr = ref_doc.get("undocumented_attributes") 

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

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

1859 

1860 return reference_documentation( 

1861 title=ref_doc["title"], 

1862 description=ref_doc["description"], 

1863 attributes=attr, 

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

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

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

1867 ) 

1868 

1869 

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

1871class TypeMappingExample(Generic[S]): 

1872 source_input: S 

1873 

1874 

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

1876class TypeMappingDocumentation(Generic[S]): 

1877 description: str | None = None 

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

1879 

1880 

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

1882 return TypeMappingExample(source_input) 

1883 

1884 

1885def type_mapping_reference_documentation( 

1886 *, 

1887 description: str | None = None, 

1888 examples: TypeMappingExample[S] | Iterable[TypeMappingExample[S]] = tuple(), 

1889) -> TypeMappingDocumentation[S]: 

1890 e = ( 

1891 tuple([examples]) 

1892 if isinstance(examples, TypeMappingExample) 

1893 else tuple(examples) 

1894 ) 

1895 return TypeMappingDocumentation( 

1896 description=description, 

1897 examples=e, 

1898 ) 

1899 

1900 

1901def documented_attr( 

1902 attr: str | Iterable[str], 

1903 description: str, 

1904) -> ParserAttributeDocumentation: 

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

1906 

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

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

1909 

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

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

1912 target attribute). 

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

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

1915 the description of `reference_documentation`. 

1916 :return: An opaque representation of the documentation, 

1917 """ 

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

1919 return ParserAttributeDocumentation( 

1920 frozenset(attributes), 

1921 description, 

1922 ) 

1923 

1924 

1925def reference_documentation( 

1926 title: str | None = None, 

1927 description: str | None = textwrap.dedent( 

1928 """\ 

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

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

1931 

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

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

1934 the manifest rule.) 

1935 """ 

1936 ), 

1937 attributes: Sequence[ParserAttributeDocumentation] | None = None, 

1938 non_mapping_description: str | None = None, 

1939 reference_documentation_url: str | None = None, 

1940 synopsis: str | None = None, 

1941) -> ParserDocumentation: 

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

1943 

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

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

1946 

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

1948 the alias provided by the user. 

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

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

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

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

1953 

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

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

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

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

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

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

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

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

1962 attributes exactly once. 

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

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

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

1966 the above listed variables. 

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

1968 :return: An opaque representation of the documentation, 

1969 """ 

1970 if title is None: 

1971 title = "Auto-generated reference documentation for ${RULE_NAME}" 

1972 return ParserDocumentation( 

1973 synopsis, 

1974 title, 

1975 description, 

1976 attributes, 

1977 non_mapping_description, 

1978 reference_documentation_url, 

1979 ) 

1980 

1981 

1982class ServiceDefinition(Generic[DSD]): 

1983 __slots__ = () 

1984 

1985 @property 

1986 def name(self) -> str: 

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

1988 

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

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

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

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

1993 

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

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

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

1997 is needed. 

1998 """ 

1999 raise NotImplementedError 

2000 

2001 @property 

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

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

2004 

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

2006 the service earlier. 

2007 """ 

2008 raise NotImplementedError 

2009 

2010 @property 

2011 def path(self) -> VirtualPath: 

2012 """The registered path for this service 

2013 

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

2015 earlier. 

2016 """ 

2017 raise NotImplementedError 

2018 

2019 @property 

2020 def type_of_service(self) -> str: 

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

2022 

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

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

2025 """ 

2026 raise NotImplementedError 

2027 

2028 @property 

2029 def service_scope(self) -> str: 

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

2031 

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

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

2034 """ 

2035 raise NotImplementedError 

2036 

2037 @property 

2038 def auto_enable_on_install(self) -> bool: 

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

2040 

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

2042 """ 

2043 raise NotImplementedError 

2044 

2045 @property 

2046 def auto_start_on_install(self) -> bool: 

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

2048 

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

2050 """ 

2051 raise NotImplementedError 

2052 

2053 @property 

2054 def on_upgrade(self) -> ServiceUpgradeRule: 

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

2056 

2057 Options are: 

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

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

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

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

2062 start the service if not is not already running. 

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

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

2065 start the service if not is not already running. 

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

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

2068 

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

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

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

2072 being uninstalled. 

2073 

2074 :return: The service restart rule 

2075 """ 

2076 raise NotImplementedError 

2077 

2078 @property 

2079 def definition_source(self) -> str: 

2080 """Describes where this definition came from 

2081 

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

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

2084 to the plugin providing this definition. 

2085 

2086 :return: The source of this definition 

2087 """ 

2088 raise NotImplementedError 

2089 

2090 @property 

2091 def is_plugin_provided_definition(self) -> bool: 

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

2093 

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

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

2096 """ 

2097 raise NotImplementedError 

2098 

2099 @property 

2100 def service_context(self) -> DSD | None: 

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

2102 

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

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

2105 then this attribute will be None. 

2106 """ 

2107 raise NotImplementedError