Coverage for src/debputy/manifest_parser/parser_doc.py: 74%

169 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2025-01-27 13:59 +0000

1import itertools 

2from string import Template 

3from typing import ( 

4 Optional, 

5 Iterable, 

6 Any, 

7 Tuple, 

8 Mapping, 

9 Sequence, 

10 FrozenSet, 

11 Container, 

12) 

13 

14from debputy import DEBPUTY_DOC_ROOT_DIR 

15from debputy.manifest_parser.declarative_parser import ( 

16 DeclarativeMappingInputParser, 

17 DeclarativeNonMappingInputParser, 

18 AttributeDescription, 

19) 

20from debputy.plugin.api.impl_types import ( 

21 DebputyPluginMetadata, 

22 DeclarativeInputParser, 

23 DispatchingObjectParser, 

24 ListWrappedDeclarativeInputParser, 

25 InPackageContextParser, 

26) 

27from debputy.plugin.api.spec import ( 

28 ParserDocumentation, 

29 reference_documentation, 

30 undocumented_attr, 

31 DebputyIntegrationMode, 

32 ALL_DEBPUTY_INTEGRATION_MODES, 

33) 

34from debputy.util import assume_not_none, _error, _warn 

35 

36 

37def _provide_placeholder_parser_doc( 

38 parser_doc: Optional[ParserDocumentation], 

39 attributes: Iterable[str], 

40) -> ParserDocumentation: 

41 if parser_doc is None: 41 ↛ 42line 41 didn't jump to line 42 because the condition on line 41 was never true

42 parser_doc = reference_documentation() 

43 changes = {} 

44 if parser_doc.attribute_doc is None: 44 ↛ 45line 44 didn't jump to line 45 because the condition on line 44 was never true

45 changes["attribute_doc"] = [undocumented_attr(attr) for attr in attributes] 

46 

47 if changes: 47 ↛ 48line 47 didn't jump to line 48 because the condition on line 47 was never true

48 return parser_doc.replace(**changes) 

49 return parser_doc 

50 

51 

52def doc_args_for_parser_doc( 

53 rule_name: str, 

54 declarative_parser: DeclarativeInputParser[Any], 

55 plugin_metadata: DebputyPluginMetadata, 

56 *, 

57 manifest_format_url: Optional[str] = None, 

58) -> Tuple[Mapping[str, str], ParserDocumentation]: 

59 attributes: Iterable[str] 

60 if isinstance(declarative_parser, DeclarativeMappingInputParser): 

61 attributes = declarative_parser.source_attributes.keys() 

62 else: 

63 attributes = [] 

64 if manifest_format_url is None: 64 ↛ 66line 64 didn't jump to line 66

65 manifest_format_url = f"{DEBPUTY_DOC_ROOT_DIR}/MANIFEST-FORMAT.md" 

66 doc_args = { 

67 "RULE_NAME": rule_name, 

68 "MANIFEST_FORMAT_DOC": manifest_format_url, 

69 "PLUGIN_NAME": plugin_metadata.plugin_name, 

70 } 

71 parser_doc = _provide_placeholder_parser_doc( 

72 declarative_parser.inline_reference_documentation, 

73 attributes, 

74 ) 

75 return doc_args, parser_doc 

76 

77 

78def render_attribute_doc( 

79 parser: Any, 

80 attributes: Mapping[str, "AttributeDescription"], 

81 required_attributes: FrozenSet[str], 

82 conditionally_required_attributes: FrozenSet[FrozenSet[str]], 

83 parser_doc: ParserDocumentation, 

84 doc_args: Mapping[str, str], 

85 *, 

86 rule_name: str = "<unset>", 

87 is_root_rule: bool = False, 

88 is_interactive: bool = False, 

89) -> Iterable[Tuple[FrozenSet[str], Sequence[str]]]: 

90 provided_attribute_docs = ( 

91 parser_doc.attribute_doc if parser_doc.attribute_doc is not None else [] 

92 ) 

93 

94 for attr_doc in assume_not_none(provided_attribute_docs): 

95 attr_description = attr_doc.description 

96 rendered_doc = [] 

97 

98 for parameter in sorted(attr_doc.attributes): 

99 parameter_details = attributes.get(parameter) 

100 if parameter_details is not None: 100 ↛ 104line 100 didn't jump to line 104 because the condition on line 100 was always true

101 source_name = parameter_details.source_attribute_name 

102 describe_type = parameter_details.type_validator.describe_type() 

103 else: 

104 assert isinstance(parser, DispatchingObjectParser) 

105 source_name = parameter 

106 subparser = parser.parser_for(source_name).parser 

107 if isinstance(subparser, InPackageContextParser): 

108 if is_interactive: 

109 describe_type = "PackageContext" 

110 else: 

111 rule_prefix = rule_name if not is_root_rule else "" 

112 describe_type = f"PackageContext (chains to `{rule_prefix}::{subparser.manifest_attribute_path_template}`)" 

113 

114 elif isinstance(subparser, DispatchingObjectParser): 

115 if is_interactive: 

116 describe_type = "Object" 

117 else: 

118 rule_prefix = rule_name if not is_root_rule else "" 

119 describe_type = f"Object (see `{rule_prefix}::{subparser.manifest_attribute_path_template}`)" 

120 elif isinstance(subparser, DeclarativeMappingInputParser): 

121 describe_type = "<Type definition not implemented yet>" # TODO: Derive from subparser 

122 elif isinstance(subparser, DeclarativeNonMappingInputParser): 

123 describe_type = ( 

124 subparser.alt_form_parser.type_validator.describe_type() 

125 ) 

126 else: 

127 describe_type = f"<Unknown: Non-introspectable subparser - {subparser.__class__.__name__}>" 

128 

129 if source_name in required_attributes: 

130 req_str = "required" 

131 elif any(source_name in s for s in conditionally_required_attributes): 

132 req_str = "conditional" 

133 else: 

134 req_str = "optional" 

135 rendered_doc.append(f"`{source_name}` ({req_str}): {describe_type}") 

136 

137 if attr_description: 137 ↛ 148line 137 didn't jump to line 148 because the condition on line 137 was always true

138 rendered_doc.append("") 

139 attr_doc_rendered = _render_template( 

140 f"attr docs for {rule_name}", 

141 attr_description, 

142 doc_args, 

143 ) 

144 rendered_doc.extend( 

145 line for line in attr_doc_rendered.splitlines(keepends=False) 

146 ) 

147 rendered_doc.append("") 

148 yield attr_doc.attributes, rendered_doc 

149 

150 

151def _render_template(name: str, template_str: str, params: Mapping[str, str]) -> str: 

152 try: 

153 return Template(template_str).substitute(params) 

154 except KeyError as e: 

155 _warn(f"Render issue: {str(e)}") 

156 _error(f"Failed to render {name}: Missing key {e.args[0]}") 

157 except ValueError as e: 

158 _warn(f"Render issue: {str(e)}") 

159 _error(f"Failed to render {name}") 

160 

161 

162def _render_integration_mode( 

163 expected_modes: Optional[Container[DebputyIntegrationMode]], 

164) -> Optional[str]: 

165 if expected_modes: 

166 allowed_modes = set() 

167 for mode in sorted(ALL_DEBPUTY_INTEGRATION_MODES): 

168 if mode in expected_modes: 

169 allowed_modes.add(mode) 

170 

171 if allowed_modes == ALL_DEBPUTY_INTEGRATION_MODES: 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true

172 restriction = "any integration mode" 

173 else: 

174 restriction = ", ".join(sorted(allowed_modes)) 

175 else: 

176 restriction = "any integration mode" 

177 

178 return f"Integration mode availability: {restriction}" 

179 

180 

181def render_rule( 

182 rule_name: str, 

183 declarative_parser: DeclarativeInputParser[Any], 

184 plugin_metadata: DebputyPluginMetadata, 

185 *, 

186 is_root_rule: bool = False, 

187 include_ref_doc_link: bool = True, 

188 include_alt_format: bool = True, 

189 heading: str = "=", 

190 manifest_format_url: Optional[str] = None, 

191) -> str: 

192 doc_args, parser_doc = doc_args_for_parser_doc( 

193 "the manifest root" if is_root_rule else rule_name, 

194 declarative_parser, 

195 plugin_metadata, 

196 manifest_format_url=manifest_format_url, 

197 ) 

198 t = _render_template( 

199 f"title of {rule_name}", 

200 assume_not_none(parser_doc.title), 

201 doc_args, 

202 ) 

203 body = _render_template( 

204 f"body of {rule_name}", 

205 assume_not_none(parser_doc.description), 

206 doc_args, 

207 ).rstrip() 

208 if heading.startswith("#"): 208 ↛ 209line 208 didn't jump to line 209

209 r = [ 

210 f"{heading} {t}", 

211 "", 

212 body, 

213 "", 

214 ] 

215 else: 

216 r = [ 

217 t, 

218 heading * len(t), 

219 "", 

220 body, 

221 "", 

222 ] 

223 

224 allowed_integration_modes = _render_integration_mode( 

225 declarative_parser.expected_debputy_integration_mode 

226 ) 

227 alt_form_parser = getattr(declarative_parser, "alt_form_parser", None) 

228 is_list_wrapped = False 

229 unwrapped_parser = declarative_parser 

230 if isinstance(declarative_parser, ListWrappedDeclarativeInputParser): 

231 is_list_wrapped = True 

232 unwrapped_parser = declarative_parser.delegate 

233 

234 if isinstance( 

235 unwrapped_parser, (DeclarativeMappingInputParser, DispatchingObjectParser) 

236 ): 

237 

238 if isinstance(unwrapped_parser, DeclarativeMappingInputParser): 238 ↛ 244line 238 didn't jump to line 244 because the condition on line 238 was always true

239 attributes = unwrapped_parser.source_attributes 

240 required = unwrapped_parser.input_time_required_parameters 

241 conditionally_required = unwrapped_parser.at_least_one_of 

242 mutually_exclusive = unwrapped_parser.mutually_exclusive_attributes 

243 else: 

244 attributes = {} 

245 required = frozenset() 

246 conditionally_required = frozenset() 

247 mutually_exclusive = frozenset() 

248 if is_list_wrapped: 

249 r.append("List where each element has the following attributes:") 

250 else: 

251 r.append("Attributes:") 

252 

253 rendered_attr_doc = render_attribute_doc( 

254 unwrapped_parser, 

255 attributes, 

256 required, 

257 conditionally_required, 

258 parser_doc, 

259 doc_args, 

260 is_root_rule=is_root_rule, 

261 rule_name=rule_name, 

262 is_interactive=False, 

263 ) 

264 for _, rendered_doc in rendered_attr_doc: 

265 prefix = " - " 

266 for line in rendered_doc: 

267 if line: 

268 r.append(f"{prefix}{line}") 

269 else: 

270 r.append("") 

271 prefix = " " 

272 

273 if ( 

274 bool(conditionally_required) 

275 or bool(mutually_exclusive) 

276 or any(pd.conflicting_attributes for pd in attributes.values()) 

277 ): 

278 r.append("") 

279 if is_list_wrapped: 

280 r.append( 

281 "This rule enforces the following restrictions on each element in the list:" 

282 ) 

283 else: 

284 r.append("This rule enforces the following restrictions:") 

285 

286 if conditionally_required or mutually_exclusive: 286 ↛ 308line 286 didn't jump to line 308 because the condition on line 286 was always true

287 all_groups = list( 

288 itertools.chain(conditionally_required, mutually_exclusive) 

289 ) 

290 seen = set() 

291 for g in all_groups: 

292 if g in seen: 

293 continue 

294 seen.add(g) 

295 anames = "`, `".join(sorted(g)) 

296 is_mx = g in mutually_exclusive 

297 is_cr = g in conditionally_required 

298 if is_mx and is_cr: 

299 r.append(f" - The rule must use exactly one of: `{anames}`") 

300 elif is_cr: 300 ↛ 301line 300 didn't jump to line 301 because the condition on line 300 was never true

301 r.append(f" - The rule must use at least one of: `{anames}`") 

302 else: 

303 assert is_mx 

304 r.append( 

305 f" - The following attributes are mutually exclusive: `{anames}`" 

306 ) 

307 

308 if mutually_exclusive or any( 308 ↛ exit,   308 ↛ 3272 missed branches: 1) line 308 didn't run the generator expression on line 308, 2) line 308 didn't jump to line 327 because the condition on line 308 was always true

309 pd.conflicting_attributes for pd in attributes.values() 

310 ): 

311 for parameter, parameter_details in sorted(attributes.items()): 

312 source_name = parameter_details.source_attribute_name 

313 conflicts = set(parameter_details.conflicting_attributes) 

314 for mx in mutually_exclusive: 

315 if parameter in mx and mx not in conditionally_required: 315 ↛ 316line 315 didn't jump to line 316 because the condition on line 315 was never true

316 conflicts |= mx 

317 if conflicts: 

318 conflicts.discard(parameter) 

319 cnames = "`, `".join( 

320 sorted( 

321 attributes[a].source_attribute_name for a in conflicts 

322 ) 

323 ) 

324 r.append( 

325 f" - The attribute `{source_name}` cannot be used with any of: `{cnames}`" 

326 ) 

327 r.append("") 

328 if include_alt_format and alt_form_parser is not None: 

329 # FIXME: Mapping[str, Any] ends here, which is ironic given the headline. 

330 r.append( 

331 f"Non-mapping format: {alt_form_parser.type_validator.describe_type()}" 

332 ) 

333 alt_parser_desc = parser_doc.alt_parser_description 

334 if alt_parser_desc: 

335 r.extend( 

336 f" {line}" 

337 for line in alt_parser_desc.format(**doc_args).splitlines( 

338 keepends=False 

339 ) 

340 ) 

341 r.append("") 

342 

343 if allowed_integration_modes: 343 ↛ 346line 343 didn't jump to line 346 because the condition on line 343 was always true

344 r.append(allowed_integration_modes) 

345 

346 if include_ref_doc_link: 346 ↛ 355line 346 didn't jump to line 355 because the condition on line 346 was always true

347 if declarative_parser.reference_documentation_url is not None: 347 ↛ 352line 347 didn't jump to line 352 because the condition on line 347 was always true

348 r.append( 

349 f"Reference documentation: {declarative_parser.reference_documentation_url}" 

350 ) 

351 else: 

352 r.append( 

353 "Reference documentation: No reference documentation link provided by the plugin" 

354 ) 

355 elif allowed_integration_modes: 

356 # Better spacing in the generated docs, but it looks weird with this newline 

357 # in `debputy plugin show p-m-r ...` 

358 r.append("") 

359 

360 return "\n".join(r)