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

376 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-04-11 08:31 +0000

1import contextlib 

2import dataclasses 

3import io 

4import tempfile 

5import textwrap 

6from typing import ( 

7 Literal, 

8 overload, 

9 TypeVar, 

10 Any, 

11 TYPE_CHECKING, 

12 TextIO, 

13 Generic, 

14 ContextManager, 

15 get_args, 

16 final, 

17 cast, 

18) 

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

20 

21from debian.debian_support import DpkgArchTable 

22from debian.substvars import Substvars 

23 

24from debputy import util 

25from debputy._deb_options_profiles import DebBuildOptionsAndProfiles 

26from debputy.exceptions import ( 

27 TestPathWithNonExistentFSPathError, 

28 PureVirtualPathError, 

29 PluginInitializationError, 

30) 

31from debputy.interpreter import Interpreter, extract_shebang_interpreter_from_file 

32from debputy.manifest_conditions import ConditionContext 

33from debputy.manifest_parser.tagging_types import DebputyDispatchableType 

34from debputy.manifest_parser.util import parse_symbolic_mode 

35from debputy.packages import BinaryPackage, SourcePackage 

36from debputy.types import S 

37from debputy.util import PackageTypeSelector 

38 

39if TYPE_CHECKING: 

40 from debputy.manifest_parser.base_types import ( 

41 StaticFileSystemOwner, 

42 StaticFileSystemGroup, 

43 ) 

44 from debputy.plugin.api.doc_parsing import ParserRefDocumentation 

45 from debputy.plugin.api.impl_types import DIPHandler 

46 from debputy.plugins.debputy.to_be_api_types import BuildSystemRule 

47 from debputy.plugin.api.impl import DebputyPluginInitializerProvider 

48 

49 

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

51 

52 

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

54MetadataAutoDetector = Callable[ 

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

56] 

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

58DpkgTriggerType = Literal[ 

59 "activate", 

60 "activate-await", 

61 "activate-noawait", 

62 "interest", 

63 "interest-await", 

64 "interest-noawait", 

65] 

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

67 

68ServiceUpgradeRule = Literal[ 

69 "do-nothing", 

70 "reload", 

71 "restart", 

72 "stop-then-start", 

73] 

74 

75DSD = TypeVar("DSD") 

76ServiceDetector = Callable[ 

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

78 None, 

79] 

80ServiceIntegrator = Callable[ 

81 [ 

82 Sequence["ServiceDefinition[DSD]"], 

83 "BinaryCtrlAccessor", 

84 "PackageProcessingContext", 

85 ], 

86 None, 

87] 

88 

89PMT = TypeVar("PMT") 

90DebputyIntegrationMode = Literal[ 

91 "full", 

92 "dh-sequence-zz-debputy", 

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

94] 

95 

96INTEGRATION_MODE_FULL: DebputyIntegrationMode = "full" 

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

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

99ALL_DEBPUTY_INTEGRATION_MODES: frozenset[DebputyIntegrationMode] = frozenset( 

100 get_args(DebputyIntegrationMode) 

101) 

102 

103_DEBPUTY_DISPATCH_METADATA_ATTR_NAME = "_debputy_dispatch_metadata" 

104 

105 

106def only_integrations( 

107 *integrations: DebputyIntegrationMode, 

108) -> Container[DebputyIntegrationMode]: 

109 return frozenset(integrations) 

110 

111 

112def not_integrations( 

113 *integrations: DebputyIntegrationMode, 

114) -> Container[DebputyIntegrationMode]: 

115 return ALL_DEBPUTY_INTEGRATION_MODES - frozenset(integrations) 

116 

117 

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

119class PackagerProvidedFileReferenceDocumentation: 

120 description: str | None = None 

121 format_documentation_uris: Sequence[str] = tuple() 

122 

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

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

125 

126 

127def packager_provided_file_reference_documentation( 

128 *, 

129 description: str | None = None, 

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

131) -> PackagerProvidedFileReferenceDocumentation: 

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

133 

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

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

136 the format of the file. Most relevant first. 

137 :return: 

138 """ 

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

140 return PackagerProvidedFileReferenceDocumentation( 

141 description=description, 

142 format_documentation_uris=uris, 

143 ) 

144 

145 

146class PathMetadataReference(Generic[PMT]): 

147 """An accessor to plugin provided metadata 

148 

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

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

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

152 """ 

153 

154 @property 

155 def is_present(self) -> bool: 

156 """Determine whether the value has been set 

157 

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

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

160 

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

162 Otherwise, this property is `False`. 

163 """ 

164 raise NotImplementedError 

165 

166 @property 

167 def can_read(self) -> bool: 

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

169 

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

171 

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

173 owning plugin. 

174 """ 

175 raise NotImplementedError 

176 

177 @property 

178 def can_write(self) -> bool: 

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

180 

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

182 """ 

183 raise NotImplementedError 

184 

185 @property 

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

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

188 

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

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

191 """ 

192 raise NotImplementedError 

193 

194 @value.setter 

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

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

197 

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

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

200 """ 

201 raise NotImplementedError 

202 

203 @value.deleter 

204 def value(self) -> None: 

205 """Delete any current value. 

206 

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

208 as the value setter. 

209 """ 

210 self.value = None 

211 

212 

213@dataclasses.dataclass(slots=True) 

214class PathDef: 

215 path_name: str 

216 mode: int | None = None 

217 mtime: int | None = None 

218 has_fs_path: bool | None = None 

219 fs_path: str | None = None 

220 link_target: str | None = None 

221 content: str | None = None 

222 materialized_content: str | None = None 

223 

224 

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

226class DispatchablePluggableManifestRuleMetadata(Generic[DP]): 

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

228 

229 manifest_keywords: Sequence[str] 

230 dispatched_type: type[DP] 

231 unwrapped_constructor: "DIPHandler" 

232 expected_debputy_integration_mode: Container[DebputyIntegrationMode] | None = None 

233 online_reference_documentation: "ParserDocumentation | None" = None 

234 apply_standard_attribute_documentation: bool = False 

235 source_format: Any | None = None 

236 

237 

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

239class BuildSystemManifestRuleMetadata(DispatchablePluggableManifestRuleMetadata): 

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

241 auto_detection_shadow_build_systems: frozenset[str] = frozenset() 

242 

243 

244def virtual_path_def( 

245 path_name: str, 

246 /, 

247 mode: int | None = None, 

248 mtime: int | None = None, 

249 fs_path: str | None = None, 

250 link_target: str | None = None, 

251 content: str | None = None, 

252 materialized_content: str | None = None, 

253) -> PathDef: 

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

255 

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

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

258 on whether a `link_target` is provided. 

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

260 should be None for symlinks. 

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

262 if the mtime attribute is accessed. 

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

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

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

266 to resolve defaults from the path. 

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

268 path a symlink. 

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

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

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

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

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

274 """ 

275 

276 is_dir = path_name.endswith("/") 

277 is_symlink = link_target is not None 

278 

279 if is_symlink: 

280 if mode is not None: 

281 raise ValueError( 

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

283 ) 

284 if is_dir: 

285 raise ValueError( 

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

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

288 ) 

289 

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

291 raise ValueError( 

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

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

294 ) 

295 

296 if materialized_content is not None: 

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

298 raise ValueError( 

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

300 f' Triggered by "{path_name}"' 

301 ) 

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

303 raise ValueError( 

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

305 f' Triggered by "{path_name}"' 

306 ) 

307 return PathDef( 

308 path_name, 

309 mode=mode, 

310 mtime=mtime, 

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

312 fs_path=fs_path, 

313 link_target=link_target, 

314 content=content, 

315 materialized_content=materialized_content, 

316 ) 

317 

318 

319class PackageProcessingContext: 

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

321 

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

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

324 """ 

325 

326 __slots__ = () 

327 

328 @property 

329 def source_package(self) -> SourcePackage: 

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

331 raise NotImplementedError 

332 

333 @property 

334 def binary_package(self) -> BinaryPackage: 

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

336 raise NotImplementedError 

337 

338 @property 

339 def binary_package_version(self) -> str: 

340 """The version of the binary package 

341 

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

343 """ 

344 raise NotImplementedError 

345 

346 @property 

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

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

349 raise NotImplementedError 

350 

351 @property 

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

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

354 

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

356 """ 

357 raise NotImplementedError 

358 

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

360 raise NotImplementedError 

361 

362 def manifest_configuration[T]( 

363 self, 

364 context_package: SourcePackage | BinaryPackage, 

365 value_type: type[T], 

366 ) -> T | None: 

367 """Request access to configuration from the manifest 

368 

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

370 said configuration was provided. 

371 

372 

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

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

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

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

377 """ 

378 raise NotImplementedError 

379 

380 @property 

381 def dpkg_arch_query_table(self) -> DpkgArchTable: 

382 raise NotImplementedError 

383 

384 @property 

385 def deb_options_and_profiles(self) -> DebBuildOptionsAndProfiles: 

386 raise NotImplementedError 

387 

388 @property 

389 def source_condition_context(self) -> ConditionContext: 

390 raise NotImplementedError 

391 

392 def condition_context( 

393 self, binary_package: BinaryPackage | None 

394 ) -> ConditionContext: 

395 raise NotImplementedError 

396 

397 @property 

398 def binary_condition_context(self) -> ConditionContext: 

399 return self.condition_context(self.binary_package) 

400 

401 

402class DebputyPluginDefinition: 

403 """Plugin definition entity 

404 

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

406 accessors. 

407 """ 

408 

409 __slots__ = ("_generic_initializers",) 

410 

411 def __init__(self) -> None: 

412 self._generic_initializers: list[ 

413 Callable[["DebputyPluginInitializerProvider"], None] 

414 ] = [] 

415 

416 @staticmethod 

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

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

419 return provided_id 

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

421 name = name[:-1] 

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

423 

424 def metadata_or_maintscript_detector( 

425 self, 

426 func: MetadataAutoDetector | None = None, 

427 *, 

428 detector_id: str | None = None, 

429 package_types: PackageTypeSelector = PackageTypeSelector.DEB, 

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

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

432 

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

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

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

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

437 

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

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

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

441 

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

443 any other hook. 

444 

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

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

447 

448 

449 >>> plugin_definition = define_debputy_plugin() 

450 >>> @plugin_definition.metadata_or_maintscript_detector 

451 ... def gsettings_dependencies( 

452 ... fs_root: "VirtualPath", 

453 ... ctrl: "BinaryCtrlAccessor", 

454 ... context: "PackageProcessingContext", 

455 ... ) -> None: 

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

457 ... if gsettings_schema_dir is None: 

458 ... return 

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

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

461 ... ctrl.substvars.add_dependency( 

462 ... "misc:Depends", 

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

464 ... ) 

465 ... break 

466 

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

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

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

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

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

472 and ignore `udeb` packages. 

473 """ 

474 

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

476 

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

478 

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

480 api.metadata_or_maintscript_detector( 

481 final_id, 

482 f, 

483 package_types=package_types, 

484 ) 

485 

486 self._generic_initializers.append(_init) 

487 

488 return f 

489 

490 if func: 

491 return _decorate(func) 

492 return _decorate 

493 

494 def manifest_variable( 

495 self, 

496 variable_name: str, 

497 value: str, 

498 *, 

499 variable_reference_documentation: str | None = None, 

500 ) -> None: 

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

502 

503 >>> plugin_definition = define_debputy_plugin() 

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

505 >>> plugin_definition.manifest_variable( 

506 ... "path:BASH_COMPLETION_DIR", 

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

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

509 ... ) 

510 

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

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

513 dynamic / context based values at this time. 

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

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

516 docs. 

517 """ 

518 

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

520 api.manifest_variable( 

521 variable_name, 

522 value, 

523 variable_reference_documentation=variable_reference_documentation, 

524 ) 

525 

526 self._generic_initializers.append(_init) 

527 

528 def packager_provided_file( 

529 self, 

530 stem: str, 

531 installed_path: str, 

532 *, 

533 default_mode: int = 0o0644, 

534 default_priority: int | None = None, 

535 allow_name_segment: bool = True, 

536 allow_architecture_segment: bool = False, 

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

538 packageless_is_fallback_for_all_packages: bool = False, 

539 reservation_only: bool = False, 

540 reference_documentation: None | ( 

541 PackagerProvidedFileReferenceDocumentation 

542 ) = None, 

543 ) -> None: 

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

545 

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

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

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

549 in the `debian/` directory. 

550 

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

552 via Python code. 

553 

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

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

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

557 

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

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

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

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

562 

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

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

565 

566 The following placeholders are supported: 

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

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

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

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

571 characters. 

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

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

574 

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

576 

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

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

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

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

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

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

583 always result in an error. 

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

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

586 error. 

587 

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

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

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

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

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

593 provide a default priority. 

594 

595 The following placeholders are supported: 

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

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

598 is not None) 

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

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

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

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

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

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

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

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

607 is a fallback for every package. 

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

609 packager_provided_file_reference_documentation function to provide the value for this parameter. 

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

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

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

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

614 """ 

615 

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

617 api.packager_provided_file( 

618 stem, 

619 installed_path, 

620 default_mode=default_mode, 

621 default_priority=default_priority, 

622 allow_name_segment=allow_name_segment, 

623 allow_architecture_segment=allow_architecture_segment, 

624 post_formatting_rewrite=post_formatting_rewrite, 

625 packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages, 

626 reservation_only=reservation_only, 

627 reference_documentation=reference_documentation, 

628 ) 

629 

630 self._generic_initializers.append(_init) 

631 

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

633 """Initialize the plugin from this definition 

634 

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

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

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

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

639 distracting for plugin maintenance. 

640 

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

642 """ 

643 

644 api_impl = cast( 

645 "DebputyPluginInitializerProvider", 

646 api, 

647 ) 

648 initializers = self._generic_initializers 

649 if not initializers: 

650 plugin_name = api_impl.plugin_metadata.plugin_name 

651 raise PluginInitializationError( 

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

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

654 ) 

655 

656 for initializer in initializers: 

657 initializer(api_impl) 

658 

659 

660def define_debputy_plugin() -> DebputyPluginDefinition: 

661 return DebputyPluginDefinition() 

662 

663 

664class DebputyPluginInitializer: 

665 __slots__ = () 

666 

667 def packager_provided_file( 

668 self, 

669 stem: str, 

670 installed_path: str, 

671 *, 

672 default_mode: int = 0o0644, 

673 default_priority: int | None = None, 

674 allow_name_segment: bool = True, 

675 allow_architecture_segment: bool = False, 

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

677 packageless_is_fallback_for_all_packages: bool = False, 

678 package_types: PackageTypeSelector = PackageTypeSelector.ALL, 

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 :param package_types: Which package types this packager provided file applies to. This can be used to 

749 exclude the packager provided file from `udeb`s. 

750 """ 

751 raise NotImplementedError 

752 

753 def metadata_or_maintscript_detector( 

754 self, 

755 auto_detector_id: str, 

756 auto_detector: MetadataAutoDetector, 

757 *, 

758 package_types: PackageTypeSelector = PackageTypeSelector.DEB, 

759 ) -> None: 

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

761 

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

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

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

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

766 

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

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

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

770 

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

772 any other hook. 

773 

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

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

776 

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

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

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

780 binary package). 

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

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

783 and ignore `udeb` packages. 

784 """ 

785 raise NotImplementedError 

786 

787 def manifest_variable( 

788 self, 

789 variable_name: str, 

790 value: str, 

791 *, 

792 variable_reference_documentation: str | None = None, 

793 ) -> None: 

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

795 

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

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

798 ... "path:BASH_COMPLETION_DIR", 

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

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

801 ... ) 

802 

803 :param variable_name: The variable name. 

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

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

806 the purpose of the variable. 

807 """ 

808 raise NotImplementedError 

809 

810 

811class MaintscriptAccessor: 

812 __slots__ = () 

813 

814 def on_configure( 

815 self, 

816 run_snippet: str, 

817 /, 

818 indent: bool | None = None, 

819 perform_substitution: bool = True, 

820 skip_on_rollback: bool = False, 

821 ) -> None: 

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

823 

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

825 common cases: 

826 * On initial install, OR 

827 * On upgrade 

828 

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

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

831 

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

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

834 

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

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

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

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

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

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

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

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

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

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

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

846 substitution is provided. 

847 """ 

848 raise NotImplementedError 

849 

850 def on_initial_install( 

851 self, 

852 run_snippet: str, 

853 /, 

854 indent: bool | None = None, 

855 perform_substitution: bool = True, 

856 ) -> None: 

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

858 

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

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

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

862 must still be idempotent): 

863 

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

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

866 

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

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

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

870 

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

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

873 

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

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

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

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

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

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

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

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

882 substitution is provided. 

883 """ 

884 raise NotImplementedError 

885 

886 def on_upgrade( 

887 self, 

888 run_snippet: str, 

889 /, 

890 indent: bool | None = None, 

891 perform_substitution: bool = True, 

892 ) -> None: 

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

894 

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

896 

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

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

899 

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

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

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

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

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

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

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

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

908 substitution is provided. 

909 """ 

910 raise NotImplementedError 

911 

912 def on_upgrade_from( 

913 self, 

914 version: str, 

915 run_snippet: str, 

916 /, 

917 indent: bool | None = None, 

918 perform_substitution: bool = True, 

919 ) -> None: 

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

921 

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

923 

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

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

926 

927 :param version: The version to upgrade from 

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

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

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

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

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

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

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

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

936 substitution is provided. 

937 """ 

938 raise NotImplementedError 

939 

940 def on_before_removal( 

941 self, 

942 run_snippet: str, 

943 /, 

944 indent: bool | None = None, 

945 perform_substitution: bool = True, 

946 ) -> None: 

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

948 

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

950 

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

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

953 

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

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

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

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

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

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

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

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

962 substitution is provided. 

963 """ 

964 raise NotImplementedError 

965 

966 def on_removed( 

967 self, 

968 run_snippet: str, 

969 /, 

970 indent: bool | None = None, 

971 perform_substitution: bool = True, 

972 ) -> None: 

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

974 

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

976 

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

978 

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

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

981 

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

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

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

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

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

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

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

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

990 substitution is provided. 

991 """ 

992 raise NotImplementedError 

993 

994 def on_purge( 

995 self, 

996 run_snippet: str, 

997 /, 

998 indent: bool | None = None, 

999 perform_substitution: bool = True, 

1000 ) -> None: 

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

1002 

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

1004 

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

1006 

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

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

1009 

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

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

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

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

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

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

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

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

1018 substitution is provided. 

1019 """ 

1020 raise NotImplementedError 

1021 

1022 def unconditionally_in_script( 

1023 self, 

1024 maintscript: Maintscript, 

1025 run_snippet: str, 

1026 /, 

1027 perform_substitution: bool = True, 

1028 ) -> None: 

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

1030 

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

1032 for when it should be run. 

1033 

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

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

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

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

1038 substitutions by default. 

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

1040 substitution is provided. 

1041 """ 

1042 raise NotImplementedError 

1043 

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

1045 """Provide sh-shell escape of strings 

1046 

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

1048 

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

1050 contain spaces or shell meta-characters. 

1051 

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

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

1054 joined by a single space. 

1055 """ 

1056 return util.escape_shell(*args) 

1057 

1058 

1059class BinaryCtrlAccessor: 

1060 __slots__ = () 

1061 

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

1063 """Register a declarative dpkg level trigger 

1064 

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

1066 

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

1068 """ 

1069 raise NotImplementedError 

1070 

1071 @property 

1072 def maintscript(self) -> MaintscriptAccessor: 

1073 """Attribute for manipulating maintscripts""" 

1074 raise NotImplementedError 

1075 

1076 @property 

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

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

1079 raise NotImplementedError 

1080 

1081 

1082class VirtualPath: 

1083 __slots__ = () 

1084 

1085 @property 

1086 def name(self) -> str: 

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

1088 

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

1090 

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

1092 """ 

1093 raise NotImplementedError 

1094 

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

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

1097 

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

1099 the iterable is always empty. 

1100 """ 

1101 raise NotImplementedError 

1102 

1103 def lookup(self, path: str) -> "VirtualPath | None": 

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

1105 

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

1107 

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

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

1110 

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

1112 

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

1114 whether the lookup is relative or absolute. 

1115 

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

1117 

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

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

1120 to this path. 

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

1122 """ 

1123 raise NotImplementedError 

1124 

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

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

1127 

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

1129 

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

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

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

1133 defined. 

1134 

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

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

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

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

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

1140 

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

1142 """ 

1143 raise NotImplementedError 

1144 

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

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

1147 # behavior to avoid surprises for now. 

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

1149 # to using it) 

1150 __iter__ = None 

1151 

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

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

1154 

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

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

1157 

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

1159 """ 

1160 raise NotImplementedError 

1161 

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

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

1164 

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

1166 """ 

1167 raise NotImplementedError 

1168 

1169 def get(self, key: str) -> "VirtualPath | None": 

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

1171 

1172 The following are the same: 

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

1174 

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

1176 """ 

1177 try: 

1178 return self[key] 

1179 except KeyError: 

1180 return None 

1181 

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

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

1184 

1185 Examples: 

1186 

1187 if 'foo' in dir: ... 

1188 """ 

1189 if isinstance(item, VirtualPath): 1189 ↛ 1190line 1189 didn't jump to line 1190 because the condition on line 1189 was never true

1190 return item.parent_dir is self 

1191 if not isinstance(item, str): 1191 ↛ 1192line 1191 didn't jump to line 1192 because the condition on line 1191 was never true

1192 return False 

1193 m = self.get(item) 

1194 return m is not None 

1195 

1196 @property 

1197 def path(self) -> str: 

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

1199 

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

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

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

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

1204 you need if you want to read the file. 

1205 

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

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

1208 

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

1210 was known prior to being detached. 

1211 """ 

1212 raise NotImplementedError 

1213 

1214 @property 

1215 def absolute(self) -> str: 

1216 """Returns the absolute version of this path 

1217 

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

1219 

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

1221 of installation (prior to being detached). 

1222 

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

1224 """ 

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

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

1227 return f"/{p}" 

1228 return p 

1229 

1230 def is_root_dir(self) -> bool: 

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

1232 

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

1234 """ 

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

1236 raise NotImplementedError 

1237 

1238 @property 

1239 def parent_dir(self) -> "VirtualPath | None": 

1240 """The parent directory of this path 

1241 

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

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

1244 

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

1246 """ 

1247 raise NotImplementedError 

1248 

1249 @property 

1250 def size(self) -> int: 

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

1252 

1253 :return: The size of the file in bytes 

1254 """ 

1255 raise NotImplementedError 

1256 

1257 @property 

1258 def mode(self) -> int: 

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

1260 

1261 Note that: 

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

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

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

1265 to the underlying file system in many cases. 

1266 

1267 

1268 :return: The mode bits for the path. 

1269 """ 

1270 raise NotImplementedError 

1271 

1272 @mode.setter 

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

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

1275 

1276 Note that: 

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

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

1279 an optimization). 

1280 

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

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

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

1284 debug errors. 

1285 """ 

1286 raise NotImplementedError 

1287 

1288 @property 

1289 def is_executable(self) -> bool: 

1290 """Determine whether a path is considered executable 

1291 

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

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

1294 parameter to be traversable. 

1295 

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

1297 """ 

1298 return bool(self.mode & 0o0111) 

1299 

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

1301 """Set the file mode of this path 

1302 

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

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

1305 

1306 Note that: 

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

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

1309 an optimization). 

1310 

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

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

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

1314 bits causes hard to debug errors. 

1315 """ 

1316 if isinstance(new_mode, str): 

1317 segments = parse_symbolic_mode(new_mode, None) 

1318 final_mode = self.mode 

1319 is_dir = self.is_dir 

1320 for segment in segments: 

1321 final_mode = segment.apply(final_mode, is_dir) 

1322 self.mode = final_mode 

1323 else: 

1324 self.mode = new_mode 

1325 

1326 def chown( 

1327 self, 

1328 owner: "StaticFileSystemOwner | None", 

1329 group: "StaticFileSystemGroup | None", 

1330 ) -> None: 

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

1332 

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

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

1335 """ 

1336 raise NotImplementedError 

1337 

1338 @property 

1339 def mtime(self) -> float: 

1340 """Determine the mtime of this path object 

1341 

1342 Note that: 

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

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

1345 normalization is handled later by `debputy`. 

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

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

1348 to the underlying file system in many cases. 

1349 

1350 :return: The mtime for the path. 

1351 """ 

1352 raise NotImplementedError 

1353 

1354 @mtime.setter 

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

1356 """Set the mtime of this path 

1357 

1358 Note that: 

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

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

1361 an optimization). 

1362 

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

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

1365 """ 

1366 raise NotImplementedError 

1367 

1368 def readlink(self) -> str: 

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

1370 

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

1372 `has_fs_path` is False. 

1373 

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

1375 """ 

1376 raise NotImplementedError() 

1377 

1378 @overload 

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

1380 self, 

1381 *, 

1382 byte_io: Literal[False] = False, 

1383 buffering: int = -1, 

1384 ) -> TextIO: ... 

1385 

1386 @overload 

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

1388 self, 

1389 *, 

1390 byte_io: Literal[True], 

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

1392 ) -> io.FileIO: ... 

1393 

1394 @overload 

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

1396 self, 

1397 *, 

1398 byte_io: Literal[True], 

1399 buffering: int = -1, 

1400 ) -> io.BufferedReader: ... 

1401 

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

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

1404 

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

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

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

1408 

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

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

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

1412 

1413 

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

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

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

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

1418 :return: The file handle. 

1419 """ 

1420 

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

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

1423 

1424 if byte_io: 

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

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

1427 

1428 @property 

1429 def fs_path(self) -> str: 

1430 """Request the underling fs_path of this path 

1431 

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

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

1434 

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

1436 multiple paths pointing to the same file system path. 

1437 

1438 Note that: 

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

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

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

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

1443 internal invariants. 

1444 

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

1446 file exist (see `has_fs_path`). 

1447 """ 

1448 raise NotImplementedError() 

1449 

1450 @property 

1451 def is_dir(self) -> bool: 

1452 """Determine if this path is a directory 

1453 

1454 Never follows symlinks. 

1455 

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

1457 """ 

1458 raise NotImplementedError() 

1459 

1460 @property 

1461 def is_file(self) -> bool: 

1462 """Determine if this path is a directory 

1463 

1464 Never follows symlinks. 

1465 

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

1467 """ 

1468 raise NotImplementedError() 

1469 

1470 @property 

1471 def is_symlink(self) -> bool: 

1472 """Determine if this path is a symlink 

1473 

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

1475 """ 

1476 raise NotImplementedError() 

1477 

1478 @property 

1479 def has_fs_path(self) -> bool: 

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

1481 

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

1483 """ 

1484 raise NotImplementedError() 

1485 

1486 @property 

1487 def is_read_write(self) -> bool: 

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

1489 

1490 Read-write rules are: 

1491 

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

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

1494 +--------------------------+-------------------+------------------------+ 

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

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

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

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

1499 

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

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

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

1503 optimizations. 

1504 

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

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

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

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

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

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

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

1512 

1513 :return: Whether file system mutations are permitted. 

1514 """ 

1515 return False 

1516 

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

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

1519 

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

1521 with this basename. 

1522 :return: The new subdirectory 

1523 """ 

1524 raise NotImplementedError 

1525 

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

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

1528 

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

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

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

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

1533 :return: The directory denoted by the given path 

1534 """ 

1535 raise NotImplementedError 

1536 

1537 def add_file( 

1538 self, 

1539 name: str, 

1540 *, 

1541 unlink_if_exists: bool = True, 

1542 use_fs_path_mode: bool = False, 

1543 mode: int = 0o0644, 

1544 mtime: float | None = None, 

1545 ) -> ContextManager["VirtualPath"]: 

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

1547 

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

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

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

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

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

1553 empty file when the context manager is entered. 

1554 

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

1556 

1557 >>> import subprocess 

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

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

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

1561 

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

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

1564 

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

1566 

1567 :param name: Basename of the new file 

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

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

1570 (when `unlink_if_exists` is True) 

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

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

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

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

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

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

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

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

1579 this interacts with the physical file system. 

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

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

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

1583 should be earlier than `SOURCE_DATE_EPOCH`. 

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

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

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

1587 manager exits 

1588 """ 

1589 raise NotImplementedError 

1590 

1591 def replace_fs_path_content( 

1592 self, 

1593 *, 

1594 use_fs_path_mode: bool = False, 

1595 ) -> ContextManager[str]: 

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

1597 

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

1599 

1600 Example: 

1601 >>> import subprocess 

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

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

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

1605 

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

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

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

1609 

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

1611 

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

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

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

1615 reliably restore the path. 

1616 

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

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

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

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

1621 definition. 

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

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

1624 the caller wishes until the context manager exits. 

1625 """ 

1626 raise NotImplementedError 

1627 

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

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

1630 

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

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

1633 

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

1635 

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

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

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

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

1640 :return: The newly created symlink. 

1641 """ 

1642 raise NotImplementedError 

1643 

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

1645 """Unlink a file or a directory 

1646 

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

1648 

1649 When the path is a: 

1650 

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

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

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

1654 

1655 Note that: 

1656 * the root directory cannot be deleted. 

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

1658 

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

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

1661 """ 

1662 raise NotImplementedError 

1663 

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

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

1666 

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

1668 

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

1670 """ 

1671 if not self.is_file: 

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

1673 try: 

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

1675 return extract_shebang_interpreter_from_file(fd) 

1676 except (PureVirtualPathError, TestPathWithNonExistentFSPathError): 

1677 return None 

1678 

1679 def metadata( 

1680 self, 

1681 metadata_type: type[PMT], 

1682 ) -> PathMetadataReference[PMT]: 

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

1684 

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

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

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

1688 

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

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

1691 

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

1693 

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

1695 :return: A reference to the metadata. 

1696 """ 

1697 raise NotImplementedError 

1698 

1699 

1700class FlushableSubstvars(Substvars): 

1701 __slots__ = () 

1702 

1703 @contextlib.contextmanager 

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

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

1706 

1707 >>> s = FlushableSubstvars() 

1708 >>> 'Test:Var' in s 

1709 False 

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

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

1712 >>> 'Test:Var' in s 

1713 True 

1714 

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

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

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

1718 

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

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

1721 the substvars. 

1722 

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

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

1725 terminates successfully. 

1726 

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

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

1729 successfully. 

1730 """ 

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

1732 self.write_substvars(tmp) 

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

1734 yield tmp.name 

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

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

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

1738 self.read_substvars(fd) 

1739 

1740 def save(self) -> None: 

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

1742 if self._substvars_path is None: 

1743 raise TypeError( 

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

1745 ) 

1746 super().save() 

1747 

1748 

1749class ServiceRegistry(Generic[DSD]): 

1750 __slots__ = () 

1751 

1752 def register_service( 

1753 self, 

1754 path: VirtualPath, 

1755 name: str | list[str], 

1756 *, 

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

1758 service_scope: str = "system", 

1759 enable_by_default: bool = True, 

1760 start_by_default: bool = True, 

1761 default_upgrade_rule: ServiceUpgradeRule = "restart", 

1762 service_context: DSD | None = None, 

1763 ) -> None: 

1764 """Register a service detected in the package 

1765 

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

1767 integration code is called. 

1768 

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

1770 

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

1772 2) Their plugin provided names has an overlap 

1773 

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

1775 

1776 :param path: The path defining this service. 

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

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

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

1780 to identify this service. 

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

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

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

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

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

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

1787 packager does not explicitly override this setting. 

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

1789 the packager does not explicitly override this setting. 

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

1791 upgrades. Options are: 

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

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

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

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

1796 start the service if not is not already running. 

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

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

1799 start the service if not is not already running. 

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

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

1802 

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

1804 integrator for this service. 

1805 """ 

1806 raise NotImplementedError 

1807 

1808 

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

1810class ParserAttributeDocumentation: 

1811 attributes: frozenset[str] 

1812 description: str | None 

1813 

1814 @property 

1815 def is_hidden(self) -> bool: 

1816 return False 

1817 

1818 

1819@final 

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

1821class StandardParserAttributeDocumentation(ParserAttributeDocumentation): 

1822 sort_category: int = 0 

1823 

1824 

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

1826 """Describe an attribute as undocumented 

1827 

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

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

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

1831 

1832 :param attr: Name of the attribute 

1833 """ 

1834 return ParserAttributeDocumentation( 

1835 frozenset({attr}), 

1836 None, 

1837 ) 

1838 

1839 

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

1841class ParserDocumentation: 

1842 synopsis: str | None = None 

1843 title: str | None = None 

1844 description: str | None = None 

1845 attribute_doc: Sequence[ParserAttributeDocumentation] | None = None 

1846 alt_parser_description: str | None = None 

1847 documentation_reference_url: str | None = None 

1848 

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

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

1851 

1852 @classmethod 

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

1854 attr = [ 

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

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

1857 ] 

1858 undoc_attr = ref_doc.get("undocumented_attributes") 

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

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

1861 

1862 return reference_documentation( 

1863 title=ref_doc["title"], 

1864 description=ref_doc["description"], 

1865 attributes=attr, 

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

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

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

1869 ) 

1870 

1871 

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

1873class TypeMappingExample(Generic[S]): 

1874 source_input: S 

1875 

1876 

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

1878class TypeMappingDocumentation(Generic[S]): 

1879 description: str | None = None 

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

1881 

1882 

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

1884 return TypeMappingExample(source_input) 

1885 

1886 

1887def type_mapping_reference_documentation( 

1888 *, 

1889 description: str | None = None, 

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

1891) -> TypeMappingDocumentation[S]: 

1892 e = ( 

1893 tuple([examples]) 

1894 if isinstance(examples, TypeMappingExample) 

1895 else tuple(examples) 

1896 ) 

1897 return TypeMappingDocumentation( 

1898 description=description, 

1899 examples=e, 

1900 ) 

1901 

1902 

1903def documented_attr( 

1904 attr: str | Iterable[str], 

1905 description: str, 

1906) -> ParserAttributeDocumentation: 

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

1908 

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

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

1911 

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

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

1914 target attribute). 

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

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

1917 the description of `reference_documentation`. 

1918 :return: An opaque representation of the documentation, 

1919 """ 

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

1921 return ParserAttributeDocumentation( 

1922 frozenset(attributes), 

1923 description, 

1924 ) 

1925 

1926 

1927def reference_documentation( 

1928 title: str | None = None, 

1929 description: str | None = textwrap.dedent("""\ 

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

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

1932 

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

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

1935 the manifest rule.) 

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