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