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

1import dataclasses 

2from typing import TYPE_CHECKING 

3from collections.abc import Callable 

4 

5from debputy.util import PackageTypeSelector 

6 

7if TYPE_CHECKING: 

8 from debputy.manifest_parser.parser_data import ParserContextData 

9 

10 

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 

15 

16 As an example: 

17 

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) 

27 

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). 

32 

33 The following rules apply: 

34 

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. 

43 

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. 

47 

48 :param target_attribute: The attribute name in the target content 

49 :return: The annotation. 

50 """ 

51 return TargetAttribute(target_attribute) 

52 

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 

59 

60 Example: 

61 

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) 

78 

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. 

83 

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. 

90 

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. 

93 

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)) 

102 

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 

110 

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 ) 

132 

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 

140 

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 ) 

165 

166 @classmethod 

167 def manifest_attribute(cls, attribute: str) -> "DebputyParseHint": 

168 """Declare what the attribute name (as written in the manifest) should be 

169 

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. 

175 

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")] 

182 

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) 

188 

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 

192 

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). 

195 

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: 

200 

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()] 

206 

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. 

210 

211 Generally, this type hint must be placed on the **source** format. Any source attribute matching 

212 the parsed format will be ignored. 

213 

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 

218 

219 

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

221class TargetAttribute(DebputyParseHint): 

222 attribute: str 

223 

224 

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

226class ConflictWithSourceAttribute(DebputyParseHint): 

227 conflicting_attributes: frozenset[str] 

228 

229 

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

231class ConditionalRequired(DebputyParseHint): 

232 reason: str 

233 condition: Callable[["ParserContextData"], bool] 

234 

235 def condition_applies(self, context: "ParserContextData") -> bool: 

236 return self.condition(context) 

237 

238 

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

240class ManifestAttribute(DebputyParseHint): 

241 attribute: str 

242 

243 

244class NotPathHint(DebputyParseHint): 

245 pass 

246 

247 

248NOT_PATH_HINT = NotPathHint()