Coverage for src/debputy/plugin/api/impl_types.py: 75%
522 statements
« prev ^ index » next coverage.py v7.8.2, created at 2026-07-19 09:13 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2026-07-19 09:13 +0000
1import collections
2import dataclasses
3import os.path
4import typing
5from collections.abc import Callable, Sequence, Iterable, Mapping, Iterator, Container
6from importlib.resources.abc import Traversable
7from pathlib import Path
8from typing import (
9 Optional,
10 TYPE_CHECKING,
11 TypeVar,
12 cast,
13 Any,
14 TypedDict,
15 NotRequired,
16 Literal,
17 Protocol,
18)
19from weakref import ref
21from debian.debian_support import DpkgArchTable
23from debputy._deb_options_profiles import DebBuildOptionsAndProfiles
24from debputy.exceptions import (
25 DebputyFSIsROError,
26 PluginAPIViolationError,
27 PluginConflictError,
28 UnhandledOrUnexpectedErrorFromPluginError,
29 PluginBaseError,
30 PluginInitializationError,
31)
32from debputy.filesystem_scan import as_path_def
33from debputy.manifest_conditions import ConditionContext
34from debputy.manifest_parser.exceptions import ManifestParseException
35from debputy.manifest_parser.tagging_types import DebputyParsedContent, TypeMapping
36from debputy.manifest_parser.util import AttributePath, check_integration_mode
37from debputy.packages import BinaryPackage, SourcePackage
38from debputy.plugin.api import (
39 VirtualPath,
40 BinaryCtrlAccessor,
41 PackageProcessingContext,
42)
43from debputy.plugin.api.spec import (
44 DebputyPluginInitializer,
45 MetadataAutoDetector,
46 DpkgTriggerType,
47 ParserDocumentation,
48 PackageProcessor,
49 PathDef,
50 ParserAttributeDocumentation,
51 undocumented_attr,
52 documented_attr,
53 reference_documentation,
54 PackagerProvidedFileReferenceDocumentation,
55 TypeMappingDocumentation,
56 DebputyIntegrationMode,
57)
58from debputy.plugin.plugin_state import (
59 run_in_context_of_plugin,
60)
61from debputy.substitution import VariableContext
62from debputy.util import (
63 _error,
64 _normalize_path,
65 package_cross_check_precheck,
66 PackageTypeSelector,
67)
69if TYPE_CHECKING:
70 from debputy.lsp.diagnostics import LintSeverity
71 from debputy.plugin.api.spec import (
72 ServiceDetector,
73 ServiceIntegrator,
74 )
75 from debputy.manifest_parser.parser_data import ParserContextData
76 from debputy.highlevel_manifest import (
77 HighLevelManifest,
78 PackageTransformationDefinition,
79 BinaryPackageData,
80 )
81 from debputy.plugins.debputy.to_be_api_types import (
82 BuildRuleParsedFormat,
83 BuildSystemRule,
84 )
87TD = TypeVar("TD", bound=DebputyParsedContent | list[DebputyParsedContent])
88PF = TypeVar("PF")
89SF = TypeVar("SF")
90TP = TypeVar("TP")
91TTP = type[TP]
92BSR = TypeVar("BSR", bound="BuildSystemRule")
94DIPKWHandler = Callable[[str, AttributePath, "ParserContextData"], TP]
95DIPHandler = Callable[[str, PF, AttributePath, "ParserContextData"], TP]
98@dataclasses.dataclass(slots=True)
99class DebputyPluginMetadata:
100 plugin_name: str
101 api_compat_version: int
102 plugin_loader: Callable[[], Callable[["DebputyPluginInitializer"], None]] | None
103 plugin_initializer: Callable[["DebputyPluginInitializer"], None] | None
104 plugin_path: str
105 plugin_doc_path_resolver: Callable[[], Traversable | Path | None] = lambda: None
106 is_from_python_path: bool = False
107 _is_initialized: bool = False
108 _is_doc_path_resolved: bool = False
109 _plugin_doc_path: Traversable | Path | None = None
111 @property
112 def is_bundled(self) -> bool:
113 return self.plugin_path == "<bundled>"
115 @property
116 def is_loaded(self) -> bool:
117 return self.plugin_initializer is not None
119 @property
120 def is_initialized(self) -> bool:
121 return self._is_initialized
123 @property
124 def plugin_doc_path(self) -> Traversable | Path | None:
125 if not self._is_doc_path_resolved:
126 self._plugin_doc_path = self.plugin_doc_path_resolver()
127 self._is_doc_path_resolved = True
128 return self._plugin_doc_path
130 def initialize_plugin(self, api: "DebputyPluginInitializer") -> None:
131 if self.is_initialized: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true
132 raise RuntimeError("Cannot load plugins twice")
133 if not self.is_loaded:
134 self.load_plugin()
135 plugin_initializer = self.plugin_initializer
136 assert plugin_initializer is not None
137 plugin_initializer(api)
138 self._is_initialized = True
140 def load_plugin(self) -> None:
141 plugin_loader = self.plugin_loader
142 assert plugin_loader is not None
143 try:
144 self.plugin_initializer = run_in_context_of_plugin(
145 self.plugin_name,
146 plugin_loader,
147 )
148 except PluginBaseError:
149 raise
150 except Exception as e:
151 raise PluginInitializationError(
152 f"Initialization of {self.plugin_name} failed due to its initializer raising an exception"
153 ) from e
154 assert self.plugin_initializer is not None
157@dataclasses.dataclass(slots=True, frozen=True)
158class PluginProvidedParser[PF, TP]:
159 parser: "DeclarativeInputParser[PF]"
160 handler: Callable[[str, PF, AttributePath, "ParserContextData"], TP]
161 plugin_metadata: DebputyPluginMetadata
163 def parse(
164 self,
165 name: str,
166 value: object,
167 attribute_path: AttributePath,
168 *,
169 parser_context: "ParserContextData",
170 ) -> TP:
171 parsed_value = self.parser.parse_input(
172 value,
173 attribute_path,
174 parser_context=parser_context,
175 )
176 return self.handler(name, parsed_value, attribute_path, parser_context)
179class PPFFormatParam(TypedDict):
180 priority: int | None
181 name: str
182 owning_package: str
185@dataclasses.dataclass(slots=True, frozen=True)
186class PackagerProvidedFileClassSpec:
187 debputy_plugin_metadata: DebputyPluginMetadata
188 stem: str
189 installed_as_format: str
190 default_mode: int
191 default_priority: int | None
192 allow_name_segment: bool
193 allow_architecture_segment: bool
194 post_formatting_rewrite: Callable[[str], str] | None
195 packageless_is_fallback_for_all_packages: bool
196 package_types: PackageTypeSelector
197 reservation_only: bool
198 formatting_callback: Callable[[str, PPFFormatParam, VirtualPath], str] | None = None
199 reference_documentation: PackagerProvidedFileReferenceDocumentation | None = None
200 bug_950723: bool = False
201 has_active_command: bool = True
203 @property
204 def supports_priority(self) -> bool:
205 return self.default_priority is not None
207 def compute_dest(
208 self,
209 assigned_name: str,
210 # Note this method is currently used 1:1 inside plugin tests.
211 *,
212 owning_package: str | None = None,
213 assigned_priority: int | None = None,
214 path: VirtualPath | None = None,
215 ) -> tuple[str, str]:
216 if assigned_priority is not None and not self.supports_priority: 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true
217 raise ValueError(
218 f"Cannot assign priority to packager provided files with stem"
219 f' "{self.stem}" (e.g., "debian/foo.{self.stem}"). They'
220 " do not use priority at all."
221 )
223 path_format = self.installed_as_format
224 if self.supports_priority and assigned_priority is None:
225 assigned_priority = self.default_priority
227 if owning_package is None:
228 owning_package = assigned_name
230 params: PPFFormatParam = {
231 "priority": assigned_priority,
232 "name": assigned_name,
233 "owning_package": owning_package,
234 }
236 if self.formatting_callback is not None:
237 if path is None: 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true
238 raise ValueError(
239 "The path parameter is required for PPFs with formatting_callback"
240 )
241 dest_path = self.formatting_callback(path_format, params, path)
242 else:
243 dest_path = path_format.format(**params)
245 dirname, basename = os.path.split(dest_path)
246 dirname = _normalize_path(dirname)
248 if self.post_formatting_rewrite:
249 basename = self.post_formatting_rewrite(basename)
250 return dirname, basename
253@dataclasses.dataclass(slots=True)
254class MetadataOrMaintscriptDetector:
255 plugin_metadata: DebputyPluginMetadata
256 detector_id: str
257 detector: MetadataAutoDetector
258 applies_to_package_types: PackageTypeSelector
259 enabled: bool = True
261 def applies_to(self, binary_package: BinaryPackage) -> bool:
262 return binary_package.package_type in self.applies_to_package_types
264 def run_detector(
265 self,
266 fs_root: "VirtualPath",
267 ctrl: "BinaryCtrlAccessor",
268 context: "PackageProcessingContext",
269 ) -> None:
270 try:
271 self.detector(fs_root, ctrl, context)
272 except DebputyFSIsROError as e:
273 nv = self.plugin_metadata.plugin_name
274 raise PluginAPIViolationError(
275 f'The plugin {nv} violated the API contract for "metadata detectors"'
276 " by attempting to mutate the provided file system in its metadata detector"
277 f" with id {self.detector_id}. File system mutation is *not* supported at"
278 " this stage (file system layout is committed and the attempted changes"
279 " would be lost)."
280 ) from e
281 except UnhandledOrUnexpectedErrorFromPluginError as e:
282 e.add_note(
283 f"The exception was raised by the detector with the ID: {self.detector_id}"
284 )
287class DeclarativeInputParser[TD]:
288 @property
289 def inline_reference_documentation(self) -> ParserDocumentation | None:
290 return None
292 @property
293 def expected_debputy_integration_mode(
294 self,
295 ) -> Container[DebputyIntegrationMode] | None:
296 return None
298 @property
299 def reference_documentation_url(self) -> str | None:
300 doc = self.inline_reference_documentation
301 return doc.documentation_reference_url if doc is not None else None
303 def parse_input(
304 self,
305 value: object,
306 path: AttributePath,
307 *,
308 parser_context: Optional["ParserContextData"] = None,
309 ) -> TD:
310 raise NotImplementedError
313class DelegatingDeclarativeInputParser(DeclarativeInputParser[TD]):
314 __slots__ = (
315 "delegate",
316 "_reference_documentation",
317 )
319 def __init__(
320 self,
321 delegate: DeclarativeInputParser[TD],
322 *,
323 inline_reference_documentation: ParserDocumentation | None = None,
324 ) -> None:
325 self.delegate = delegate
326 self._reference_documentation = inline_reference_documentation
328 @property
329 def expected_debputy_integration_mode(
330 self,
331 ) -> Container[DebputyIntegrationMode] | None:
332 return self.delegate.expected_debputy_integration_mode
334 @property
335 def inline_reference_documentation(self) -> ParserDocumentation | None:
336 doc = self._reference_documentation
337 if doc is None:
338 return self.delegate.inline_reference_documentation
339 return doc
342class AllowNoneDeclarativeInputParser(DelegatingDeclarativeInputParser[TD]):
343 __slots__ = ()
345 def parse_input(
346 self,
347 value: object | None,
348 path: AttributePath,
349 *,
350 parser_context: Optional["ParserContextData"] = None,
351 ) -> TD | None:
352 check_integration_mode(
353 path,
354 parser_context,
355 self.expected_debputy_integration_mode,
356 )
357 if value is None:
358 return None
359 return self.delegate.parse_input(
360 value,
361 path,
362 parser_context=parser_context,
363 )
366class ListWrappedDeclarativeInputParser(DelegatingDeclarativeInputParser[TD]):
367 __slots__ = ("_expected_debputy_integration_mode",)
369 def __init__(
370 self,
371 delegate: DeclarativeInputParser[TD],
372 *,
373 inline_reference_documentation: ParserDocumentation | None = None,
374 expected_debputy_integration_mode: (
375 Container[DebputyIntegrationMode] | None
376 ) = None,
377 ) -> None:
378 super().__init__(
379 delegate,
380 inline_reference_documentation=inline_reference_documentation,
381 )
382 self._expected_debputy_integration_mode = expected_debputy_integration_mode
384 @property
385 def expected_debputy_integration_mode(
386 self,
387 ) -> Container[DebputyIntegrationMode] | None:
388 if expected_debputy_integration_mode := self._expected_debputy_integration_mode:
389 return expected_debputy_integration_mode
390 return super().expected_debputy_integration_mode
392 def _doc_url_error_suffix(self, *, see_url_version: bool = False) -> str:
393 doc_url = self.reference_documentation_url
394 if doc_url is not None: 394 ↛ 398line 394 didn't jump to line 398 because the condition on line 394 was always true
395 if see_url_version: 395 ↛ 397line 395 didn't jump to line 397 because the condition on line 395 was always true
396 return f" Please see {doc_url} for the documentation."
397 return f" (Documentation: {doc_url})"
398 return ""
400 def parse_input(
401 self,
402 value: object,
403 path: AttributePath,
404 *,
405 parser_context: Optional["ParserContextData"] = None,
406 ) -> TD:
407 check_integration_mode(
408 path, parser_context, self.expected_debputy_integration_mode
409 )
410 if not isinstance(value, list):
411 doc_ref = self._doc_url_error_suffix(see_url_version=True)
412 raise ManifestParseException(
413 f"The attribute {path.path} must be a list.{doc_ref}"
414 )
415 result = []
416 delegate = self.delegate
417 for idx, element in enumerate(value):
418 element_path = path[idx]
419 result.append(
420 delegate.parse_input(
421 element,
422 element_path,
423 parser_context=parser_context,
424 )
425 )
426 return result
429class DispatchingParserBase[TP]:
430 def __init__(self, manifest_attribute_path_template: str) -> None:
431 self.manifest_attribute_path_template = manifest_attribute_path_template
432 self._parsers: dict[str, PluginProvidedParser[Any, TP]] = {}
434 @property
435 def unknown_keys_diagnostic_severity(self) -> Optional["LintSeverity"]:
436 return "error"
438 def is_known_keyword(self, keyword: str) -> bool:
439 return keyword in self._parsers
441 def registered_keywords(self) -> Iterable[str]:
442 yield from self._parsers
444 def parser_for(self, keyword: str) -> PluginProvidedParser[Any, TP]:
445 return self._parsers[keyword]
447 def register_keyword(
448 self,
449 keyword: str | Sequence[str],
450 handler: DIPKWHandler,
451 plugin_metadata: DebputyPluginMetadata,
452 *,
453 inline_reference_documentation: ParserDocumentation | None = None,
454 ) -> None:
455 reference_documentation_url = None
456 if inline_reference_documentation:
457 if inline_reference_documentation.attribute_doc: 457 ↛ 458line 457 didn't jump to line 458 because the condition on line 457 was never true
458 raise ValueError(
459 "Cannot provide per-attribute documentation for a value-less keyword!"
460 )
461 if inline_reference_documentation.alt_parser_description: 461 ↛ 462line 461 didn't jump to line 462 because the condition on line 461 was never true
462 raise ValueError(
463 "Cannot provide non-mapping-format documentation for a value-less keyword!"
464 )
465 reference_documentation_url = (
466 inline_reference_documentation.documentation_reference_url
467 )
468 parser = DeclarativeValuelessKeywordInputParser(
469 inline_reference_documentation,
470 documentation_reference=reference_documentation_url,
471 )
473 def _combined_handler(
474 name: str,
475 _ignored: Any,
476 attr_path: AttributePath,
477 context: "ParserContextData",
478 ) -> TP:
479 return handler(name, attr_path, context)
481 p = PluginProvidedParser(
482 parser,
483 _combined_handler,
484 plugin_metadata,
485 )
487 self._add_parser(keyword, p)
489 def register_parser(
490 self,
491 keyword: str | collections.abc.Sequence[str],
492 parser: "DeclarativeInputParser[PF]",
493 handler: Callable[[str, PF, AttributePath, "ParserContextData"], TP],
494 plugin_metadata: DebputyPluginMetadata,
495 ) -> None:
496 p = PluginProvidedParser(
497 parser,
498 handler,
499 plugin_metadata,
500 )
501 self._add_parser(keyword, p)
503 def _add_parser(
504 self,
505 keyword: str | Iterable[str],
506 ppp: PluginProvidedParser[PF, TP],
507 ) -> None:
508 ks = [keyword] if isinstance(keyword, str) else keyword
509 for k in ks:
510 existing_parser = self._parsers.get(k)
511 if existing_parser is not None: 511 ↛ 512line 511 didn't jump to line 512 because the condition on line 511 was never true
512 message = (
513 f'The rule name "{k}" is already taken by the plugin'
514 f" {existing_parser.plugin_metadata.plugin_name}. This conflict was triggered"
515 f" when plugin {ppp.plugin_metadata.plugin_name} attempted to register its parser."
516 )
517 raise PluginConflictError(
518 message,
519 existing_parser.plugin_metadata,
520 ppp.plugin_metadata,
521 )
522 self._new_parser(k, ppp)
524 def _new_parser(self, keyword: str, ppp: PluginProvidedParser[PF, TP]) -> None:
525 self._parsers[keyword] = ppp
527 def parse_input(
528 self,
529 orig_value: object,
530 attribute_path: AttributePath,
531 *,
532 parser_context: "ParserContextData",
533 ) -> TP:
534 raise NotImplementedError
537class DispatchingObjectParser(
538 DispatchingParserBase[Mapping[str, Any]],
539 DeclarativeInputParser[Mapping[str, Any]],
540):
541 def __init__(
542 self,
543 manifest_attribute_path_template: str,
544 *,
545 parser_documentation: ParserDocumentation | None = None,
546 expected_debputy_integration_mode: None | (
547 Container[DebputyIntegrationMode]
548 ) = None,
549 unknown_keys_diagnostic_severity: Optional["LintSeverity"] = "error",
550 allow_unknown_keys: bool = False,
551 ) -> None:
552 super().__init__(manifest_attribute_path_template)
553 self._attribute_documentation: list[ParserAttributeDocumentation] = []
554 if parser_documentation is None:
555 parser_documentation = reference_documentation()
556 self._parser_documentation = parser_documentation
557 self._expected_debputy_integration_mode = expected_debputy_integration_mode
558 self._unknown_keys_diagnostic_severity = unknown_keys_diagnostic_severity
559 self._allow_unknown_keys = allow_unknown_keys
561 @property
562 def unknown_keys_diagnostic_severity(self) -> Optional["LintSeverity"]:
563 return self._unknown_keys_diagnostic_severity
565 @property
566 def expected_debputy_integration_mode(
567 self,
568 ) -> Container[DebputyIntegrationMode] | None:
569 return self._expected_debputy_integration_mode
571 @property
572 def reference_documentation_url(self) -> str | None:
573 return self._parser_documentation.documentation_reference_url
575 @property
576 def inline_reference_documentation(self) -> ParserDocumentation | None:
577 ref_doc = self._parser_documentation
578 return reference_documentation(
579 title=ref_doc.title,
580 description=ref_doc.description,
581 attributes=self._attribute_documentation,
582 reference_documentation_url=self.reference_documentation_url,
583 )
585 def _new_parser(self, keyword: str, ppp: PluginProvidedParser[PF, TP]) -> None:
586 super()._new_parser(keyword, ppp)
587 doc = ppp.parser.inline_reference_documentation
588 if doc is None or doc.description is None:
589 self._attribute_documentation.append(undocumented_attr(keyword))
590 else:
591 self._attribute_documentation.append(
592 documented_attr(keyword, doc.description)
593 )
595 def register_child_parser(
596 self,
597 keyword: str,
598 parser: "DispatchingObjectParser",
599 plugin_metadata: DebputyPluginMetadata,
600 *,
601 on_end_parse_step: None | (
602 Callable[
603 [str, Mapping[str, Any] | None, AttributePath, "ParserContextData"],
604 None,
605 ]
606 ) = None,
607 nested_in_package_context: bool = False,
608 ) -> None:
609 def _handler(
610 name: str,
611 value: Mapping[str, Any],
612 path: AttributePath,
613 parser_context: "ParserContextData",
614 ) -> Mapping[str, Any]:
615 if on_end_parse_step is not None: 615 ↛ 617line 615 didn't jump to line 617 because the condition on line 615 was always true
616 on_end_parse_step(name, value, path, parser_context)
617 return value
619 if nested_in_package_context:
620 parser = InPackageContextParser(
621 keyword,
622 parser,
623 )
625 p = PluginProvidedParser(
626 parser,
627 _handler,
628 plugin_metadata,
629 )
630 self._add_parser(keyword, p)
632 def parse_input(
633 self,
634 orig_value: object,
635 attribute_path: AttributePath,
636 *,
637 parser_context: "ParserContextData",
638 ) -> TP:
639 check_integration_mode(
640 attribute_path,
641 parser_context,
642 self.expected_debputy_integration_mode,
643 )
644 doc_ref = ""
645 if self.reference_documentation_url is not None: 645 ↛ 649line 645 didn't jump to line 649 because the condition on line 645 was always true
646 doc_ref = (
647 f" Please see {self.reference_documentation_url} for the documentation."
648 )
649 if not isinstance(orig_value, dict):
650 raise ManifestParseException(
651 f"The attribute {attribute_path.path_container_lc} must be a non-empty mapping.{doc_ref}"
652 )
653 if not orig_value: 653 ↛ 654line 653 didn't jump to line 654 because the condition on line 653 was never true
654 raise ManifestParseException(
655 f"The attribute {attribute_path.path_container_lc} must be a non-empty mapping.{doc_ref}"
656 )
657 result = {}
658 unknown_keys = orig_value.keys() - self._parsers.keys()
659 if unknown_keys and not self._allow_unknown_keys: 659 ↛ 660line 659 didn't jump to line 660 because the condition on line 659 was never true
660 first_key = next(iter(unknown_keys))
661 remaining_valid_attributes = self._parsers.keys() - orig_value.keys()
662 if not remaining_valid_attributes:
663 raise ManifestParseException(
664 f'The attribute "{first_key}" is not applicable at {attribute_path.path} (with the'
665 f" current set of plugins).{doc_ref}"
666 )
667 remaining_valid_attribute_names = ", ".join(remaining_valid_attributes)
668 raise ManifestParseException(
669 f'The attribute "{first_key}" is not applicable at {attribute_path.path} (with the current set'
670 " of plugins). Possible attributes available (and not already used) are:"
671 f" {remaining_valid_attribute_names}.{doc_ref}"
672 )
673 # Parse order is important for the root level (currently we use rule registration order)
674 for key, provided_parser in self._parsers.items():
675 value = orig_value.get(key)
676 if value is None:
677 if isinstance(provided_parser.parser, DispatchingObjectParser):
678 provided_parser.handler(
679 key,
680 {},
681 attribute_path[key],
682 parser_context,
683 )
684 continue
685 value_path = attribute_path[key]
686 if provided_parser is None: 686 ↛ 687line 686 didn't jump to line 687 because the condition on line 686 was never true
687 valid_keys = ", ".join(sorted(self._parsers.keys()))
688 raise ManifestParseException(
689 f'Unknown or unsupported option "{key}" at {value_path.path}.'
690 " Valid options at this location are:"
691 f" {valid_keys}\n{doc_ref}"
692 )
693 parsed_value = provided_parser.parse(
694 key, value, value_path, parser_context=parser_context
695 )
696 result[key] = parsed_value
697 return result
700@dataclasses.dataclass(slots=True, frozen=True)
701class PackageContextData[TP]:
702 resolved_package_name: str
703 value: TP
706class InPackageContextParser(
707 DelegatingDeclarativeInputParser[Mapping[str, PackageContextData[TP]]]
708):
709 __slots__ = ()
711 def __init__(
712 self,
713 manifest_attribute_path_template: str,
714 delegate: DeclarativeInputParser[TP],
715 *,
716 parser_documentation: ParserDocumentation | None = None,
717 ) -> None:
718 self.manifest_attribute_path_template = manifest_attribute_path_template
719 self._attribute_documentation: list[ParserAttributeDocumentation] = []
720 super().__init__(delegate, inline_reference_documentation=parser_documentation)
722 def parse_input(
723 self,
724 orig_value: object,
725 attribute_path: AttributePath,
726 *,
727 parser_context: Optional["ParserContextData"] = None,
728 ) -> TP:
729 assert parser_context is not None
730 check_integration_mode(
731 attribute_path,
732 parser_context,
733 self.expected_debputy_integration_mode,
734 )
735 doc_ref = ""
736 if self.reference_documentation_url is not None: 736 ↛ 740line 736 didn't jump to line 740 because the condition on line 736 was always true
737 doc_ref = (
738 f" Please see {self.reference_documentation_url} for the documentation."
739 )
740 if not isinstance(orig_value, dict) or not orig_value: 740 ↛ 741line 740 didn't jump to line 741 because the condition on line 740 was never true
741 raise ManifestParseException(
742 f"The attribute {attribute_path.path_container_lc} must be a non-empty mapping.{doc_ref}"
743 )
744 delegate = self.delegate
745 result = {}
746 for package_name_raw, value in orig_value.items():
748 definition_source = attribute_path[package_name_raw]
749 package_name = package_name_raw
750 if "{{" in package_name:
751 package_name = parser_context.substitution.substitute(
752 package_name_raw,
753 definition_source.path,
754 )
755 package_state: PackageTransformationDefinition
756 with parser_context.binary_package_context(package_name) as package_state:
757 if package_state.is_auto_generated_package: 757 ↛ 759line 757 didn't jump to line 759 because the condition on line 757 was never true
758 # Maybe lift (part) of this restriction.
759 raise ManifestParseException(
760 f'Cannot define rules for package "{package_name}" (at {definition_source.path}). It is an'
761 " auto-generated package."
762 )
763 parsed_value = delegate.parse_input(
764 value, definition_source, parser_context=parser_context
765 )
766 result[package_name_raw] = PackageContextData(
767 package_name, parsed_value
768 )
769 return result
772class DispatchingTableParser(
773 DispatchingParserBase[TP],
774 DeclarativeInputParser[TP],
775):
776 def __init__(self, base_type: TTP, manifest_attribute_path_template: str) -> None:
777 super().__init__(manifest_attribute_path_template)
778 self.base_type = base_type
780 def parse_input(
781 self,
782 orig_value: object,
783 attribute_path: AttributePath,
784 *,
785 parser_context: "ParserContextData",
786 ) -> TP:
787 if isinstance(orig_value, str): 787 ↛ 788line 787 didn't jump to line 788 because the condition on line 787 was never true
788 key = orig_value
789 value = None
790 value_path = attribute_path
791 elif isinstance(orig_value, dict): 791 ↛ 802line 791 didn't jump to line 802 because the condition on line 791 was always true
792 if len(orig_value) != 1: 792 ↛ 793line 792 didn't jump to line 793 because the condition on line 792 was never true
793 valid_keys = ", ".join(sorted(self._parsers.keys()))
794 raise ManifestParseException(
795 f'The mapping "{attribute_path.path}" had two keys, but it should only have one top level key.'
796 " Maybe you are missing a list marker behind the second key or some indentation. The"
797 f" possible keys are: {valid_keys}"
798 )
799 key, value = next(iter(orig_value.items()))
800 value_path = attribute_path[key]
801 else:
802 raise ManifestParseException(
803 f"The attribute {attribute_path.path} must be a string or a mapping."
804 )
805 provided_parser = self._parsers.get(key)
806 if provided_parser is None: 806 ↛ 807line 806 didn't jump to line 807 because the condition on line 806 was never true
807 valid_keys = ", ".join(sorted(self._parsers.keys()))
808 raise ManifestParseException(
809 f'Unknown or unsupported action "{key}" at {value_path.path}.'
810 " Valid actions at this location are:"
811 f" {valid_keys}"
812 )
813 return provided_parser.parse(
814 key, value, value_path, parser_context=parser_context
815 )
818@dataclasses.dataclass(slots=True)
819class DeclarativeValuelessKeywordInputParser(DeclarativeInputParser[None]):
820 inline_reference_documentation: ParserDocumentation | None = None
821 documentation_reference: str | None = None
823 def parse_input(
824 self,
825 value: object,
826 path: AttributePath,
827 *,
828 parser_context: Optional["ParserContextData"] = None,
829 ) -> TD:
830 if value is None:
831 return cast("TD", value)
832 if self.documentation_reference is not None:
833 doc_ref = f" (Documentation: {self.documentation_reference})"
834 else:
835 doc_ref = ""
836 raise ManifestParseException(
837 f"Expected attribute {path.path} to be a string.{doc_ref}"
838 )
841@dataclasses.dataclass(slots=True)
842class PluginProvidedManifestVariable:
843 plugin_metadata: DebputyPluginMetadata
844 variable_name: str
845 variable_value: str | Callable[[VariableContext], str] | None
846 is_context_specific_variable: bool
847 variable_reference_documentation: str | None = None
848 is_documentation_placeholder: bool = False
849 is_for_special_case: bool = False
851 @property
852 def is_internal(self) -> bool:
853 return self.variable_name.startswith("_") or ":_" in self.variable_name
855 @property
856 def is_token(self) -> bool:
857 return self.variable_name.startswith("token:")
859 def resolve(self, variable_context: VariableContext) -> str:
860 value_resolver = self.variable_value
861 if isinstance(value_resolver, str):
862 res = value_resolver
863 elif value_resolver is None: 863 ↛ 864line 863 didn't jump to line 864 because the condition on line 863 was never true
864 _error(f"variable {self.variable_name} is not set")
865 else:
866 res = value_resolver(variable_context)
867 return res
870@dataclasses.dataclass(slots=True, frozen=True)
871class AutomaticDiscardRuleExample:
872 content: Sequence[tuple[PathDef, bool]]
873 description: str | None = None
876def automatic_discard_rule_example(
877 *content: str | PathDef | tuple[str | PathDef, bool],
878 example_description: str | None = None,
879) -> AutomaticDiscardRuleExample:
880 """Provide an example for an automatic discard rule
882 The return value of this method should be passed to the `examples` parameter of
883 `automatic_discard_rule` method - either directly for a single example or as a
884 part of a sequence of examples.
886 >>> # Possible example for an exclude rule for ".la" files
887 >>> # Example shows two files; The ".la" file that will be removed and another file that
888 >>> # will be kept.
889 >>> automatic_discard_rule_example( # doctest: +ELLIPSIS
890 ... "usr/lib/libfoo.la",
891 ... ("usr/lib/libfoo.so.1.0.0", False),
892 ... )
893 AutomaticDiscardRuleExample(...)
895 Keep in mind that you have to explicitly include directories that are relevant for the test
896 if you want them shown. Also, if a directory is excluded, all path beneath it will be
897 automatically excluded in the example as well. Your example data must account for that.
899 >>> # Possible example for python cache file discard rule
900 >>> # In this example, we explicitly list the __pycache__ directory itself because we
901 >>> # want it shown in the output (otherwise, we could have omitted it)
902 >>> automatic_discard_rule_example( # doctest: +ELLIPSIS
903 ... (".../foo.py", False),
904 ... ".../__pycache__/",
905 ... ".../__pycache__/...",
906 ... ".../foo.pyc",
907 ... ".../foo.pyo",
908 ... )
909 AutomaticDiscardRuleExample(...)
911 Note: Even if `__pycache__` had been implicit, the result would have been the same. However,
912 the rendered example would not have shown the directory on its own. The use of `...` as
913 path names is useful for denoting "anywhere" or "anything". Though, there is nothing "magic"
914 about this name - it happens to be allowed as a path name (unlike `.` or `..`).
916 These examples can be seen via `debputy plugin show automatic-discard-rules <name-here>`.
918 :param content: The content of the example. Each element can be either a path definition or
919 a tuple of a path definition followed by a verdict (boolean). Each provided path definition
920 describes the paths to be presented in the example. Implicit paths such as parent
921 directories will be created but not shown in the example. Therefore, if a directory is
922 relevant to the example, be sure to explicitly list it.
924 The verdict associated with a path determines whether the path should be discarded (when
925 True) or kept (when False). When a path is not explicitly associated with a verdict, the
926 verdict is assumed to be discarded (True).
927 :param example_description: An optional description displayed together with the example.
928 :return: An opaque data structure containing the example.
929 """
930 example = []
931 for d in content:
932 if not isinstance(d, tuple):
933 pd = d
934 verdict = True
935 else:
936 pd, verdict = d
938 path_def = as_path_def(pd)
939 example.append((path_def, verdict))
941 if not example: 941 ↛ 942line 941 didn't jump to line 942 because the condition on line 941 was never true
942 raise ValueError("At least one path must be given for an example")
944 return AutomaticDiscardRuleExample(
945 tuple(example),
946 description=example_description,
947 )
950@dataclasses.dataclass(slots=True, frozen=True)
951class PluginProvidedPackageProcessor:
952 processor_id: str
953 applies_to_package_types: PackageTypeSelector
954 package_processor: PackageProcessor
955 dependencies: frozenset[tuple[str, str]]
956 plugin_metadata: DebputyPluginMetadata
958 def applies_to(self, binary_package: BinaryPackage) -> bool:
959 return binary_package.package_type in self.applies_to_package_types
961 @property
962 def dependency_id(self) -> tuple[str, str]:
963 return self.plugin_metadata.plugin_name, self.processor_id
965 def run_package_processor(
966 self,
967 fs_root: "VirtualPath",
968 unused: None,
969 context: "PackageProcessingContext",
970 ) -> None:
971 self.package_processor(fs_root, unused, context)
974@dataclasses.dataclass(slots=True, frozen=True)
975class PluginProvidedDiscardRule:
976 name: str
977 plugin_metadata: DebputyPluginMetadata
978 discard_check: Callable[[VirtualPath], bool]
979 reference_documentation: str | None
980 examples: Sequence[AutomaticDiscardRuleExample] = tuple()
982 def should_discard(self, path: VirtualPath) -> bool:
983 return self.discard_check(path)
986@dataclasses.dataclass(slots=True, frozen=True)
987class ServiceManagerDetails:
988 service_manager: str
989 service_detector: "ServiceDetector"
990 service_integrator: "ServiceIntegrator"
991 plugin_metadata: DebputyPluginMetadata
994class ReferenceValue(TypedDict):
995 description: str
998def _reference_data_value(
999 *,
1000 description: str,
1001) -> ReferenceValue:
1002 return {
1003 "description": description,
1004 }
1007KnownPackagingFileCategories = Literal[
1008 "generated",
1009 "generic-template",
1010 "ppf-file",
1011 "ppf-control-file",
1012 "maint-config",
1013 "pkg-metadata",
1014 "pkg-helper-config",
1015 "testing",
1016 "lint-config",
1017]
1018KNOWN_PACKAGING_FILE_CATEGORY_DESCRIPTIONS: Mapping[
1019 KnownPackagingFileCategories, ReferenceValue
1020] = {
1021 "generated": _reference_data_value(
1022 description="The file is (likely) generated from another file"
1023 ),
1024 "generic-template": _reference_data_value(
1025 description="The file is (likely) a generic template that generates a known packaging file. While the"
1026 " file is annotated as if it was the target file, the file might uses a custom template"
1027 " language inside it."
1028 ),
1029 "ppf-file": _reference_data_value(
1030 description="Packager provided file to be installed on the file system - usually as-is."
1031 " When `install-pattern` or `install-path` are provided, this is where the file is installed."
1032 ),
1033 "ppf-control-file": _reference_data_value(
1034 description="Packager provided file that becomes a control file - possible after processing. "
1035 " If `install-pattern` or `install-path` are provided, they denote where the is placed"
1036 " (generally, this will be of the form `DEBIAN/<name>`)"
1037 ),
1038 "maint-config": _reference_data_value(
1039 description="Maintenance configuration for a specific tool that the maintainer uses (tool / style preferences)"
1040 ),
1041 "pkg-metadata": _reference_data_value(
1042 description="The file is related to standard package metadata (usually documented in Debian Policy)"
1043 ),
1044 "pkg-helper-config": _reference_data_value(
1045 description="The file is packaging helper configuration or instruction file"
1046 ),
1047 "testing": _reference_data_value(
1048 description="The file is related to automated testing (autopkgtests, salsa/gitlab CI)."
1049 ),
1050 "lint-config": _reference_data_value(
1051 description="The file is related to a linter (such as overrides for false-positives or style preferences)"
1052 ),
1053}
1055KnownPackagingConfigFeature = Literal[
1056 "dh-filearray",
1057 "dh-filedoublearray",
1058 "dh-hash-subst",
1059 "dh-dollar-subst",
1060 "dh-glob",
1061 "dh-partial-glob",
1062 "dh-late-glob",
1063 "dh-glob-after-execute",
1064 "dh-executable-config",
1065 "dh-custom-format",
1066 "dh-file-list",
1067 "dh-install-list",
1068 "dh-install-list-dest-dir-like-dh_install",
1069 "dh-install-list-fixed-dest-dir",
1070 "dh-fixed-dest-dir",
1071 "dh-exec-rename",
1072 "dh-docs-only",
1073]
1075KNOWN_PACKAGING_FILE_CONFIG_FEATURE_DESCRIPTION: Mapping[
1076 KnownPackagingConfigFeature, ReferenceValue
1077] = {
1078 "dh-filearray": _reference_data_value(
1079 description="The file will be read as a list of space/newline separated tokens",
1080 ),
1081 "dh-filedoublearray": _reference_data_value(
1082 description="Each line in the file will be read as a list of space-separated tokens",
1083 ),
1084 "dh-hash-subst": _reference_data_value(
1085 description="Supports debhelper #PACKAGE# style substitutions (udebs often excluded)",
1086 ),
1087 "dh-dollar-subst": _reference_data_value(
1088 description="Supports debhelper ${PACKAGE} style substitutions (usually requires compat 13+)",
1089 ),
1090 "dh-glob": _reference_data_value(
1091 description="Supports standard debhelper globing",
1092 ),
1093 "dh-partial-glob": _reference_data_value(
1094 description="Supports standard debhelper globing but only to a subset of the values (implies dh-late-glob)",
1095 ),
1096 "dh-late-glob": _reference_data_value(
1097 description="Globbing is done separately instead of using the built-in function",
1098 ),
1099 "dh-glob-after-execute": _reference_data_value(
1100 description="When the dh config file is executable, the generated output will be subject to globbing",
1101 ),
1102 "dh-executable-config": _reference_data_value(
1103 description="If marked executable, debhelper will execute the file and read its output",
1104 ),
1105 "dh-custom-format": _reference_data_value(
1106 description="The dh tool will or may have a custom parser for this file",
1107 ),
1108 "dh-file-list": _reference_data_value(
1109 description="The dh file contains a list of paths to be processed",
1110 ),
1111 "dh-install-list": _reference_data_value(
1112 description="The dh file contains a list of paths/globs to be installed but the tool specific knowledge"
1113 " required to understand the file cannot be conveyed via this interface.",
1114 ),
1115 "dh-install-list-dest-dir-like-dh_install": _reference_data_value(
1116 description="The dh file is processed similar to dh_install (notably dest-dir handling derived"
1117 " from the path or the last token on the line)",
1118 ),
1119 "dh-install-list-fixed-dest-dir": _reference_data_value(
1120 description="The dh file is an install list and the dest-dir is always the same for all patterns"
1121 " (when `install-pattern` or `install-path` are provided, they identify the directory - not the file location)",
1122 ),
1123 "dh-exec-rename": _reference_data_value(
1124 description="When `dh-exec` is the interpreter of this dh config file, its renaming (=>) feature can be"
1125 " requested/used",
1126 ),
1127 "dh-docs-only": _reference_data_value(
1128 description="The dh config file is used for documentation only. Implicit <!nodocs> Build-Profiles support",
1129 ),
1130}
1132CONFIG_FEATURE_ALIASES: dict[
1133 KnownPackagingConfigFeature, list[tuple[KnownPackagingConfigFeature, int]]
1134] = {
1135 "dh-filearray": [
1136 ("dh-filearray", 0),
1137 ("dh-executable-config", 9),
1138 ("dh-dollar-subst", 13),
1139 ],
1140 "dh-filedoublearray": [
1141 ("dh-filedoublearray", 0),
1142 ("dh-executable-config", 9),
1143 ("dh-dollar-subst", 13),
1144 ],
1145}
1148def _implies(
1149 features: list[KnownPackagingConfigFeature],
1150 seen: set[KnownPackagingConfigFeature],
1151 implying: Sequence[KnownPackagingConfigFeature],
1152 implied: KnownPackagingConfigFeature,
1153) -> None:
1154 if implied in seen:
1155 return
1156 if all(f in seen for f in implying):
1157 seen.add(implied)
1158 features.append(implied)
1161def expand_known_packaging_config_features(
1162 compat_level: int,
1163 features: list[KnownPackagingConfigFeature],
1164) -> list[KnownPackagingConfigFeature]:
1165 final_features: list[KnownPackagingConfigFeature] = []
1166 seen = set()
1167 for feature in features:
1168 expanded = CONFIG_FEATURE_ALIASES.get(feature)
1169 if not expanded:
1170 expanded = [(feature, 0)]
1171 for v, c in expanded:
1172 if compat_level < c or v in seen:
1173 continue
1174 seen.add(v)
1175 final_features.append(v)
1176 if "dh-glob" in seen and "dh-late-glob" in seen:
1177 final_features.remove("dh-glob")
1179 _implies(final_features, seen, ["dh-partial-glob"], "dh-late-glob")
1180 _implies(
1181 final_features,
1182 seen,
1183 ["dh-late-glob", "dh-executable-config"],
1184 "dh-glob-after-execute",
1185 )
1186 return sorted(final_features)
1189class DHCompatibilityBasedRule(DebputyParsedContent):
1190 install_pattern: NotRequired[str]
1191 add_config_features: NotRequired[list[KnownPackagingConfigFeature]]
1192 starting_with_compat_level: NotRequired[int]
1195class KnownPackagingFileInfo(DebputyParsedContent):
1196 # Exposed directly in the JSON plugin parsing; be careful with changes
1197 path: NotRequired[str]
1198 pkgfile: NotRequired[str]
1199 detection_method: NotRequired[Literal["path", "dh.pkgfile"]]
1200 file_categories: NotRequired[list[KnownPackagingFileCategories]]
1201 documentation_uris: NotRequired[list[str]]
1202 debputy_cmd_templates: NotRequired[list[list[str]]]
1203 debhelper_commands: NotRequired[list[str]]
1204 config_features: NotRequired[list[KnownPackagingConfigFeature]]
1205 install_pattern: NotRequired[str]
1206 dh_compat_rules: NotRequired[list[DHCompatibilityBasedRule]]
1207 default_priority: NotRequired[int]
1208 post_formatting_rewrite: NotRequired[Literal["period-to-underscore"]]
1209 packageless_is_fallback_for_all_packages: NotRequired[bool]
1210 has_active_command: NotRequired[bool]
1213@dataclasses.dataclass(slots=True)
1214class PluginProvidedKnownPackagingFile:
1215 info: KnownPackagingFileInfo
1216 detection_method: Literal["path", "dh.pkgfile"]
1217 detection_value: str
1218 plugin_metadata: DebputyPluginMetadata
1221class BuildSystemAutoDetector(Protocol):
1223 def __call__(self, source_root: VirtualPath, *args: Any, **kwargs: Any) -> bool: ... 1223 ↛ exitline 1223 didn't return from function '__call__' because
1226@dataclasses.dataclass(slots=True, frozen=True)
1227class PluginProvidedTypeMapping:
1228 mapped_type: TypeMapping[Any, Any]
1229 reference_documentation: TypeMappingDocumentation | None
1230 plugin_metadata: DebputyPluginMetadata
1233@dataclasses.dataclass(slots=True, frozen=True)
1234class PluginProvidedBuildSystemAutoDetection[BSR]:
1235 manifest_keyword: str
1236 build_system_rule_type: type[BSR]
1237 detector: BuildSystemAutoDetector
1238 constructor: Callable[
1239 ["BuildRuleParsedFormat", AttributePath, "HighLevelManifest"],
1240 BSR,
1241 ]
1242 auto_detection_shadow_build_systems: frozenset[str]
1243 plugin_metadata: DebputyPluginMetadata
1246class PackageDataTable:
1247 def __init__(self, package_data_table: Mapping[str, "BinaryPackageData"]) -> None:
1248 self._package_data_table = package_data_table
1249 # This is enabled for metadata-detectors. But it is deliberate not enabled for package processors,
1250 # because it is not clear how it should interact with dependencies. For metadata-detectors, things
1251 # read-only and there are no dependencies, so we cannot "get them wrong".
1252 self.enable_cross_package_checks = False
1254 def __iter__(self) -> Iterator["BinaryPackageData"]:
1255 return iter(self._package_data_table.values())
1257 def __getitem__(self, item: str) -> "BinaryPackageData":
1258 return self._package_data_table[item]
1260 def __contains__(self, item: str) -> bool:
1261 return item in self._package_data_table
1264class PackageProcessingContextProvider(PackageProcessingContext):
1265 __slots__ = (
1266 "_manifest",
1267 "_binary_package",
1268 "_related_udeb_package",
1269 "_package_data_table",
1270 "_cross_check_cache",
1271 )
1273 def __init__(
1274 self,
1275 manifest: "HighLevelManifest",
1276 binary_package: BinaryPackage,
1277 related_udeb_package: BinaryPackage | None,
1278 package_data_table: PackageDataTable,
1279 ) -> None:
1280 self._manifest = manifest
1281 self._binary_package = binary_package
1282 self._related_udeb_package = related_udeb_package
1283 self._package_data_table = ref(package_data_table)
1284 self._cross_check_cache: None | (
1285 Sequence[tuple[BinaryPackage, "VirtualPath"]]
1286 ) = None
1288 def _package_state_for(
1289 self,
1290 package: BinaryPackage,
1291 ) -> "PackageTransformationDefinition":
1292 return self._manifest.package_state_for(package.name)
1294 def _package_version_for(
1295 self,
1296 package: BinaryPackage,
1297 ) -> str:
1298 package_state = self._package_state_for(package)
1299 version = package_state.binary_version
1300 if version is not None:
1301 return version
1302 return self._manifest.source_version(
1303 include_binnmu_version=not package.is_arch_all
1304 )
1306 @property
1307 def source_package(self) -> SourcePackage:
1308 return self._manifest.source_package
1310 @property
1311 def binary_package(self) -> BinaryPackage:
1312 return self._binary_package
1314 @property
1315 def related_udeb_package(self) -> BinaryPackage | None:
1316 return self._related_udeb_package
1318 @property
1319 def binary_package_version(self) -> str:
1320 return self._package_version_for(self._binary_package)
1322 @property
1323 def related_udeb_package_version(self) -> str | None:
1324 udeb = self._related_udeb_package
1325 if udeb is None:
1326 return None
1327 return self._package_version_for(udeb)
1329 def accessible_package_roots(self) -> Iterable[tuple[BinaryPackage, "VirtualPath"]]:
1330 package_table = self._package_data_table()
1331 if package_table is None:
1332 raise ReferenceError(
1333 "Internal error: package_table was garbage collected too early"
1334 )
1335 if not package_table.enable_cross_package_checks:
1336 raise PluginAPIViolationError(
1337 "Cross package content checks are not available at this time."
1338 )
1339 cache = self._cross_check_cache
1340 if cache is None:
1341 matches = []
1342 pkg = self.binary_package
1343 for pkg_data in package_table:
1344 if pkg_data.binary_package.name == pkg.name:
1345 continue
1346 res = package_cross_check_precheck(pkg, pkg_data.binary_package)
1347 if not res[0]:
1348 continue
1349 matches.append((pkg_data.binary_package, pkg_data.fs_root))
1350 cache = tuple(matches) if matches else tuple()
1351 self._cross_check_cache = cache
1352 return cache
1354 def manifest_configuration[T](
1355 self,
1356 context_package: SourcePackage | BinaryPackage,
1357 value_type: type[T],
1358 ) -> T | None:
1359 return self._manifest.manifest_configuration(context_package, value_type)
1361 @property
1362 def dpkg_arch_query_table(self) -> DpkgArchTable:
1363 return self._manifest.dpkg_arch_query_table
1365 @property
1366 def deb_options_and_profiles(self) -> DebBuildOptionsAndProfiles:
1367 return self._manifest.deb_options_and_profiles
1369 @property
1370 def source_condition_context(self) -> ConditionContext:
1371 return self._manifest.source_condition_context
1373 def condition_context(
1374 self, binary_package: BinaryPackage | None
1375 ) -> ConditionContext:
1376 return self._manifest.condition_context(binary_package)
1379@dataclasses.dataclass(slots=True, frozen=True)
1380class PluginProvidedTrigger:
1381 dpkg_trigger_type: DpkgTriggerType
1382 dpkg_trigger_target: str
1383 provider: DebputyPluginMetadata
1384 provider_source_id: str
1386 def serialized_format(self) -> str:
1387 return f"{self.dpkg_trigger_type} {self.dpkg_trigger_target}"