Coverage for src/debputy/manifest_parser/parse_hints.py: 84%
51 statements
« prev ^ index » next coverage.py v7.8.2, created at 2026-01-16 17:20 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2026-01-16 17:20 +0000
1import dataclasses
2from typing import TYPE_CHECKING
3from collections.abc import Callable
5from debputy.util import PackageTypeSelector
7if TYPE_CHECKING:
8 from debputy.manifest_parser.parser_data import ParserContextData
11class DebputyParseHint:
12 @classmethod
13 def target_attribute(cls, target_attribute: str) -> "DebputyParseHint":
14 """Define this source attribute to have a different target attribute name
16 As an example:
18 >>> from typing import Annotated, NotRequired, TypedDict
19 >>> from debputy.manifest_parser.declarative_parser import ParserGenerator
20 >>> class SourceType(TypedDict):
21 ... source: Annotated[NotRequired[str], DebputyParseHint.target_attribute("sources")]
22 ... sources: NotRequired[list[str]]
23 >>> class TargetType(TypedDict):
24 ... sources: list[str]
25 >>> pg = ParserGenerator()
26 >>> parser = pg.generate_parser(TargetType, source_content=SourceType)
28 In this example, the user can provide either `source` or `sources` and the parser will
29 map them to the `sources` attribute in the `TargetType`. Note this example relies on
30 the builtin mapping of `str` to `list[str]` to align the types between `source` (from
31 SourceType) and `sources` (from TargetType).
33 The following rules apply:
35 * All source attributes that map to the same target attribute will be mutually exclusive
36 (that is, the user cannot give `source` *and* `sources` as input).
37 * When the target attribute is required, the source attributes are conditionally
38 mandatory requiring the user to provide exactly one of them.
39 * When multiple source attributes point to a single target attribute, none of the source
40 attributes can be Required.
41 * The annotation can only be used for the source type specification and the source type
42 specification must be different from the target type specification.
44 The `target_attribute` annotation can be used without having multiple source attributes. This
45 can be useful if the source attribute name is not valid as a python variable identifier to
46 rename it to a valid python identifier.
48 :param target_attribute: The attribute name in the target content
49 :return: The annotation.
50 """
51 return TargetAttribute(target_attribute)
53 @classmethod
54 def conflicts_with_source_attributes(
55 cls,
56 *conflicting_source_attributes: str,
57 ) -> "DebputyParseHint":
58 """Declare a conflict with one or more source attributes
60 Example:
62 >>> from typing import Annotated, NotRequired, TypedDict
63 >>> from debputy.manifest_parser.declarative_parser import ParserGenerator
64 >>> class SourceType(TypedDict):
65 ... source: Annotated[NotRequired[str], DebputyParseHint.target_attribute("sources")]
66 ... sources: NotRequired[list[str]]
67 ... into_dir: NotRequired[str]
68 ... renamed_to: Annotated[
69 ... NotRequired[str],
70 ... DebputyParseHint.conflicts_with_source_attributes("sources", "into_dir")
71 ... ]
72 >>> class TargetType(TypedDict):
73 ... sources: list[str]
74 ... into_dir: NotRequired[str]
75 ... renamed_to: NotRequired[str]
76 >>> pg = ParserGenerator()
77 >>> parser = pg.generate_parser(TargetType, source_content=SourceType)
79 In this example, if the user was to provide `renamed_to` with `sources` or `into_dir` the parser would report
80 an error. However, the parser will allow `renamed_to` with `source` as the conflict is considered only for
81 the input source. That is, it is irrelevant that `sources` and `source´ happens to "map" to the same target
82 attribute.
84 The following rules apply:
85 * It is not possible for a target attribute to declare conflicts unless the target type spec is reused as
86 source type spec.
87 * All attributes involved in a conflict must be NotRequired. If any of the attributes are Required, then
88 the parser generator will reject the input.
89 * All attributes listed in the conflict must be valid attributes in the source type spec.
91 Note you do not have to specify conflicts between two attributes with the same target attribute name. The
92 `target_attribute` annotation will handle that for you.
94 :param conflicting_source_attributes: All source attributes that cannot be used with this attribute.
95 :return: The annotation.
96 """
97 if len(conflicting_source_attributes) < 1: 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true
98 raise ValueError(
99 "DebputyParseHint.conflicts_with_source_attributes requires at least one attribute as input"
100 )
101 return ConflictWithSourceAttribute(frozenset(conflicting_source_attributes))
103 @classmethod
104 def required_when_single_binary(
105 cls,
106 *,
107 package_types: PackageTypeSelector = PackageTypeSelector.ALL,
108 ) -> "DebputyParseHint":
109 """Declare a source attribute as required when the source package produces exactly one binary package
111 The attribute in question must always be declared as `NotRequired` in the TypedDict and this condition
112 can only be used for source attributes.
113 """
114 reason = "The field is required for source packages producing exactly one binary package"
115 if package_types != PackageTypeSelector.ALL:
116 reason += f" of type {package_types}"
117 return ConditionalRequired(
118 reason,
119 lambda c: len(
120 [
121 p
122 for p in c.binary_packages.values()
123 if p.package_type in package_types
124 ]
125 )
126 == 1,
127 )
128 return ConditionalRequired(
129 reason,
130 lambda c: c.is_single_binary_package,
131 )
133 @classmethod
134 def required_when_multi_binary(
135 cls,
136 *,
137 package_types: PackageTypeSelector = PackageTypeSelector.ALL,
138 ) -> "DebputyParseHint":
139 """Declare a source attribute as required when the source package produces two or more binary package
141 The attribute in question must always be declared as `NotRequired` in the TypedDict and this condition
142 can only be used for source attributes.
143 """
144 reason = "The field is required for source packages producing two or more binary packages"
145 if package_types != PackageTypeSelector.ALL:
146 reason = (
147 "The field is required for source packages producing not producing exactly one binary packages"
148 f" of type {package_types}"
149 )
150 return ConditionalRequired(
151 reason,
152 lambda c: len(
153 [
154 p
155 for p in c.binary_packages.values()
156 if p.package_type in package_types
157 ]
158 )
159 != 1,
160 )
161 return ConditionalRequired(
162 reason,
163 lambda c: not c.is_single_binary_package,
164 )
166 @classmethod
167 def manifest_attribute(cls, attribute: str) -> "DebputyParseHint":
168 """Declare what the attribute name (as written in the manifest) should be
170 By default, debputy will do an attribute normalizing that will take valid python identifiers such
171 as `dest_dir` and remap it to the manifest variant (such as `dest-dir`) automatically. If you have
172 a special case, where this built-in normalization is insufficient or the python name is considerably
173 different from what the user would write in the manifest, you can use this parse hint to set the
174 name that the user would have to write in the manifest for this attribute.
176 >>> from typing import Annotated, NotRequired, TypedDict
177 >>> from debputy.manifest_parser.base_types import FileSystemMatchRule, FileSystemExactMatchRule
178 >>> class SourceType(TypedDict):
179 ... source: list[FileSystemMatchRule]
180 ... # Use "as" in the manifest because "as_" was not pretty enough
181 ... install_as: Annotated[NotRequired[FileSystemExactMatchRule], DebputyParseHint.manifest_attribute("as")]
183 In this example, we use the parse hint to use "as" as the name in the manifest, because we cannot
184 use "as" a valid python identifier (it is a keyword). While debputy would map `as_` to `as` for us,
185 we have chosen to use `install_as` as a python identifier.
186 """
187 return ManifestAttribute(attribute)
189 @classmethod
190 def not_path_error_hint(cls) -> "DebputyParseHint":
191 """Mark this attribute as not a "path hint" when it comes to reporting errors
193 By default, `debputy` will pick up attributes that uses path names (FileSystemMatchRule) as
194 candidates for parse error hints (the little "<Search for: VALUE>" in error messages).
196 Most rules only have one active path-based attribute and paths tends to be unique enough
197 that it helps people spot the issue faster. However, in rare cases, you can have multiple
198 attributes that fit the bill. In this case, this hint can be used to "hide" the suboptimal
199 choice. As an example:
201 >>> from typing import Annotated, NotRequired, TypedDict
202 >>> from debputy.manifest_parser.base_types import FileSystemMatchRule, FileSystemExactMatchRule
203 >>> class SourceType(TypedDict):
204 ... source: list[FileSystemMatchRule]
205 ... install_as: Annotated[NotRequired[FileSystemExactMatchRule], DebputyParseHint.not_path_error_hint()]
207 In this case, without the hint, `debputy` might pick up `install_as` as the attribute to
208 use as hint for error reporting. However, here we have decided that we never want `install_as`
209 leaving `source` as the only option.
211 Generally, this type hint must be placed on the **source** format. Any source attribute matching
212 the parsed format will be ignored.
214 Mind the asymmetry: The annotation is placed in the **source** format while `debputy` looks at
215 the type of the target attribute to determine if it counts as path.
216 """
217 return NOT_PATH_HINT
220@dataclasses.dataclass(frozen=True, slots=True)
221class TargetAttribute(DebputyParseHint):
222 attribute: str
225@dataclasses.dataclass(frozen=True, slots=True)
226class ConflictWithSourceAttribute(DebputyParseHint):
227 conflicting_attributes: frozenset[str]
230@dataclasses.dataclass(frozen=True, slots=True)
231class ConditionalRequired(DebputyParseHint):
232 reason: str
233 condition: Callable[["ParserContextData"], bool]
235 def condition_applies(self, context: "ParserContextData") -> bool:
236 return self.condition(context)
239@dataclasses.dataclass(frozen=True, slots=True)
240class ManifestAttribute(DebputyParseHint):
241 attribute: str
244class NotPathHint(DebputyParseHint):
245 pass
248NOT_PATH_HINT = NotPathHint()