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

378 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-01-04 10:15 +0000

1import contextlib 

2import dataclasses 

3import os 

4import tempfile 

5import textwrap 

6from typing import ( 

7 Optional, 

8 Literal, 

9 Union, 

10 overload, 

11 TypeVar, 

12 Any, 

13 TYPE_CHECKING, 

14 TextIO, 

15 BinaryIO, 

16 Generic, 

17 ContextManager, 

18 get_args, 

19 final, 

20 cast, 

21) 

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

23 

24from debian.debian_support import DpkgArchTable 

25from debian.substvars import Substvars 

26 

27from debputy import util 

28from debputy._deb_options_profiles import DebBuildOptionsAndProfiles 

29from debputy.exceptions import ( 

30 TestPathWithNonExistentFSPathError, 

31 PureVirtualPathError, 

32 PluginInitializationError, 

33) 

34from debputy.interpreter import Interpreter, extract_shebang_interpreter_from_file 

35from debputy.manifest_conditions import ConditionContext 

36from debputy.manifest_parser.tagging_types import DebputyDispatchableType 

37from debputy.manifest_parser.util import parse_symbolic_mode 

38from debputy.packages import BinaryPackage, SourcePackage 

39from debputy.types import S 

40 

41if TYPE_CHECKING: 

42 from debputy.manifest_parser.base_types import ( 

43 StaticFileSystemOwner, 

44 StaticFileSystemGroup, 

45 ) 

46 from debputy.plugin.api.doc_parsing import ParserRefDocumentation 

47 from debputy.plugin.api.impl_types import DIPHandler 

48 from debputy.plugins.debputy.to_be_api_types import BuildSystemRule 

49 from debputy.plugin.api.impl import DebputyPluginInitializerProvider 

50 

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

52 # for most static analysis tools. 

53 assert DebputyPluginInitializerProvider 

54 

55 

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

57 

58 

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

60MetadataAutoDetector = Callable[ 

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

62] 

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

64DpkgTriggerType = Literal[ 

65 "activate", 

66 "activate-await", 

67 "activate-noawait", 

68 "interest", 

69 "interest-await", 

70 "interest-noawait", 

71] 

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

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

74ServiceUpgradeRule = Literal[ 

75 "do-nothing", 

76 "reload", 

77 "restart", 

78 "stop-then-start", 

79] 

80 

81DSD = TypeVar("DSD") 

82ServiceDetector = Callable[ 

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

84 None, 

85] 

86ServiceIntegrator = Callable[ 

87 [ 

88 Sequence["ServiceDefinition[DSD]"], 

89 "BinaryCtrlAccessor", 

90 "PackageProcessingContext", 

91 ], 

92 None, 

93] 

94 

95PMT = TypeVar("PMT") 

96DebputyIntegrationMode = Literal[ 

97 "full", 

98 "dh-sequence-zz-debputy", 

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

100] 

101 

102INTEGRATION_MODE_FULL: DebputyIntegrationMode = "full" 

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

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

105ALL_DEBPUTY_INTEGRATION_MODES: frozenset[DebputyIntegrationMode] = frozenset( 

106 get_args(DebputyIntegrationMode) 

107) 

108 

109_DEBPUTY_DISPATCH_METADATA_ATTR_NAME = "_debputy_dispatch_metadata" 

110 

111 

112def only_integrations( 

113 *integrations: DebputyIntegrationMode, 

114) -> Container[DebputyIntegrationMode]: 

115 return frozenset(integrations) 

116 

117 

118def not_integrations( 

119 *integrations: DebputyIntegrationMode, 

120) -> Container[DebputyIntegrationMode]: 

121 return ALL_DEBPUTY_INTEGRATION_MODES - frozenset(integrations) 

122 

123 

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

125class PackagerProvidedFileReferenceDocumentation: 

126 description: str | None = None 

127 format_documentation_uris: Sequence[str] = tuple() 

128 

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

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

131 

132 

133def packager_provided_file_reference_documentation( 

134 *, 

135 description: str | None = None, 

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

137) -> PackagerProvidedFileReferenceDocumentation: 

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

139 

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

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

142 the format of the file. Most relevant first. 

143 :return: 

144 """ 

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

146 return PackagerProvidedFileReferenceDocumentation( 

147 description=description, 

148 format_documentation_uris=uris, 

149 ) 

150 

151 

152class PathMetadataReference(Generic[PMT]): 

153 """An accessor to plugin provided metadata 

154 

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

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

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

158 """ 

159 

160 @property 

161 def is_present(self) -> bool: 

162 """Determine whether the value has been set 

163 

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

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

166 

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

168 Otherwise, this property is `False`. 

169 """ 

170 raise NotImplementedError 

171 

172 @property 

173 def can_read(self) -> bool: 

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

175 

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

177 

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

179 owning plugin. 

180 """ 

181 raise NotImplementedError 

182 

183 @property 

184 def can_write(self) -> bool: 

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

186 

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

188 """ 

189 raise NotImplementedError 

190 

191 @property 

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

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

194 

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

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

197 """ 

198 raise NotImplementedError 

199 

200 @value.setter 

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

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

203 

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

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

206 """ 

207 raise NotImplementedError 

208 

209 @value.deleter 

210 def value(self) -> None: 

211 """Delete any current value. 

212 

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

214 as the value setter. 

215 """ 

216 self.value = None 

217 

218 

219@dataclasses.dataclass(slots=True) 

220class PathDef: 

221 path_name: str 

222 mode: int | None = None 

223 mtime: int | None = None 

224 has_fs_path: bool | None = None 

225 fs_path: str | None = None 

226 link_target: str | None = None 

227 content: str | None = None 

228 materialized_content: str | None = None 

229 

230 

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

232class DispatchablePluggableManifestRuleMetadata(Generic[DP]): 

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

234 

235 manifest_keywords: Sequence[str] 

236 dispatched_type: type[DP] 

237 unwrapped_constructor: "DIPHandler" 

238 expected_debputy_integration_mode: Container[DebputyIntegrationMode] | None = None 

239 online_reference_documentation: Optional["ParserDocumentation"] = None 

240 apply_standard_attribute_documentation: bool = False 

241 source_format: Any | None = None 

242 

243 

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

245class BuildSystemManifestRuleMetadata(DispatchablePluggableManifestRuleMetadata): 

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

247 auto_detection_shadow_build_systems: frozenset[str] = frozenset() 

248 

249 

250def virtual_path_def( 

251 path_name: str, 

252 /, 

253 mode: int | None = None, 

254 mtime: int | None = None, 

255 fs_path: str | None = None, 

256 link_target: str | None = None, 

257 content: str | None = None, 

258 materialized_content: str | None = None, 

259) -> PathDef: 

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

261 

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

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

264 on whether a `link_target` is provided. 

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

266 should be None for symlinks. 

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

268 if the mtime attribute is accessed. 

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

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

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

272 to resolve defaults from the path. 

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

274 path a symlink. 

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

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

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

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

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

280 """ 

281 

282 is_dir = path_name.endswith("/") 

283 is_symlink = link_target is not None 

284 

285 if is_symlink: 

286 if mode is not None: 

287 raise ValueError( 

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

289 ) 

290 if is_dir: 

291 raise ValueError( 

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

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

294 ) 

295 

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

297 raise ValueError( 

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

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

300 ) 

301 

302 if materialized_content is not None: 

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

304 raise ValueError( 

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

306 f' Triggered by "{path_name}"' 

307 ) 

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

309 raise ValueError( 

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

311 f' Triggered by "{path_name}"' 

312 ) 

313 return PathDef( 

314 path_name, 

315 mode=mode, 

316 mtime=mtime, 

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

318 fs_path=fs_path, 

319 link_target=link_target, 

320 content=content, 

321 materialized_content=materialized_content, 

322 ) 

323 

324 

325class PackageProcessingContext: 

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

327 

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

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

330 """ 

331 

332 __slots__ = () 

333 

334 @property 

335 def source_package(self) -> SourcePackage: 

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

337 raise NotImplementedError 

338 

339 @property 

340 def binary_package(self) -> BinaryPackage: 

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

342 raise NotImplementedError 

343 

344 @property 

345 def binary_package_version(self) -> str: 

346 """The version of the binary package 

347 

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

349 """ 

350 raise NotImplementedError 

351 

352 @property 

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

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

355 raise NotImplementedError 

356 

357 @property 

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

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

360 

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

362 """ 

363 raise NotImplementedError 

364 

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

366 raise NotImplementedError 

367 

368 def manifest_configuration[T]( 

369 self, 

370 context_package: SourcePackage | BinaryPackage, 

371 value_type: type[T], 

372 ) -> T | None: 

373 """Request access to configuration from the manifest 

374 

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

376 said configuration was provided. 

377 

378 

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

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

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

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

383 """ 

384 raise NotImplementedError 

385 

386 @property 

387 def dpkg_arch_query_table(self) -> DpkgArchTable: 

388 raise NotImplementedError 

389 

390 @property 

391 def deb_options_and_profiles(self) -> DebBuildOptionsAndProfiles: 

392 raise NotImplementedError 

393 

394 @property 

395 def source_condition_context(self) -> ConditionContext: 

396 raise NotImplementedError 

397 

398 def condition_context( 

399 self, binary_package: BinaryPackage | None 

400 ) -> ConditionContext: 

401 raise NotImplementedError 

402 

403 @property 

404 def binary_condition_context(self) -> ConditionContext: 

405 return self.condition_context(self.binary_package) 

406 

407 

408class DebputyPluginDefinition: 

409 """Plugin definition entity 

410 

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

412 accessors. 

413 """ 

414 

415 __slots__ = ("_generic_initializers",) 

416 

417 def __init__(self) -> None: 

418 self._generic_initializers: list[ 

419 Callable[["DebputyPluginInitializerProvider"], None] 

420 ] = [] 

421 

422 @staticmethod 

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

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

425 return provided_id 

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

427 name = name[:-1] 

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

429 

430 def metadata_or_maintscript_detector( 

431 self, 

432 func: MetadataAutoDetector | None = None, 

433 *, 

434 detector_id: str | None = None, 

435 package_type: PackageTypeSelector = "deb", 

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

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

438 

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

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

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

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

443 

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

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

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

447 

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

449 any other hook. 

450 

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

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

453 

454 

455 >>> plugin_definition = define_debputy_plugin() 

456 >>> @plugin_definition.metadata_or_maintscript_detector 

457 ... def gsettings_dependencies( 

458 ... fs_root: "VirtualPath", 

459 ... ctrl: "BinaryCtrlAccessor", 

460 ... context: "PackageProcessingContext", 

461 ... ) -> None: 

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

463 ... if gsettings_schema_dir is None: 

464 ... return 

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

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

467 ... ctrl.substvars.add_dependency( 

468 ... "misc:Depends", 

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

470 ... ) 

471 ... break 

472 

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

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

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

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

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

478 and ignore `udeb` packages. 

479 """ 

480 

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

482 

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

484 

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

486 api.metadata_or_maintscript_detector( 

487 final_id, 

488 f, 

489 package_type=package_type, 

490 ) 

491 

492 self._generic_initializers.append(_init) 

493 

494 return f 

495 

496 if func: 

497 return _decorate(func) 

498 return _decorate 

499 

500 def manifest_variable( 

501 self, 

502 variable_name: str, 

503 value: str, 

504 *, 

505 variable_reference_documentation: str | None = None, 

506 ) -> None: 

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

508 

509 >>> plugin_definition = define_debputy_plugin() 

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

511 >>> plugin_definition.manifest_variable( 

512 ... "path:BASH_COMPLETION_DIR", 

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

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

515 ... ) 

516 

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

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

519 dynamic / context based values at this time. 

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

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

522 docs. 

523 """ 

524 

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

526 api.manifest_variable( 

527 variable_name, 

528 value, 

529 variable_reference_documentation=variable_reference_documentation, 

530 ) 

531 

532 self._generic_initializers.append(_init) 

533 

534 def packager_provided_file( 

535 self, 

536 stem: str, 

537 installed_path: str, 

538 *, 

539 default_mode: int = 0o0644, 

540 default_priority: int | None = None, 

541 allow_name_segment: bool = True, 

542 allow_architecture_segment: bool = False, 

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

544 packageless_is_fallback_for_all_packages: bool = False, 

545 reservation_only: bool = False, 

546 reference_documentation: None | ( 

547 PackagerProvidedFileReferenceDocumentation 

548 ) = None, 

549 ) -> None: 

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

551 

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

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

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

555 in the `debian/` directory. 

556 

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

558 via Python code. 

559 

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

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

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

563 

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

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

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

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

568 

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

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

571 

572 The following placeholders are supported: 

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

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

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

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

577 characters. 

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

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

580 

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

582 

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

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

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

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

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

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

589 always result in an error. 

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

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

592 error. 

593 

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

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

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

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

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

599 provide a default priority. 

600 

601 The following placeholders are supported: 

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

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

604 is not None) 

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

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

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

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

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

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

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

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

613 is a fallback for every package. 

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

615 packager_provided_file_reference_documentation function to provide the value for this parameter. 

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

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

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

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

620 """ 

621 

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

623 api.packager_provided_file( 

624 stem, 

625 installed_path, 

626 default_mode=default_mode, 

627 default_priority=default_priority, 

628 allow_name_segment=allow_name_segment, 

629 allow_architecture_segment=allow_architecture_segment, 

630 post_formatting_rewrite=post_formatting_rewrite, 

631 packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages, 

632 reservation_only=reservation_only, 

633 reference_documentation=reference_documentation, 

634 ) 

635 

636 self._generic_initializers.append(_init) 

637 

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

639 """Initialize the plugin from this definition 

640 

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

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

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

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

645 distracting for plugin maintenance. 

646 

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

648 """ 

649 

650 api_impl = cast( 

651 "DebputyPluginInitializerProvider", 

652 api, 

653 ) 

654 initializers = self._generic_initializers 

655 if not initializers: 

656 plugin_name = api_impl.plugin_metadata.plugin_name 

657 raise PluginInitializationError( 

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

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

660 ) 

661 

662 for initializer in initializers: 

663 initializer(api_impl) 

664 

665 

666def define_debputy_plugin() -> DebputyPluginDefinition: 

667 return DebputyPluginDefinition() 

668 

669 

670class DebputyPluginInitializer: 

671 __slots__ = () 

672 

673 def packager_provided_file( 

674 self, 

675 stem: str, 

676 installed_path: str, 

677 *, 

678 default_mode: int = 0o0644, 

679 default_priority: int | None = None, 

680 allow_name_segment: bool = True, 

681 allow_architecture_segment: bool = False, 

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

683 packageless_is_fallback_for_all_packages: bool = False, 

684 reservation_only: bool = False, 

685 reference_documentation: None | ( 

686 PackagerProvidedFileReferenceDocumentation 

687 ) = None, 

688 ) -> None: 

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

690 

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

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

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

694 in the `debian/` directory. 

695 

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

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

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

699 

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

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

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

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

704 

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

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

707 

708 The following placeholders are supported: 

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

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

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

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

713 characters. 

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

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

716 

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

718 

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

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

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

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

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

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

725 always result in an error. 

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

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

728 error. 

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

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

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

732 provide a default priority. 

733 

734 The following placeholders are supported: 

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

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

737 is not None) 

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

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

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

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

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

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

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

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

746 is a fallback for every package. 

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

748 packager_provided_file_reference_documentation function to provide the value for this parameter. 

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

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

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

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

753 """ 

754 raise NotImplementedError 

755 

756 def metadata_or_maintscript_detector( 

757 self, 

758 auto_detector_id: str, 

759 auto_detector: MetadataAutoDetector, 

760 *, 

761 package_type: PackageTypeSelector = "deb", 

762 ) -> None: 

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

764 

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

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

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

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

769 

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

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

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

773 

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

775 any other hook. 

776 

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

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

779 

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

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

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

783 binary package). 

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

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

786 and ignore `udeb` packages. 

787 """ 

788 raise NotImplementedError 

789 

790 def manifest_variable( 

791 self, 

792 variable_name: str, 

793 value: str, 

794 *, 

795 variable_reference_documentation: str | None = None, 

796 ) -> None: 

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

798 

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

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

801 ... "path:BASH_COMPLETION_DIR", 

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

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

804 ... ) 

805 

806 :param variable_name: The variable name. 

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

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

809 the purpose of the variable. 

810 """ 

811 raise NotImplementedError 

812 

813 

814class MaintscriptAccessor: 

815 __slots__ = () 

816 

817 def on_configure( 

818 self, 

819 run_snippet: str, 

820 /, 

821 indent: bool | None = None, 

822 perform_substitution: bool = True, 

823 skip_on_rollback: bool = False, 

824 ) -> None: 

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

826 

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

828 common cases: 

829 * On initial install, OR 

830 * On upgrade 

831 

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

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

834 

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

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

837 

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

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

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

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

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

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

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

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

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

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

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

849 substitution is provided. 

850 """ 

851 raise NotImplementedError 

852 

853 def on_initial_install( 

854 self, 

855 run_snippet: str, 

856 /, 

857 indent: bool | None = None, 

858 perform_substitution: bool = True, 

859 ) -> None: 

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

861 

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

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

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

865 must still be idempotent): 

866 

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

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

869 

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

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

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

873 

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

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

876 

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

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

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

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

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

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

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

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

885 substitution is provided. 

886 """ 

887 raise NotImplementedError 

888 

889 def on_upgrade( 

890 self, 

891 run_snippet: str, 

892 /, 

893 indent: bool | None = None, 

894 perform_substitution: bool = True, 

895 ) -> None: 

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

897 

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

899 

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

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

902 

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

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

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

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

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

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

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

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

911 substitution is provided. 

912 """ 

913 raise NotImplementedError 

914 

915 def on_upgrade_from( 

916 self, 

917 version: str, 

918 run_snippet: str, 

919 /, 

920 indent: bool | None = None, 

921 perform_substitution: bool = True, 

922 ) -> None: 

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

924 

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

926 

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

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

929 

930 :param version: The version to upgrade from 

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

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

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

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

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

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

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

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

939 substitution is provided. 

940 """ 

941 raise NotImplementedError 

942 

943 def on_before_removal( 

944 self, 

945 run_snippet: str, 

946 /, 

947 indent: bool | None = None, 

948 perform_substitution: bool = True, 

949 ) -> None: 

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

951 

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

953 

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

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

956 

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

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

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

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

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

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

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

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

965 substitution is provided. 

966 """ 

967 raise NotImplementedError 

968 

969 def on_removed( 

970 self, 

971 run_snippet: str, 

972 /, 

973 indent: bool | None = None, 

974 perform_substitution: bool = True, 

975 ) -> None: 

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

977 

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

979 

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

981 

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

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

984 

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

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

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

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

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

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

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

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

993 substitution is provided. 

994 """ 

995 raise NotImplementedError 

996 

997 def on_purge( 

998 self, 

999 run_snippet: str, 

1000 /, 

1001 indent: bool | None = None, 

1002 perform_substitution: bool = True, 

1003 ) -> None: 

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

1005 

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

1007 

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

1009 

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

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

1012 

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

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

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

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

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

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

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

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

1021 substitution is provided. 

1022 """ 

1023 raise NotImplementedError 

1024 

1025 def unconditionally_in_script( 

1026 self, 

1027 maintscript: Maintscript, 

1028 run_snippet: str, 

1029 /, 

1030 perform_substitution: bool = True, 

1031 ) -> None: 

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

1033 

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

1035 for when it should be run. 

1036 

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

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

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

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

1041 substitutions by default. 

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

1043 substitution is provided. 

1044 """ 

1045 raise NotImplementedError 

1046 

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

1048 """Provide sh-shell escape of strings 

1049 

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

1051 

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

1053 contain spaces or shell meta-characters. 

1054 

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

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

1057 joined by a single space. 

1058 """ 

1059 return util.escape_shell(*args) 

1060 

1061 

1062class BinaryCtrlAccessor: 

1063 __slots__ = () 

1064 

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

1066 """Register a declarative dpkg level trigger 

1067 

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

1069 

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

1071 """ 

1072 raise NotImplementedError 

1073 

1074 @property 

1075 def maintscript(self) -> MaintscriptAccessor: 

1076 """Attribute for manipulating maintscripts""" 

1077 raise NotImplementedError 

1078 

1079 @property 

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

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

1082 raise NotImplementedError 

1083 

1084 

1085class VirtualPath: 

1086 __slots__ = () 

1087 

1088 @property 

1089 def name(self) -> str: 

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

1091 

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

1093 

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

1095 """ 

1096 raise NotImplementedError 

1097 

1098 @property 

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

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

1101 

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

1103 the iterable is always empty. 

1104 """ 

1105 raise NotImplementedError 

1106 

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

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

1109 

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

1111 

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

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

1114 

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

1116 

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

1118 whether the lookup is relative or absolute. 

1119 

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

1121 

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

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

1124 to this path. 

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

1126 """ 

1127 raise NotImplementedError 

1128 

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

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

1131 

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

1133 

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

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

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

1137 defined. 

1138 

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

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

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

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

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

1144 

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

1146 """ 

1147 raise NotImplementedError 

1148 

1149 @property 

1150 def is_detached(self) -> bool: 

1151 """Returns True if this path is detached 

1152 

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

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

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

1156 

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

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

1159 always be manipulated. 

1160 

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

1162 can be garbage collected. 

1163 """ 

1164 raise NotImplementedError 

1165 

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

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

1168 # behavior to avoid surprises for now. 

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

1170 # to using it) 

1171 __iter__ = None 

1172 

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

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

1175 

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

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

1178 

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

1180 """ 

1181 raise NotImplementedError 

1182 

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

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

1185 

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

1187 """ 

1188 raise NotImplementedError 

1189 

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

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

1192 

1193 The following are the same: 

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

1195 

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

1197 """ 

1198 try: 

1199 return self[key] 

1200 except KeyError: 

1201 return None 

1202 

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

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

1205 

1206 Examples: 

1207 

1208 if 'foo' in dir: ... 

1209 """ 

1210 if isinstance(item, VirtualPath): 

1211 return item.parent_dir is self 

1212 if not isinstance(item, str): 

1213 return False 

1214 m = self.get(item) 

1215 return m is not None 

1216 

1217 @property 

1218 def path(self) -> str: 

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

1220 

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

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

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

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

1225 you need if you want to read the file. 

1226 

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

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

1229 

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

1231 was known prior to being detached. 

1232 """ 

1233 raise NotImplementedError 

1234 

1235 @property 

1236 def absolute(self) -> str: 

1237 """Returns the absolute version of this path 

1238 

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

1240 

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

1242 of installation (prior to being detached). 

1243 

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

1245 """ 

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

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

1248 return f"/{p}" 

1249 return p 

1250 

1251 @property 

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

1253 """The parent directory of this path 

1254 

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

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

1257 

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

1259 """ 

1260 raise NotImplementedError 

1261 

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

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

1264 

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

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

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

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

1269 

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

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

1272 

1273 :return: The stat result or an error. 

1274 """ 

1275 raise NotImplementedError() 

1276 

1277 @property 

1278 def size(self) -> int: 

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

1280 

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

1282 

1283 :return: The size of the file in bytes 

1284 """ 

1285 return self.stat().st_size 

1286 

1287 @property 

1288 def mode(self) -> int: 

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

1290 

1291 Note that: 

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

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

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

1295 to the underlying file system in many cases. 

1296 

1297 

1298 :return: The mode bits for the path. 

1299 """ 

1300 raise NotImplementedError 

1301 

1302 @mode.setter 

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

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

1305 

1306 Note that: 

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

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

1309 an optimization). 

1310 

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

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

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

1314 debug errors. 

1315 """ 

1316 raise NotImplementedError 

1317 

1318 @property 

1319 def is_executable(self) -> bool: 

1320 """Determine whether a path is considered executable 

1321 

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

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

1324 parameter to be traversable. 

1325 

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

1327 """ 

1328 return bool(self.mode & 0o0111) 

1329 

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

1331 """Set the file mode of this path 

1332 

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

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

1335 

1336 Note that: 

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

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

1339 an optimization). 

1340 

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

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

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

1344 bits causes hard to debug errors. 

1345 """ 

1346 if isinstance(new_mode, str): 

1347 segments = parse_symbolic_mode(new_mode, None) 

1348 final_mode = self.mode 

1349 is_dir = self.is_dir 

1350 for segment in segments: 

1351 final_mode = segment.apply(final_mode, is_dir) 

1352 self.mode = final_mode 

1353 else: 

1354 self.mode = new_mode 

1355 

1356 def chown( 

1357 self, 

1358 owner: Optional["StaticFileSystemOwner"], 

1359 group: Optional["StaticFileSystemGroup"], 

1360 ) -> None: 

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

1362 

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

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

1365 """ 

1366 raise NotImplementedError 

1367 

1368 @property 

1369 def mtime(self) -> float: 

1370 """Determine the mtime of this path object 

1371 

1372 Note that: 

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

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

1375 normalization is handled later by `debputy`. 

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

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

1378 to the underlying file system in many cases. 

1379 

1380 :return: The mtime for the path. 

1381 """ 

1382 raise NotImplementedError 

1383 

1384 @mtime.setter 

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

1386 """Set the mtime of this path 

1387 

1388 Note that: 

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

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

1391 an optimization). 

1392 

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

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

1395 """ 

1396 raise NotImplementedError 

1397 

1398 def readlink(self) -> str: 

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

1400 

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

1402 `has_fs_path` is False. 

1403 

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

1405 """ 

1406 raise NotImplementedError() 

1407 

1408 @overload 

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

1410 self, 

1411 *, 

1412 byte_io: Literal[False] = False, 

1413 buffering: int = -1, 

1414 ) -> TextIO: ... 

1415 

1416 @overload 

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

1418 self, 

1419 *, 

1420 byte_io: Literal[True], 

1421 buffering: int = -1, 

1422 ) -> BinaryIO: ... 

1423 

1424 @overload 

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

1426 self, 

1427 *, 

1428 byte_io: bool, 

1429 buffering: int = -1, 

1430 ) -> TextIO | BinaryIO: ... 

1431 

1432 def open( 

1433 self, 

1434 *, 

1435 byte_io: bool = False, 

1436 buffering: int = -1, 

1437 ) -> TextIO | BinaryIO: 

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

1439 

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

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

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

1443 

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

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

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

1447 

1448 

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

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

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

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

1453 :return: The file handle. 

1454 """ 

1455 

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

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

1458 

1459 if byte_io: 

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

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

1462 

1463 @property 

1464 def fs_path(self) -> str: 

1465 """Request the underling fs_path of this path 

1466 

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

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

1469 

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

1471 multiple paths pointing to the same file system path. 

1472 

1473 Note that: 

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

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

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

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

1478 internal invariants. 

1479 

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

1481 file exist (see `has_fs_path`). 

1482 """ 

1483 raise NotImplementedError() 

1484 

1485 @property 

1486 def is_dir(self) -> bool: 

1487 """Determine if this path is a directory 

1488 

1489 Never follows symlinks. 

1490 

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

1492 """ 

1493 raise NotImplementedError() 

1494 

1495 @property 

1496 def is_file(self) -> bool: 

1497 """Determine if this path is a directory 

1498 

1499 Never follows symlinks. 

1500 

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

1502 """ 

1503 raise NotImplementedError() 

1504 

1505 @property 

1506 def is_symlink(self) -> bool: 

1507 """Determine if this path is a symlink 

1508 

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

1510 """ 

1511 raise NotImplementedError() 

1512 

1513 @property 

1514 def has_fs_path(self) -> bool: 

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

1516 

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

1518 """ 

1519 raise NotImplementedError() 

1520 

1521 @property 

1522 def is_read_write(self) -> bool: 

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

1524 

1525 Read-write rules are: 

1526 

1527 +--------------------------+-------------------+------------------------+ 

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

1529 +--------------------------+-------------------+------------------------+ 

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

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

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

1533 +--------------------------+-------------------+------------------------+ 

1534 

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

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

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

1538 optimizations. 

1539 

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

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

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

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

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

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

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

1547 

1548 :return: Whether file system mutations are permitted. 

1549 """ 

1550 return False 

1551 

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

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

1554 

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

1556 with this basename. 

1557 :return: The new subdirectory 

1558 """ 

1559 raise NotImplementedError 

1560 

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

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

1563 

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

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

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

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

1568 :return: The directory denoted by the given path 

1569 """ 

1570 raise NotImplementedError 

1571 

1572 def add_file( 

1573 self, 

1574 name: str, 

1575 *, 

1576 unlink_if_exists: bool = True, 

1577 use_fs_path_mode: bool = False, 

1578 mode: int = 0o0644, 

1579 mtime: float | None = None, 

1580 ) -> ContextManager["VirtualPath"]: 

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

1582 

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

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

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

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

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

1588 empty file when the context manager is entered. 

1589 

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

1591 

1592 >>> import subprocess 

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

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

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

1596 

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

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

1599 

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

1601 

1602 :param name: Basename of the new file 

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

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

1605 (when `unlink_if_exists` is True) 

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

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

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

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

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

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

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

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

1614 this interacts with the physical file system. 

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

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

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

1618 should be earlier than `SOURCE_DATE_EPOCH`. 

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

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

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

1622 manager exits 

1623 """ 

1624 raise NotImplementedError 

1625 

1626 def replace_fs_path_content( 

1627 self, 

1628 *, 

1629 use_fs_path_mode: bool = False, 

1630 ) -> ContextManager[str]: 

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

1632 

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

1634 

1635 Example: 

1636 >>> import subprocess 

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

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

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

1640 

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

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

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

1644 

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

1646 

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

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

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

1650 reliably restore the path. 

1651 

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

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

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

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

1656 definition. 

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

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

1659 the caller wishes until the context manager exits. 

1660 """ 

1661 raise NotImplementedError 

1662 

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

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

1665 

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

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

1668 

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

1670 

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

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

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

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

1675 :return: The newly created symlink. 

1676 """ 

1677 raise NotImplementedError 

1678 

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

1680 """Unlink a file or a directory 

1681 

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

1683 

1684 When the path is a: 

1685 

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

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

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

1689 

1690 Note that: 

1691 * the root directory cannot be deleted. 

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

1693 

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

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

1696 """ 

1697 raise NotImplementedError 

1698 

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

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

1701 

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

1703 

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

1705 """ 

1706 if not self.is_file: 

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

1708 try: 

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

1710 return extract_shebang_interpreter_from_file(fd) 

1711 except (PureVirtualPathError, TestPathWithNonExistentFSPathError): 

1712 return None 

1713 

1714 def metadata( 

1715 self, 

1716 metadata_type: type[PMT], 

1717 ) -> PathMetadataReference[PMT]: 

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

1719 

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

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

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

1723 

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

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

1726 

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

1728 

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

1730 :return: A reference to the metadata. 

1731 """ 

1732 raise NotImplementedError 

1733 

1734 

1735class FlushableSubstvars(Substvars): 

1736 __slots__ = () 

1737 

1738 @contextlib.contextmanager 

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

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

1741 

1742 >>> s = FlushableSubstvars() 

1743 >>> 'Test:Var' in s 

1744 False 

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

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

1747 >>> 'Test:Var' in s 

1748 True 

1749 

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

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

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

1753 

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

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

1756 the substvars. 

1757 

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

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

1760 terminates successfully. 

1761 

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

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

1764 successfully. 

1765 """ 

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

1767 self.write_substvars(tmp) 

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

1769 yield tmp.name 

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

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

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

1773 self.read_substvars(fd) 

1774 

1775 def save(self) -> None: 

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

1777 if self._substvars_path is None: 

1778 raise TypeError( 

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

1780 ) 

1781 super().save() 

1782 

1783 

1784class ServiceRegistry(Generic[DSD]): 

1785 __slots__ = () 

1786 

1787 def register_service( 

1788 self, 

1789 path: VirtualPath, 

1790 name: str | list[str], 

1791 *, 

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

1793 service_scope: str = "system", 

1794 enable_by_default: bool = True, 

1795 start_by_default: bool = True, 

1796 default_upgrade_rule: ServiceUpgradeRule = "restart", 

1797 service_context: DSD | None = None, 

1798 ) -> None: 

1799 """Register a service detected in the package 

1800 

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

1802 integration code is called. 

1803 

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

1805 

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

1807 2) Their plugin provided names has an overlap 

1808 

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

1810 

1811 :param path: The path defining this service. 

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

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

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

1815 to identify this service. 

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

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

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

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

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

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

1822 packager does not explicitly override this setting. 

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

1824 the packager does not explicitly override this setting. 

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

1826 upgrades. Options are: 

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

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

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

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

1831 start the service if not is not already running. 

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

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

1834 start the service if not is not already running. 

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

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

1837 

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

1839 integrator for this service. 

1840 """ 

1841 raise NotImplementedError 

1842 

1843 

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

1845class ParserAttributeDocumentation: 

1846 attributes: frozenset[str] 

1847 description: str | None 

1848 

1849 @property 

1850 def is_hidden(self) -> bool: 

1851 return False 

1852 

1853 

1854@final 

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

1856class StandardParserAttributeDocumentation(ParserAttributeDocumentation): 

1857 sort_category: int = 0 

1858 

1859 

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

1861 """Describe an attribute as undocumented 

1862 

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

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

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

1866 

1867 :param attr: Name of the attribute 

1868 """ 

1869 return ParserAttributeDocumentation( 

1870 frozenset({attr}), 

1871 None, 

1872 ) 

1873 

1874 

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

1876class ParserDocumentation: 

1877 synopsis: str | None = None 

1878 title: str | None = None 

1879 description: str | None = None 

1880 attribute_doc: Sequence[ParserAttributeDocumentation] | None = None 

1881 alt_parser_description: str | None = None 

1882 documentation_reference_url: str | None = None 

1883 

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

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

1886 

1887 @classmethod 

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

1889 attr = [ 

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

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

1892 ] 

1893 undoc_attr = ref_doc.get("undocumented_attributes") 

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

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

1896 

1897 return reference_documentation( 

1898 title=ref_doc["title"], 

1899 description=ref_doc["description"], 

1900 attributes=attr, 

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

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

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

1904 ) 

1905 

1906 

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

1908class TypeMappingExample(Generic[S]): 

1909 source_input: S 

1910 

1911 

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

1913class TypeMappingDocumentation(Generic[S]): 

1914 description: str | None = None 

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

1916 

1917 

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

1919 return TypeMappingExample(source_input) 

1920 

1921 

1922def type_mapping_reference_documentation( 

1923 *, 

1924 description: str | None = None, 

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

1926) -> TypeMappingDocumentation[S]: 

1927 e = ( 

1928 tuple([examples]) 

1929 if isinstance(examples, TypeMappingExample) 

1930 else tuple(examples) 

1931 ) 

1932 return TypeMappingDocumentation( 

1933 description=description, 

1934 examples=e, 

1935 ) 

1936 

1937 

1938def documented_attr( 

1939 attr: str | Iterable[str], 

1940 description: str, 

1941) -> ParserAttributeDocumentation: 

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

1943 

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

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

1946 

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

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

1949 target attribute). 

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

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

1952 the description of `reference_documentation`. 

1953 :return: An opaque representation of the documentation, 

1954 """ 

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

1956 return ParserAttributeDocumentation( 

1957 frozenset(attributes), 

1958 description, 

1959 ) 

1960 

1961 

1962def reference_documentation( 

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

1964 description: str | None = textwrap.dedent( 

1965 """\ 

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

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

1968 

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

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

1971 the manifest rule.) 

1972 """ 

1973 ), 

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

1975 non_mapping_description: str | None = None, 

1976 reference_documentation_url: str | None = None, 

1977 synopsis: str | None = None, 

1978) -> ParserDocumentation: 

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

1980 

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

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

1983 

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

1985 the alias provided by the user. 

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

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

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

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

1990 

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

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

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

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

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

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

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

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

1999 attributes exactly once. 

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

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

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

2003 the above listed variables. 

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

2005 :return: An opaque representation of the documentation, 

2006 """ 

2007 return ParserDocumentation( 

2008 synopsis, 

2009 title, 

2010 description, 

2011 attributes, 

2012 non_mapping_description, 

2013 reference_documentation_url, 

2014 ) 

2015 

2016 

2017class ServiceDefinition(Generic[DSD]): 

2018 __slots__ = () 

2019 

2020 @property 

2021 def name(self) -> str: 

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

2023 

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

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

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

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

2028 

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

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

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

2032 is needed. 

2033 """ 

2034 raise NotImplementedError 

2035 

2036 @property 

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

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

2039 

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

2041 the service earlier. 

2042 """ 

2043 raise NotImplementedError 

2044 

2045 @property 

2046 def path(self) -> VirtualPath: 

2047 """The registered path for this service 

2048 

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

2050 earlier. 

2051 """ 

2052 raise NotImplementedError 

2053 

2054 @property 

2055 def type_of_service(self) -> str: 

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

2057 

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

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

2060 """ 

2061 raise NotImplementedError 

2062 

2063 @property 

2064 def service_scope(self) -> str: 

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

2066 

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

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

2069 """ 

2070 raise NotImplementedError 

2071 

2072 @property 

2073 def auto_enable_on_install(self) -> bool: 

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

2075 

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

2077 """ 

2078 raise NotImplementedError 

2079 

2080 @property 

2081 def auto_start_on_install(self) -> bool: 

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

2083 

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

2085 """ 

2086 raise NotImplementedError 

2087 

2088 @property 

2089 def on_upgrade(self) -> ServiceUpgradeRule: 

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

2091 

2092 Options are: 

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

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

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

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

2097 start the service if not is not already running. 

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

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

2100 start the service if not is not already running. 

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

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

2103 

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

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

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

2107 being uninstalled. 

2108 

2109 :return: The service restart rule 

2110 """ 

2111 raise NotImplementedError 

2112 

2113 @property 

2114 def definition_source(self) -> str: 

2115 """Describes where this definition came from 

2116 

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

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

2119 to the plugin providing this definition. 

2120 

2121 :return: The source of this definition 

2122 """ 

2123 raise NotImplementedError 

2124 

2125 @property 

2126 def is_plugin_provided_definition(self) -> bool: 

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

2128 

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

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

2131 """ 

2132 raise NotImplementedError 

2133 

2134 @property 

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

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

2137 

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

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

2140 then this attribute will be None. 

2141 """ 

2142 raise NotImplementedError