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

1import dataclasses 

2from typing import ( 

3 NotRequired, 

4 TypedDict, 

5 TYPE_CHECKING, 

6 Callable, 

7 FrozenSet, 

8 Annotated, 

9 List, 

10) 

11 

12from debputy.manifest_parser.util import ( 

13 resolve_package_type_selectors, 

14 _ALL_PACKAGE_TYPES, 

15) 

16from debputy.plugin.api.spec import PackageTypeSelector 

17 

18if TYPE_CHECKING: 

19 from debputy.manifest_parser.parser_data import ParserContextData 

20 

21 

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 

26 

27 As an example: 

28 

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) 

37 

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

42 

43 The following rules apply: 

44 

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. 

53 

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. 

57 

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

59 :return: The annotation. 

60 """ 

61 return TargetAttribute(target_attribute) 

62 

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 

69 

70 Example: 

71 

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) 

87 

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. 

92 

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. 

99 

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. 

102 

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

111 

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 

119 

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 ) 

143 

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 

151 

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 ) 

178 

179 @classmethod 

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

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

182 

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. 

188 

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

194 

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) 

200 

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 

204 

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

207 

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: 

212 

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

217 

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. 

221 

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

223 the parsed format will be ignored. 

224 

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 

229 

230 

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

232class TargetAttribute(DebputyParseHint): 

233 attribute: str 

234 

235 

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

237class ConflictWithSourceAttribute(DebputyParseHint): 

238 conflicting_attributes: FrozenSet[str] 

239 

240 

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

242class ConditionalRequired(DebputyParseHint): 

243 reason: str 

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

245 

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

247 return self.condition(context) 

248 

249 

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

251class ManifestAttribute(DebputyParseHint): 

252 attribute: str 

253 

254 

255class NotPathHint(DebputyParseHint): 

256 pass 

257 

258 

259NOT_PATH_HINT = NotPathHint()