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

362 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-10-12 15:06 +0000

1import contextlib 

2import dataclasses 

3import os 

4import tempfile 

5import textwrap 

6from typing import ( 

7 Optional, 

8 Literal, 

9 Union, 

10 overload, 

11 FrozenSet, 

12 TypeVar, 

13 Any, 

14 TYPE_CHECKING, 

15 TextIO, 

16 BinaryIO, 

17 Generic, 

18 ContextManager, 

19 List, 

20 Type, 

21 Tuple, 

22 get_args, 

23 final, 

24 cast, 

25) 

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

27 

28from debian.substvars import Substvars 

29 

30from debputy import util 

31from debputy.exceptions import ( 

32 TestPathWithNonExistentFSPathError, 

33 PureVirtualPathError, 

34 PluginInitializationError, 

35) 

36from debputy.interpreter import Interpreter, extract_shebang_interpreter_from_file 

37from debputy.manifest_parser.tagging_types import DebputyDispatchableType 

38from debputy.manifest_parser.util import parse_symbolic_mode 

39from debputy.packages import BinaryPackage 

40from debputy.types import S 

41 

42if TYPE_CHECKING: 

43 from debputy.manifest_parser.base_types import ( 

44 StaticFileSystemOwner, 

45 StaticFileSystemGroup, 

46 ) 

47 from debputy.plugin.api.doc_parsing import ParserRefDocumentation 

48 from debputy.plugin.api.impl_types import DIPHandler 

49 from debputy.plugins.debputy.to_be_api_types import BuildSystemRule 

50 from debputy.plugin.api.impl import DebputyPluginInitializerProvider 

51 

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

53 # for most static analysis tools. 

54 assert DebputyPluginInitializerProvider 

55 

56 

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

58 

59 

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

61MetadataAutoDetector = Callable[ 

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

63] 

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

65DpkgTriggerType = Literal[ 

66 "activate", 

67 "activate-await", 

68 "activate-noawait", 

69 "interest", 

70 "interest-await", 

71 "interest-noawait", 

72] 

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

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

75ServiceUpgradeRule = Literal[ 

76 "do-nothing", 

77 "reload", 

78 "restart", 

79 "stop-then-start", 

80] 

81 

82DSD = TypeVar("DSD") 

83ServiceDetector = Callable[ 

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

85 None, 

86] 

87ServiceIntegrator = Callable[ 

88 [ 

89 Sequence["ServiceDefinition[DSD]"], 

90 "BinaryCtrlAccessor", 

91 "PackageProcessingContext", 

92 ], 

93 None, 

94] 

95 

96PMT = TypeVar("PMT") 

97DebputyIntegrationMode = Literal[ 

98 "full", 

99 "dh-sequence-zz-debputy", 

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

101] 

102 

103INTEGRATION_MODE_FULL: DebputyIntegrationMode = "full" 

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

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

106ALL_DEBPUTY_INTEGRATION_MODES: frozenset[DebputyIntegrationMode] = frozenset( 

107 get_args(DebputyIntegrationMode) 

108) 

109 

110_DEBPUTY_DISPATCH_METADATA_ATTR_NAME = "_debputy_dispatch_metadata" 

111 

112 

113def only_integrations( 

114 *integrations: DebputyIntegrationMode, 

115) -> Container[DebputyIntegrationMode]: 

116 return frozenset(integrations) 

117 

118 

119def not_integrations( 

120 *integrations: DebputyIntegrationMode, 

121) -> Container[DebputyIntegrationMode]: 

122 return ALL_DEBPUTY_INTEGRATION_MODES - frozenset(integrations) 

123 

124 

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

126class PackagerProvidedFileReferenceDocumentation: 

127 description: str | None = None 

128 format_documentation_uris: Sequence[str] = tuple() 

129 

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

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

132 

133 

134def packager_provided_file_reference_documentation( 

135 *, 

136 description: str | None = None, 

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

138) -> PackagerProvidedFileReferenceDocumentation: 

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

140 

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

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

143 the format of the file. Most relevant first. 

144 :return: 

145 """ 

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

147 return PackagerProvidedFileReferenceDocumentation( 

148 description=description, 

149 format_documentation_uris=uris, 

150 ) 

151 

152 

153class PathMetadataReference(Generic[PMT]): 

154 """An accessor to plugin provided metadata 

155 

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

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

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

159 """ 

160 

161 @property 

162 def is_present(self) -> bool: 

163 """Determine whether the value has been set 

164 

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

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

167 

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

169 Otherwise, this property is `False`. 

170 """ 

171 raise NotImplementedError 

172 

173 @property 

174 def can_read(self) -> bool: 

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

176 

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

178 

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

180 owning plugin. 

181 """ 

182 raise NotImplementedError 

183 

184 @property 

185 def can_write(self) -> bool: 

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

187 

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

189 """ 

190 raise NotImplementedError 

191 

192 @property 

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

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

195 

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

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

198 """ 

199 raise NotImplementedError 

200 

201 @value.setter 

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

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

204 

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

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

207 """ 

208 raise NotImplementedError 

209 

210 @value.deleter 

211 def value(self) -> None: 

212 """Delete any current value. 

213 

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

215 as the value setter. 

216 """ 

217 self.value = None 

218 

219 

220@dataclasses.dataclass(slots=True) 

221class PathDef: 

222 path_name: str 

223 mode: int | None = None 

224 mtime: int | None = None 

225 has_fs_path: bool | None = None 

226 fs_path: str | None = None 

227 link_target: str | None = None 

228 content: str | None = None 

229 materialized_content: str | None = None 

230 

231 

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

233class DispatchablePluggableManifestRuleMetadata(Generic[DP]): 

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

235 

236 manifest_keywords: Sequence[str] 

237 dispatched_type: type[DP] 

238 unwrapped_constructor: "DIPHandler" 

239 expected_debputy_integration_mode: Container[DebputyIntegrationMode] | None = None 

240 online_reference_documentation: Optional["ParserDocumentation"] = None 

241 apply_standard_attribute_documentation: bool = False 

242 source_format: Any | None = None 

243 

244 

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

246class BuildSystemManifestRuleMetadata(DispatchablePluggableManifestRuleMetadata): 

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

248 auto_detection_shadow_build_systems: frozenset[str] = frozenset() 

249 

250 

251def virtual_path_def( 

252 path_name: str, 

253 /, 

254 mode: int | None = None, 

255 mtime: int | None = None, 

256 fs_path: str | None = None, 

257 link_target: str | None = None, 

258 content: str | None = None, 

259 materialized_content: str | None = None, 

260) -> PathDef: 

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

262 

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

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

265 on whether a `link_target` is provided. 

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

267 should be None for symlinks. 

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

269 if the mtime attribute is accessed. 

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

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

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

273 to resolve defaults from the path. 

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

275 path a symlink. 

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

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

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

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

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

281 """ 

282 

283 is_dir = path_name.endswith("/") 

284 is_symlink = link_target is not None 

285 

286 if is_symlink: 

287 if mode is not None: 

288 raise ValueError( 

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

290 ) 

291 if is_dir: 

292 raise ValueError( 

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

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

295 ) 

296 

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

298 raise ValueError( 

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

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

301 ) 

302 

303 if materialized_content is not None: 

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

305 raise ValueError( 

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

307 f' Triggered by "{path_name}"' 

308 ) 

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

310 raise ValueError( 

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

312 f' Triggered by "{path_name}"' 

313 ) 

314 return PathDef( 

315 path_name, 

316 mode=mode, 

317 mtime=mtime, 

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

319 fs_path=fs_path, 

320 link_target=link_target, 

321 content=content, 

322 materialized_content=materialized_content, 

323 ) 

324 

325 

326class PackageProcessingContext: 

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

328 

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

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

331 """ 

332 

333 __slots__ = () 

334 

335 @property 

336 def binary_package(self) -> BinaryPackage: 

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

338 raise NotImplementedError 

339 

340 @property 

341 def binary_package_version(self) -> str: 

342 """The version of the binary package 

343 

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

345 """ 

346 raise NotImplementedError 

347 

348 @property 

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

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

351 raise NotImplementedError 

352 

353 @property 

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

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

356 

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

358 """ 

359 raise NotImplementedError 

360 

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

362 raise NotImplementedError 

363 

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

365 # source_package: SourcePackage 

366 

367 

368class DebputyPluginDefinition: 

369 """Plugin definition entity 

370 

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

372 accessors. 

373 """ 

374 

375 __slots__ = ("_generic_initializers",) 

376 

377 def __init__(self) -> None: 

378 self._generic_initializers: list[ 

379 Callable[["DebputyPluginInitializerProvider"], None] 

380 ] = [] 

381 

382 @staticmethod 

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

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

385 return provided_id 

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

387 name = name[:-1] 

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

389 

390 def metadata_or_maintscript_detector( 

391 self, 

392 func: MetadataAutoDetector | None = None, 

393 *, 

394 detector_id: str | None = None, 

395 package_type: PackageTypeSelector = "deb", 

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

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

398 

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

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

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

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

403 

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

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

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

407 

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

409 any other hook. 

410 

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

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

413 

414 

415 >>> plugin_definition = define_debputy_plugin() 

416 >>> @plugin_definition.metadata_or_maintscript_detector 

417 ... def gsettings_dependencies( 

418 ... fs_root: "VirtualPath", 

419 ... ctrl: "BinaryCtrlAccessor", 

420 ... context: "PackageProcessingContext", 

421 ... ) -> None: 

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

423 ... if gsettings_schema_dir is None: 

424 ... return 

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

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

427 ... ctrl.substvars.add_dependency( 

428 ... "misc:Depends", 

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

430 ... ) 

431 ... break 

432 

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

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

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

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

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

438 and ignore `udeb` packages. 

439 """ 

440 

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

442 

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

444 

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

446 api.metadata_or_maintscript_detector( 

447 final_id, 

448 f, 

449 package_type=package_type, 

450 ) 

451 

452 self._generic_initializers.append(_init) 

453 

454 return f 

455 

456 if func: 

457 return _decorate(func) 

458 return _decorate 

459 

460 def manifest_variable( 

461 self, 

462 variable_name: str, 

463 value: str, 

464 *, 

465 variable_reference_documentation: str | None = None, 

466 ) -> None: 

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

468 

469 >>> plugin_definition = define_debputy_plugin() 

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

471 >>> plugin_definition.manifest_variable( 

472 ... "path:BASH_COMPLETION_DIR", 

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

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

475 ... ) 

476 

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

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

479 dynamic / context based values at this time. 

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

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

482 docs. 

483 """ 

484 

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

486 api.manifest_variable( 

487 variable_name, 

488 value, 

489 variable_reference_documentation=variable_reference_documentation, 

490 ) 

491 

492 self._generic_initializers.append(_init) 

493 

494 def packager_provided_file( 

495 self, 

496 stem: str, 

497 installed_path: str, 

498 *, 

499 default_mode: int = 0o0644, 

500 default_priority: int | None = None, 

501 allow_name_segment: bool = True, 

502 allow_architecture_segment: bool = False, 

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

504 packageless_is_fallback_for_all_packages: bool = False, 

505 reservation_only: bool = False, 

506 reference_documentation: None | ( 

507 PackagerProvidedFileReferenceDocumentation 

508 ) = None, 

509 ) -> None: 

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

511 

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

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

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

515 in the `debian/` directory. 

516 

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

518 via Python code. 

519 

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

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

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

523 

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

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

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

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

528 

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

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

531 

532 The following placeholders are supported: 

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

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

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

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

537 characters. 

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

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

540 

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

542 

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

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

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

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

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

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

549 always result in an error. 

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

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

552 error. 

553 

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

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

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

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

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

559 provide a default priority. 

560 

561 The following placeholders are supported: 

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

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

564 is not None) 

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

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

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

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

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

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

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

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

573 is a fallback for every package. 

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

575 packager_provided_file_reference_documentation function to provide the value for this parameter. 

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

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

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

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

580 """ 

581 

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

583 api.packager_provided_file( 

584 stem, 

585 installed_path, 

586 default_mode=default_mode, 

587 default_priority=default_priority, 

588 allow_name_segment=allow_name_segment, 

589 allow_architecture_segment=allow_architecture_segment, 

590 post_formatting_rewrite=post_formatting_rewrite, 

591 packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages, 

592 reservation_only=reservation_only, 

593 reference_documentation=reference_documentation, 

594 ) 

595 

596 self._generic_initializers.append(_init) 

597 

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

599 """Initialize the plugin from this definition 

600 

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

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

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

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

605 distracting for plugin maintenance. 

606 

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

608 """ 

609 

610 api_impl = cast( 

611 "DebputyPluginInitializerProvider", 

612 api, 

613 ) 

614 initializers = self._generic_initializers 

615 if not initializers: 

616 plugin_name = api_impl.plugin_metadata.plugin_name 

617 raise PluginInitializationError( 

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

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

620 ) 

621 

622 for initializer in initializers: 

623 initializer(api_impl) 

624 

625 

626def define_debputy_plugin() -> DebputyPluginDefinition: 

627 return DebputyPluginDefinition() 

628 

629 

630class DebputyPluginInitializer: 

631 __slots__ = () 

632 

633 def packager_provided_file( 

634 self, 

635 stem: str, 

636 installed_path: str, 

637 *, 

638 default_mode: int = 0o0644, 

639 default_priority: int | None = None, 

640 allow_name_segment: bool = True, 

641 allow_architecture_segment: bool = False, 

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

643 packageless_is_fallback_for_all_packages: bool = False, 

644 reservation_only: bool = False, 

645 reference_documentation: None | ( 

646 PackagerProvidedFileReferenceDocumentation 

647 ) = None, 

648 ) -> None: 

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

650 

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

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

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

654 in the `debian/` directory. 

655 

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

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

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

659 

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

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

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

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

664 

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

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

667 

668 The following placeholders are supported: 

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

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

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

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

673 characters. 

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

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

676 

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

678 

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

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

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

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

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

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

685 always result in an error. 

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

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

688 error. 

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

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

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

692 provide a default priority. 

693 

694 The following placeholders are supported: 

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

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

697 is not None) 

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

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

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

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

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

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

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

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

706 is a fallback for every package. 

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

708 packager_provided_file_reference_documentation function to provide the value for this parameter. 

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

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

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

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

713 """ 

714 raise NotImplementedError 

715 

716 def metadata_or_maintscript_detector( 

717 self, 

718 auto_detector_id: str, 

719 auto_detector: MetadataAutoDetector, 

720 *, 

721 package_type: PackageTypeSelector = "deb", 

722 ) -> None: 

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

724 

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

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

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

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

729 

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

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

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

733 

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

735 any other hook. 

736 

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

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

739 

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

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

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

743 binary package). 

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

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

746 and ignore `udeb` packages. 

747 """ 

748 raise NotImplementedError 

749 

750 def manifest_variable( 

751 self, 

752 variable_name: str, 

753 value: str, 

754 *, 

755 variable_reference_documentation: str | None = None, 

756 ) -> None: 

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

758 

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

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

761 ... "path:BASH_COMPLETION_DIR", 

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

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

764 ... ) 

765 

766 :param variable_name: The variable name. 

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

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

769 the purpose of the variable. 

770 """ 

771 raise NotImplementedError 

772 

773 

774class MaintscriptAccessor: 

775 __slots__ = () 

776 

777 def on_configure( 

778 self, 

779 run_snippet: str, 

780 /, 

781 indent: bool | None = None, 

782 perform_substitution: bool = True, 

783 skip_on_rollback: bool = False, 

784 ) -> None: 

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

786 

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

788 common cases: 

789 * On initial install, OR 

790 * On upgrade 

791 

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

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

794 

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

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

797 

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

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

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

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

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

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

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

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

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

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

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

809 substitution is provided. 

810 """ 

811 raise NotImplementedError 

812 

813 def on_initial_install( 

814 self, 

815 run_snippet: str, 

816 /, 

817 indent: bool | None = None, 

818 perform_substitution: bool = True, 

819 ) -> None: 

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

821 

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

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

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

825 must still be idempotent): 

826 

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

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

829 

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

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

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

833 

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

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

836 

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

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

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

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

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

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

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

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

845 substitution is provided. 

846 """ 

847 raise NotImplementedError 

848 

849 def on_upgrade( 

850 self, 

851 run_snippet: str, 

852 /, 

853 indent: bool | None = None, 

854 perform_substitution: bool = True, 

855 ) -> None: 

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

857 

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

859 

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

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

862 

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

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

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

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

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

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

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

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

871 substitution is provided. 

872 """ 

873 raise NotImplementedError 

874 

875 def on_upgrade_from( 

876 self, 

877 version: str, 

878 run_snippet: str, 

879 /, 

880 indent: bool | None = None, 

881 perform_substitution: bool = True, 

882 ) -> None: 

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

884 

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

886 

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

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

889 

890 :param version: The version to upgrade from 

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

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

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

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

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

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

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

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

899 substitution is provided. 

900 """ 

901 raise NotImplementedError 

902 

903 def on_before_removal( 

904 self, 

905 run_snippet: str, 

906 /, 

907 indent: bool | None = None, 

908 perform_substitution: bool = True, 

909 ) -> None: 

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

911 

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

913 

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

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

916 

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

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

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

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

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

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

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

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

925 substitution is provided. 

926 """ 

927 raise NotImplementedError 

928 

929 def on_removed( 

930 self, 

931 run_snippet: str, 

932 /, 

933 indent: bool | None = None, 

934 perform_substitution: bool = True, 

935 ) -> None: 

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

937 

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

939 

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

941 

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

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

944 

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

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

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

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

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

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

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

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

953 substitution is provided. 

954 """ 

955 raise NotImplementedError 

956 

957 def on_purge( 

958 self, 

959 run_snippet: str, 

960 /, 

961 indent: bool | None = None, 

962 perform_substitution: bool = True, 

963 ) -> None: 

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

965 

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

967 

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

969 

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

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

972 

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

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

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

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

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

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

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

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

981 substitution is provided. 

982 """ 

983 raise NotImplementedError 

984 

985 def unconditionally_in_script( 

986 self, 

987 maintscript: Maintscript, 

988 run_snippet: str, 

989 /, 

990 perform_substitution: bool = True, 

991 ) -> None: 

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

993 

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

995 for when it should be run. 

996 

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

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

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

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

1001 substitutions by default. 

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

1003 substitution is provided. 

1004 """ 

1005 raise NotImplementedError 

1006 

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

1008 """Provide sh-shell escape of strings 

1009 

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

1011 

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

1013 contain spaces or shell meta-characters. 

1014 

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

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

1017 joined by a single space. 

1018 """ 

1019 return util.escape_shell(*args) 

1020 

1021 

1022class BinaryCtrlAccessor: 

1023 __slots__ = () 

1024 

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

1026 """Register a declarative dpkg level trigger 

1027 

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

1029 

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

1031 """ 

1032 raise NotImplementedError 

1033 

1034 @property 

1035 def maintscript(self) -> MaintscriptAccessor: 

1036 """Attribute for manipulating maintscripts""" 

1037 raise NotImplementedError 

1038 

1039 @property 

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

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

1042 raise NotImplementedError 

1043 

1044 

1045class VirtualPath: 

1046 __slots__ = () 

1047 

1048 @property 

1049 def name(self) -> str: 

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

1051 

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

1053 

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

1055 """ 

1056 raise NotImplementedError 

1057 

1058 @property 

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

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

1061 

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

1063 the iterable is always empty. 

1064 """ 

1065 raise NotImplementedError 

1066 

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

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

1069 

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

1071 

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

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

1074 

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

1076 

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

1078 whether the lookup is relative or absolute. 

1079 

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

1081 

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

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

1084 to this path. 

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

1086 """ 

1087 raise NotImplementedError 

1088 

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

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

1091 

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

1093 

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

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

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

1097 defined. 

1098 

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

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

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

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

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

1104 

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

1106 """ 

1107 raise NotImplementedError 

1108 

1109 @property 

1110 def is_detached(self) -> bool: 

1111 """Returns True if this path is detached 

1112 

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

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

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

1116 

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

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

1119 always be manipulated. 

1120 

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

1122 can be garbage collected. 

1123 """ 

1124 raise NotImplementedError 

1125 

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

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

1128 # behavior to avoid surprises for now. 

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

1130 # to using it) 

1131 __iter__ = None 

1132 

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

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

1135 

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

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

1138 

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

1140 """ 

1141 raise NotImplementedError 

1142 

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

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

1145 

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

1147 """ 

1148 raise NotImplementedError 

1149 

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

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

1152 

1153 The following are the same: 

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

1155 

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

1157 """ 

1158 try: 

1159 return self[key] 

1160 except KeyError: 

1161 return None 

1162 

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

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

1165 

1166 Examples: 

1167 

1168 if 'foo' in dir: ... 

1169 """ 

1170 if isinstance(item, VirtualPath): 

1171 return item.parent_dir is self 

1172 if not isinstance(item, str): 

1173 return False 

1174 m = self.get(item) 

1175 return m is not None 

1176 

1177 @property 

1178 def path(self) -> str: 

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

1180 

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

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

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

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

1185 you need if you want to read the file. 

1186 

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

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

1189 

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

1191 was known prior to being detached. 

1192 """ 

1193 raise NotImplementedError 

1194 

1195 @property 

1196 def absolute(self) -> str: 

1197 """Returns the absolute version of this path 

1198 

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

1200 

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

1202 of installation (prior to being detached). 

1203 

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

1205 """ 

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

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

1208 return f"/{p}" 

1209 return p 

1210 

1211 @property 

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

1213 """The parent directory of this path 

1214 

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

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

1217 

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

1219 """ 

1220 raise NotImplementedError 

1221 

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

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

1224 

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

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

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

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

1229 

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

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

1232 

1233 :return: The stat result or an error. 

1234 """ 

1235 raise NotImplementedError() 

1236 

1237 @property 

1238 def size(self) -> int: 

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

1240 

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

1242 

1243 :return: The size of the file in bytes 

1244 """ 

1245 return self.stat().st_size 

1246 

1247 @property 

1248 def mode(self) -> int: 

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

1250 

1251 Note that: 

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

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

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

1255 to the underlying file system in many cases. 

1256 

1257 

1258 :return: The mode bits for the path. 

1259 """ 

1260 raise NotImplementedError 

1261 

1262 @mode.setter 

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

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

1265 

1266 Note that: 

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

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

1269 an optimization). 

1270 

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

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

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

1274 debug errors. 

1275 """ 

1276 raise NotImplementedError 

1277 

1278 @property 

1279 def is_executable(self) -> bool: 

1280 """Determine whether a path is considered executable 

1281 

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

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

1284 parameter to be traversable. 

1285 

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

1287 """ 

1288 return bool(self.mode & 0o0111) 

1289 

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

1291 """Set the file mode of this path 

1292 

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

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

1295 

1296 Note that: 

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

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

1299 an optimization). 

1300 

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

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

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

1304 bits causes hard to debug errors. 

1305 """ 

1306 if isinstance(new_mode, str): 

1307 segments = parse_symbolic_mode(new_mode, None) 

1308 final_mode = self.mode 

1309 is_dir = self.is_dir 

1310 for segment in segments: 

1311 final_mode = segment.apply(final_mode, is_dir) 

1312 self.mode = final_mode 

1313 else: 

1314 self.mode = new_mode 

1315 

1316 def chown( 

1317 self, 

1318 owner: Optional["StaticFileSystemOwner"], 

1319 group: Optional["StaticFileSystemGroup"], 

1320 ) -> None: 

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

1322 

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

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

1325 """ 

1326 raise NotImplementedError 

1327 

1328 @property 

1329 def mtime(self) -> float: 

1330 """Determine the mtime of this path object 

1331 

1332 Note that: 

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

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

1335 normalization is handled later by `debputy`. 

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

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

1338 to the underlying file system in many cases. 

1339 

1340 :return: The mtime for the path. 

1341 """ 

1342 raise NotImplementedError 

1343 

1344 @mtime.setter 

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

1346 """Set the mtime of this path 

1347 

1348 Note that: 

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

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

1351 an optimization). 

1352 

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

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

1355 """ 

1356 raise NotImplementedError 

1357 

1358 def readlink(self) -> str: 

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

1360 

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

1362 `has_fs_path` is False. 

1363 

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

1365 """ 

1366 raise NotImplementedError() 

1367 

1368 @overload 

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

1370 self, 

1371 *, 

1372 byte_io: Literal[False] = False, 

1373 buffering: int = -1, 

1374 ) -> TextIO: ... 

1375 

1376 @overload 

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

1378 self, 

1379 *, 

1380 byte_io: Literal[True], 

1381 buffering: int = -1, 

1382 ) -> BinaryIO: ... 

1383 

1384 @overload 

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

1386 self, 

1387 *, 

1388 byte_io: bool, 

1389 buffering: int = -1, 

1390 ) -> TextIO | BinaryIO: ... 

1391 

1392 def open( 

1393 self, 

1394 *, 

1395 byte_io: bool = False, 

1396 buffering: int = -1, 

1397 ) -> TextIO | BinaryIO: 

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

1399 

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

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

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

1403 

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

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

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

1407 

1408 

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

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

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

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

1413 :return: The file handle. 

1414 """ 

1415 

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

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

1418 

1419 if byte_io: 

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

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

1422 

1423 @property 

1424 def fs_path(self) -> str: 

1425 """Request the underling fs_path of this path 

1426 

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

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

1429 

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

1431 multiple paths pointing to the same file system path. 

1432 

1433 Note that: 

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

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

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

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

1438 internal invariants. 

1439 

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

1441 file exist (see `has_fs_path`). 

1442 """ 

1443 raise NotImplementedError() 

1444 

1445 @property 

1446 def is_dir(self) -> bool: 

1447 """Determine if this path is a directory 

1448 

1449 Never follows symlinks. 

1450 

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

1452 """ 

1453 raise NotImplementedError() 

1454 

1455 @property 

1456 def is_file(self) -> bool: 

1457 """Determine if this path is a directory 

1458 

1459 Never follows symlinks. 

1460 

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

1462 """ 

1463 raise NotImplementedError() 

1464 

1465 @property 

1466 def is_symlink(self) -> bool: 

1467 """Determine if this path is a symlink 

1468 

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

1470 """ 

1471 raise NotImplementedError() 

1472 

1473 @property 

1474 def has_fs_path(self) -> bool: 

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

1476 

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

1478 """ 

1479 raise NotImplementedError() 

1480 

1481 @property 

1482 def is_read_write(self) -> bool: 

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

1484 

1485 Read-write rules are: 

1486 

1487 +--------------------------+-------------------+------------------------+ 

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

1489 +--------------------------+-------------------+------------------------+ 

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

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

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

1493 +--------------------------+-------------------+------------------------+ 

1494 

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

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

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

1498 optimizations. 

1499 

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

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

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

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

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

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

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

1507 

1508 :return: Whether file system mutations are permitted. 

1509 """ 

1510 return False 

1511 

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

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

1514 

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

1516 with this basename. 

1517 :return: The new subdirectory 

1518 """ 

1519 raise NotImplementedError 

1520 

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

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

1523 

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

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

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

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

1528 :return: The directory denoted by the given path 

1529 """ 

1530 raise NotImplementedError 

1531 

1532 def add_file( 

1533 self, 

1534 name: str, 

1535 *, 

1536 unlink_if_exists: bool = True, 

1537 use_fs_path_mode: bool = False, 

1538 mode: int = 0o0644, 

1539 mtime: float | None = None, 

1540 ) -> ContextManager["VirtualPath"]: 

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

1542 

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

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

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

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

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

1548 empty file when the context manager is entered. 

1549 

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

1551 

1552 >>> import subprocess 

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

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

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

1556 

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

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

1559 

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

1561 

1562 :param name: Basename of the new file 

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

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

1565 (when `unlink_if_exists` is True) 

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

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

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

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

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

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

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

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

1574 this interacts with the physical file system. 

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

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

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

1578 should be earlier than `SOURCE_DATE_EPOCH`. 

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

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

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

1582 manager exits 

1583 """ 

1584 raise NotImplementedError 

1585 

1586 def replace_fs_path_content( 

1587 self, 

1588 *, 

1589 use_fs_path_mode: bool = False, 

1590 ) -> ContextManager[str]: 

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

1592 

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

1594 

1595 Example: 

1596 >>> import subprocess 

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

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

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

1600 

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

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

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

1604 

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

1606 

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

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

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

1610 reliably restore the path. 

1611 

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

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

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

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

1616 definition. 

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

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

1619 the caller wishes until the context manager exits. 

1620 """ 

1621 raise NotImplementedError 

1622 

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

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

1625 

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

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

1628 

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

1630 

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

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

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

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

1635 :return: The newly created symlink. 

1636 """ 

1637 raise NotImplementedError 

1638 

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

1640 """Unlink a file or a directory 

1641 

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

1643 

1644 When the path is a: 

1645 

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

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

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

1649 

1650 Note that: 

1651 * the root directory cannot be deleted. 

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

1653 

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

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

1656 """ 

1657 raise NotImplementedError 

1658 

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

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

1661 

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

1663 

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

1665 """ 

1666 if not self.is_file: 

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

1668 try: 

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

1670 return extract_shebang_interpreter_from_file(fd) 

1671 except (PureVirtualPathError, TestPathWithNonExistentFSPathError): 

1672 return None 

1673 

1674 def metadata( 

1675 self, 

1676 metadata_type: type[PMT], 

1677 ) -> PathMetadataReference[PMT]: 

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

1679 

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

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

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

1683 

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

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

1686 

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

1688 

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

1690 :return: A reference to the metadata. 

1691 """ 

1692 raise NotImplementedError 

1693 

1694 

1695class FlushableSubstvars(Substvars): 

1696 __slots__ = () 

1697 

1698 @contextlib.contextmanager 

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

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

1701 

1702 >>> s = FlushableSubstvars() 

1703 >>> 'Test:Var' in s 

1704 False 

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

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

1707 >>> 'Test:Var' in s 

1708 True 

1709 

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

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

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

1713 

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

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

1716 the substvars. 

1717 

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

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

1720 terminates successfully. 

1721 

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

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

1724 successfully. 

1725 """ 

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

1727 self.write_substvars(tmp) 

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

1729 yield tmp.name 

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

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

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

1733 self.read_substvars(fd) 

1734 

1735 def save(self) -> None: 

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

1737 if self._substvars_path is None: 

1738 raise TypeError( 

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

1740 ) 

1741 super().save() 

1742 

1743 

1744class ServiceRegistry(Generic[DSD]): 

1745 __slots__ = () 

1746 

1747 def register_service( 

1748 self, 

1749 path: VirtualPath, 

1750 name: str | list[str], 

1751 *, 

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

1753 service_scope: str = "system", 

1754 enable_by_default: bool = True, 

1755 start_by_default: bool = True, 

1756 default_upgrade_rule: ServiceUpgradeRule = "restart", 

1757 service_context: DSD | None = None, 

1758 ) -> None: 

1759 """Register a service detected in the package 

1760 

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

1762 integration code is called. 

1763 

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

1765 

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

1767 2) Their plugin provided names has an overlap 

1768 

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

1770 

1771 :param path: The path defining this service. 

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

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

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

1775 to identify this service. 

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

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

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

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

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

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

1782 packager does not explicitly override this setting. 

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

1784 the packager does not explicitly override this setting. 

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

1786 upgrades. Options are: 

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

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

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

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

1791 start the service if not is not already running. 

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

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

1794 start the service if not is not already running. 

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

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

1797 

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

1799 integrator for this service. 

1800 """ 

1801 raise NotImplementedError 

1802 

1803 

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

1805class ParserAttributeDocumentation: 

1806 attributes: frozenset[str] 

1807 description: str | None 

1808 

1809 @property 

1810 def is_hidden(self) -> bool: 

1811 return False 

1812 

1813 

1814@final 

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

1816class StandardParserAttributeDocumentation(ParserAttributeDocumentation): 

1817 sort_category: int = 0 

1818 

1819 

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

1821 """Describe an attribute as undocumented 

1822 

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

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

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

1826 

1827 :param attr: Name of the attribute 

1828 """ 

1829 return ParserAttributeDocumentation( 

1830 frozenset({attr}), 

1831 None, 

1832 ) 

1833 

1834 

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

1836class ParserDocumentation: 

1837 synopsis: str | None = None 

1838 title: str | None = None 

1839 description: str | None = None 

1840 attribute_doc: Sequence[ParserAttributeDocumentation] | None = None 

1841 alt_parser_description: str | None = None 

1842 documentation_reference_url: str | None = None 

1843 

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

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

1846 

1847 @classmethod 

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

1849 attr = [ 

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

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

1852 ] 

1853 undoc_attr = ref_doc.get("undocumented_attributes") 

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

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

1856 

1857 return reference_documentation( 

1858 title=ref_doc["title"], 

1859 description=ref_doc["description"], 

1860 attributes=attr, 

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

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

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

1864 ) 

1865 

1866 

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

1868class TypeMappingExample(Generic[S]): 

1869 source_input: S 

1870 

1871 

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

1873class TypeMappingDocumentation(Generic[S]): 

1874 description: str | None = None 

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

1876 

1877 

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

1879 return TypeMappingExample(source_input) 

1880 

1881 

1882def type_mapping_reference_documentation( 

1883 *, 

1884 description: str | None = None, 

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

1886) -> TypeMappingDocumentation[S]: 

1887 e = ( 

1888 tuple([examples]) 

1889 if isinstance(examples, TypeMappingExample) 

1890 else tuple(examples) 

1891 ) 

1892 return TypeMappingDocumentation( 

1893 description=description, 

1894 examples=e, 

1895 ) 

1896 

1897 

1898def documented_attr( 

1899 attr: str | Iterable[str], 

1900 description: str, 

1901) -> ParserAttributeDocumentation: 

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

1903 

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

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

1906 

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

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

1909 target attribute). 

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

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

1912 the description of `reference_documentation`. 

1913 :return: An opaque representation of the documentation, 

1914 """ 

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

1916 return ParserAttributeDocumentation( 

1917 frozenset(attributes), 

1918 description, 

1919 ) 

1920 

1921 

1922def reference_documentation( 

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

1924 description: str | None = textwrap.dedent( 

1925 """\ 

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

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

1928 

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

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

1931 the manifest rule.) 

1932 """ 

1933 ), 

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

1935 non_mapping_description: str | None = None, 

1936 reference_documentation_url: str | None = None, 

1937 synopsis: str | None = None, 

1938) -> ParserDocumentation: 

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

1940 

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

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

1943 

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

1945 the alias provided by the user. 

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

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

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

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

1950 

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

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

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

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

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

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

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

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

1959 attributes exactly once. 

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

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

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

1963 the above listed variables. 

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

1965 :return: An opaque representation of the documentation, 

1966 """ 

1967 return ParserDocumentation( 

1968 synopsis, 

1969 title, 

1970 description, 

1971 attributes, 

1972 non_mapping_description, 

1973 reference_documentation_url, 

1974 ) 

1975 

1976 

1977class ServiceDefinition(Generic[DSD]): 

1978 __slots__ = () 

1979 

1980 @property 

1981 def name(self) -> str: 

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

1983 

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

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

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

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

1988 

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

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

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

1992 is needed. 

1993 """ 

1994 raise NotImplementedError 

1995 

1996 @property 

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

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

1999 

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

2001 the service earlier. 

2002 """ 

2003 raise NotImplementedError 

2004 

2005 @property 

2006 def path(self) -> VirtualPath: 

2007 """The registered path for this service 

2008 

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

2010 earlier. 

2011 """ 

2012 raise NotImplementedError 

2013 

2014 @property 

2015 def type_of_service(self) -> str: 

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

2017 

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

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

2020 """ 

2021 raise NotImplementedError 

2022 

2023 @property 

2024 def service_scope(self) -> str: 

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

2026 

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

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

2029 """ 

2030 raise NotImplementedError 

2031 

2032 @property 

2033 def auto_enable_on_install(self) -> bool: 

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

2035 

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

2037 """ 

2038 raise NotImplementedError 

2039 

2040 @property 

2041 def auto_start_on_install(self) -> bool: 

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

2043 

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

2045 """ 

2046 raise NotImplementedError 

2047 

2048 @property 

2049 def on_upgrade(self) -> ServiceUpgradeRule: 

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

2051 

2052 Options are: 

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

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

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

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

2057 start the service if not is not already running. 

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

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

2060 start the service if not is not already running. 

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

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

2063 

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

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

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

2067 being uninstalled. 

2068 

2069 :return: The service restart rule 

2070 """ 

2071 raise NotImplementedError 

2072 

2073 @property 

2074 def definition_source(self) -> str: 

2075 """Describes where this definition came from 

2076 

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

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

2079 to the plugin providing this definition. 

2080 

2081 :return: The source of this definition 

2082 """ 

2083 raise NotImplementedError 

2084 

2085 @property 

2086 def is_plugin_provided_definition(self) -> bool: 

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

2088 

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

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

2091 """ 

2092 raise NotImplementedError 

2093 

2094 @property 

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

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

2097 

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

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

2100 then this attribute will be None. 

2101 """ 

2102 raise NotImplementedError