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

241 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-07-18 20:51 +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 Union, 

13) 

14 

15from debputy import DEBPUTY_DOC_ROOT_DIR 

16from debputy.commands.debputy_cmd.output import OutputStyle 

17from debputy.manifest_parser.declarative_parser import ( 

18 DeclarativeMappingInputParser, 

19 DeclarativeNonMappingInputParser, 

20 AttributeDescription, 

21 BASIC_SIMPLE_TYPES, 

22) 

23from debputy.manifest_parser.parser_data import ParserContextData 

24from debputy.manifest_parser.tagging_types import TypeMapping 

25from debputy.manifest_parser.util import AttributePath, unpack_type 

26from debputy.plugin.api.impl_types import ( 

27 DebputyPluginMetadata, 

28 DeclarativeInputParser, 

29 DispatchingObjectParser, 

30 ListWrappedDeclarativeInputParser, 

31 InPackageContextParser, 

32 PluginProvidedTypeMapping, 

33) 

34from debputy.plugin.api.spec import ( 

35 ParserDocumentation, 

36 reference_documentation, 

37 undocumented_attr, 

38 DebputyIntegrationMode, 

39 ALL_DEBPUTY_INTEGRATION_MODES, 

40 TypeMappingExample, 

41) 

42from debputy.util import assume_not_none, _error, _warn 

43 

44 

45def _provide_placeholder_parser_doc( 

46 parser_doc: Optional[ParserDocumentation], 

47 attributes: Iterable[str], 

48) -> ParserDocumentation: 

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

50 parser_doc = reference_documentation() 

51 changes = {} 

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

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

54 

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

56 return parser_doc.replace(**changes) 

57 return parser_doc 

58 

59 

60def doc_args_for_parser_doc( 

61 rule_name: str, 

62 declarative_parser: DeclarativeInputParser[Any], 

63 plugin_metadata: DebputyPluginMetadata, 

64 *, 

65 manifest_format_url: Optional[str] = None, 

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

67 attributes: Iterable[str] 

68 if isinstance(declarative_parser, DeclarativeMappingInputParser): 

69 attributes = declarative_parser.source_attributes.keys() 

70 else: 

71 attributes = [] 

72 if manifest_format_url is None: 72 ↛ 74line 72 didn't jump to line 74 because the condition on line 72 was always true

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

74 doc_args = { 

75 "RULE_NAME": rule_name, 

76 "MANIFEST_FORMAT_DOC": manifest_format_url, 

77 "PLUGIN_NAME": plugin_metadata.plugin_name, 

78 } 

79 parser_doc = _provide_placeholder_parser_doc( 

80 declarative_parser.inline_reference_documentation, 

81 attributes, 

82 ) 

83 return doc_args, parser_doc 

84 

85 

86def render_attribute_doc( 

87 parser: Any, 

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

89 required_attributes: FrozenSet[str], 

90 conditionally_required_attributes: FrozenSet[FrozenSet[str]], 

91 parser_doc: ParserDocumentation, 

92 doc_args: Mapping[str, str], 

93 *, 

94 rule_name: str = "<unset>", 

95 is_root_rule: bool = False, 

96 is_interactive: bool = False, 

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

98 provided_attribute_docs = ( 

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

100 ) 

101 

102 for attr_doc in assume_not_none(provided_attribute_docs): 

103 attr_description = attr_doc.description 

104 rendered_doc = [] 

105 

106 for parameter in sorted(attr_doc.attributes): 

107 parameter_details = attributes.get(parameter) 

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

109 source_name = parameter_details.source_attribute_name 

110 describe_type = parameter_details.type_validator.describe_type() 

111 else: 

112 assert isinstance(parser, DispatchingObjectParser) 

113 source_name = parameter 

114 subparser = parser.parser_for(source_name).parser 

115 if isinstance(subparser, InPackageContextParser): 

116 if is_interactive: 

117 describe_type = "PackageContext" 

118 else: 

119 rule_prefix = rule_name if not is_root_rule else "" 

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

121 

122 elif isinstance(subparser, DispatchingObjectParser): 

123 if is_interactive: 

124 describe_type = "Object" 

125 else: 

126 rule_prefix = rule_name if not is_root_rule else "" 

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

128 elif isinstance(subparser, DeclarativeMappingInputParser): 

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

130 elif isinstance(subparser, DeclarativeNonMappingInputParser): 

131 describe_type = ( 

132 subparser.alt_form_parser.type_validator.describe_type() 

133 ) 

134 else: 

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

136 

137 if source_name in required_attributes: 

138 req_str = "required" 

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

140 req_str = "conditional" 

141 else: 

142 req_str = "optional" 

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

144 

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

146 rendered_doc.append("") 

147 attr_doc_rendered = _render_template( 

148 f"attr docs for {rule_name}", 

149 attr_description, 

150 doc_args, 

151 ) 

152 rendered_doc.extend( 

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

154 ) 

155 rendered_doc.append("") 

156 yield attr_doc.attributes, rendered_doc 

157 

158 

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

160 try: 

161 return Template(template_str).substitute(params) 

162 except KeyError as e: 

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

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

165 except ValueError as e: 

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

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

168 

169 

170def _render_integration_mode( 

171 expected_modes: Optional[Container[DebputyIntegrationMode]], 

172) -> Optional[str]: 

173 if expected_modes: 

174 allowed_modes = set() 

175 for mode in sorted(ALL_DEBPUTY_INTEGRATION_MODES): 

176 if mode in expected_modes: 

177 allowed_modes.add(mode) 

178 

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

180 restriction = "any integration mode" 

181 else: 

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

183 else: 

184 restriction = "any integration mode" 

185 

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

187 

188 

189def render_rule( 

190 rule_name: str, 

191 declarative_parser: DeclarativeInputParser[Any], 

192 plugin_metadata: DebputyPluginMetadata, 

193 color_base: OutputStyle, 

194 *, 

195 is_root_rule: bool = False, 

196 include_ref_doc_link: bool = True, 

197 include_alt_format: bool = True, 

198 base_heading_level: int = 1, 

199 manifest_format_url: Optional[str] = None, 

200 show_integration_mode: bool = True, 

201) -> str: 

202 doc_args, parser_doc = doc_args_for_parser_doc( 

203 "the manifest root" if is_root_rule else rule_name, 

204 declarative_parser, 

205 plugin_metadata, 

206 manifest_format_url=manifest_format_url, 

207 ) 

208 t = _render_template( 

209 f"title of {rule_name}", 

210 assume_not_none(parser_doc.title), 

211 doc_args, 

212 ) 

213 body = _render_template( 

214 f"body of {rule_name}", 

215 assume_not_none(parser_doc.description), 

216 doc_args, 

217 ).rstrip() 

218 r = [ 

219 color_base.heading(t, base_heading_level), 

220 "", 

221 body, 

222 "", 

223 ] 

224 

225 if show_integration_mode: 225 ↛ 230line 225 didn't jump to line 230 because the condition on line 225 was always true

226 allowed_integration_modes = _render_integration_mode( 

227 declarative_parser.expected_debputy_integration_mode 

228 ) 

229 else: 

230 allowed_integration_modes = None 

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

232 is_list_wrapped = False 

233 unwrapped_parser = declarative_parser 

234 if isinstance(declarative_parser, ListWrappedDeclarativeInputParser): 

235 is_list_wrapped = True 

236 unwrapped_parser = declarative_parser.delegate 

237 

238 if isinstance( 

239 unwrapped_parser, (DeclarativeMappingInputParser, DispatchingObjectParser) 

240 ): 

241 

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

243 attributes = unwrapped_parser.source_attributes 

244 required = unwrapped_parser.input_time_required_parameters 

245 conditionally_required = unwrapped_parser.at_least_one_of 

246 mutually_exclusive = unwrapped_parser.mutually_exclusive_attributes 

247 else: 

248 attributes = {} 

249 required = frozenset() 

250 conditionally_required = frozenset() 

251 mutually_exclusive = frozenset() 

252 if is_list_wrapped: 

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

254 else: 

255 r.append("Attributes:") 

256 

257 rendered_attr_doc = render_attribute_doc( 

258 unwrapped_parser, 

259 attributes, 

260 required, 

261 conditionally_required, 

262 parser_doc, 

263 doc_args, 

264 is_root_rule=is_root_rule, 

265 rule_name=rule_name, 

266 is_interactive=False, 

267 ) 

268 for _, rendered_doc in rendered_attr_doc: 

269 prefix = " - " 

270 for line in rendered_doc: 

271 if line: 

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

273 else: 

274 r.append("") 

275 prefix = " " 

276 

277 if ( 277 ↛ 331line 277 didn't jump to line 331 because the condition on line 277 was always true

278 bool(conditionally_required) 

279 or bool(mutually_exclusive) 

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

281 ): 

282 r.append("") 

283 if is_list_wrapped: 

284 r.append( 

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

286 ) 

287 else: 

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

289 

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

291 all_groups = list( 

292 itertools.chain(conditionally_required, mutually_exclusive) 

293 ) 

294 seen = set() 

295 for g in all_groups: 

296 if g in seen: 

297 continue 

298 seen.add(g) 

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

300 is_mx = g in mutually_exclusive 

301 is_cr = g in conditionally_required 

302 if is_mx and is_cr: 

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

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

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

306 else: 

307 assert is_mx 

308 r.append( 

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

310 ) 

311 

312 if mutually_exclusive or any( 312 ↛ 331line 312 didn't jump to line 331 because the condition on line 312 was always true

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

314 ): 

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

316 source_name = parameter_details.source_attribute_name 

317 conflicts = set(parameter_details.conflicting_attributes) 

318 for mx in mutually_exclusive: 

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

320 conflicts |= mx 

321 if conflicts: 

322 conflicts.discard(parameter) 

323 cnames = "`, `".join( 

324 sorted( 

325 attributes[a].source_attribute_name for a in conflicts 

326 ) 

327 ) 

328 r.append( 

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

330 ) 

331 r.append("") 

332 if include_alt_format and alt_form_parser is not None: 

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

334 r.append( 

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

336 ) 

337 alt_parser_desc = parser_doc.alt_parser_description 

338 if alt_parser_desc: 

339 r.extend( 

340 f" {line}" 

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

342 keepends=False 

343 ) 

344 ) 

345 r.append("") 

346 

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

348 r.append(allowed_integration_modes) 

349 

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

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

352 r.append( 

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

354 ) 

355 else: 

356 r.append( 

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

358 ) 

359 elif allowed_integration_modes: 

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

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

362 r.append("") 

363 

364 return "\n".join(r) 

365 

366 

367def render_multiline_documentation( 

368 documentation: str, 

369 *, 

370 first_line_prefix: str = "Documentation: ", 

371 following_line_prefix: str = " ", 

372) -> Iterable[str]: 

373 current_prefix = first_line_prefix 

374 result = [] 

375 for line in documentation.splitlines(keepends=False): 

376 if line.isspace(): 

377 if not current_prefix.isspace(): 

378 result.append(current_prefix.rstrip()) 

379 current_prefix = following_line_prefix 

380 else: 

381 result.append("") 

382 continue 

383 result.append(f"{current_prefix}{line}") 

384 current_prefix = following_line_prefix 

385 return result 

386 

387 

388def _render_type_example( 

389 type_mapping: TypeMapping[Any, Any], 

390 output_style: OutputStyle, 

391 parser_context: ParserContextData, 

392 example: TypeMappingExample, 

393 *, 

394 recover_from_broken_examples: bool, 

395) -> Tuple[str, bool]: 

396 attr_path = AttributePath.builtin_path()["Render Request"] 

397 v = _render_value(example.source_input) 

398 try: 

399 type_mapping.mapper( 

400 example.source_input, 

401 attr_path, 

402 parser_context, 

403 ) 

404 except RuntimeError: 

405 if not recover_from_broken_examples: 

406 raise 

407 return ( 

408 output_style.colored(v, fg="red") + " [Example value could not be parsed]", 

409 True, 

410 ) 

411 return output_style.colored(v, fg="green"), False 

412 

413 

414def render_source_type(t: Any) -> str: 

415 _, origin_type, args = unpack_type(t, False) 

416 if origin_type == Union: 

417 return " | ".join(render_source_type(st) for st in args) 

418 name = BASIC_SIMPLE_TYPES.get(t) 

419 if name is not None: 

420 return name 

421 try: 

422 return t.__name__ 

423 except AttributeError: 

424 return str(t) 

425 

426 

427def render_type_mapping( 

428 pptm: PluginProvidedTypeMapping, 

429 output_style: OutputStyle, 

430 parser_context: ParserContextData, 

431 *, 

432 recover_from_broken_examples: bool = False, 

433 base_heading_level: int = 1, 

434) -> str: 

435 type_mapping = pptm.mapped_type 

436 target_type = type_mapping.target_type 

437 ref_doc = pptm.reference_documentation 

438 desc = ref_doc.description if ref_doc is not None else None 

439 examples = ref_doc.examples if ref_doc is not None else tuple() 

440 base_type = render_source_type(type_mapping.source_type) 

441 lines = [ 

442 output_style.heading( 

443 f"Type Mapping: {target_type.__name__} [{base_type}]", base_heading_level 

444 ), 

445 "", 

446 ] 

447 

448 if desc is not None: 

449 lines.extend( 

450 render_multiline_documentation( 

451 desc, 

452 first_line_prefix="", 

453 following_line_prefix="", 

454 ) 

455 ) 

456 else: 

457 lines.append("No documentation provided.") 

458 

459 if examples: 

460 had_issues = False 

461 lines.append("") 

462 lines.append(output_style.heading("Example values", base_heading_level + 1)) 

463 lines.append("") 

464 for no, example in enumerate(examples, start=1): 

465 v, i = _render_type_example( 

466 type_mapping, 

467 output_style, 

468 parser_context, 

469 example, 

470 recover_from_broken_examples=recover_from_broken_examples, 

471 ) 

472 if i and recover_from_broken_examples: 

473 lines.append( 

474 output_style.colored("Broken example: ", fg="red") 

475 + f"Provided example input ({v})" 

476 + " caused an exception when parsed. Please file a bug against the plugin." 

477 + " Use --debug/DEBPUTY_DEBUG=1 to see the stack trace" 

478 ) 

479 lines.append(f" * {v}") 

480 if i: 

481 had_issues = True 

482 else: 

483 had_issues = False 

484 

485 if had_issues: 

486 lines.append("") 

487 lines.append( 

488 output_style.colored( 

489 "Examples had issues. Please file a bug against the plugin", fg="red" 

490 ) 

491 ) 

492 lines.append("") 

493 lines.append("Use --debug/DEBPUTY_DEBUG=1 to see the stacktrace") 

494 

495 return "\n".join(lines) 

496 

497 

498def _render_value(v: Any) -> str: 

499 if isinstance(v, str) and '"' not in v: 

500 return f'"{v}"' 

501 return str(v)