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

242 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-10-12 15:06 +0000

1import itertools 

2from string import Template 

3from typing import ( 

4 Optional, 

5 Any, 

6 Tuple, 

7 FrozenSet, 

8 Union, 

9) 

10from collections.abc import Iterable, Mapping, Sequence, Container 

11 

12from debputy import DEBPUTY_DOC_ROOT_DIR 

13from debputy.commands.debputy_cmd.output import OutputStyle 

14from debputy.manifest_parser.declarative_parser import ( 

15 DeclarativeMappingInputParser, 

16 DeclarativeNonMappingInputParser, 

17 AttributeDescription, 

18 BASIC_SIMPLE_TYPES, 

19) 

20from debputy.manifest_parser.parser_data import ParserContextData 

21from debputy.manifest_parser.tagging_types import TypeMapping 

22from debputy.manifest_parser.util import AttributePath, unpack_type 

23from debputy.plugin.api.impl_types import ( 

24 DebputyPluginMetadata, 

25 DeclarativeInputParser, 

26 DispatchingObjectParser, 

27 ListWrappedDeclarativeInputParser, 

28 InPackageContextParser, 

29 PluginProvidedTypeMapping, 

30) 

31from debputy.plugin.api.spec import ( 

32 ParserDocumentation, 

33 reference_documentation, 

34 undocumented_attr, 

35 DebputyIntegrationMode, 

36 ALL_DEBPUTY_INTEGRATION_MODES, 

37 TypeMappingExample, 

38) 

39from debputy.util import assume_not_none, _error, _warn 

40 

41 

42def _provide_placeholder_parser_doc( 

43 parser_doc: ParserDocumentation | None, 

44 attributes: Iterable[str], 

45) -> ParserDocumentation: 

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

47 parser_doc = reference_documentation() 

48 changes = {} 

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

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

51 

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

53 return parser_doc.replace(**changes) 

54 return parser_doc 

55 

56 

57def doc_args_for_parser_doc( 

58 rule_name: str, 

59 declarative_parser: DeclarativeInputParser[Any], 

60 plugin_metadata: DebputyPluginMetadata, 

61 *, 

62 manifest_format_url: str | None = None, 

63) -> tuple[Mapping[str, str], ParserDocumentation]: 

64 attributes: Iterable[str] 

65 if isinstance(declarative_parser, DeclarativeMappingInputParser): 

66 attributes = declarative_parser.source_attributes.keys() 

67 else: 

68 attributes = [] 

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

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

71 doc_args = { 

72 "RULE_NAME": rule_name, 

73 "MANIFEST_FORMAT_DOC": manifest_format_url, 

74 "PLUGIN_NAME": plugin_metadata.plugin_name, 

75 } 

76 parser_doc = _provide_placeholder_parser_doc( 

77 declarative_parser.inline_reference_documentation, 

78 attributes, 

79 ) 

80 return doc_args, parser_doc 

81 

82 

83def render_attribute_doc( 

84 parser: Any, 

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

86 required_attributes: frozenset[str], 

87 conditionally_required_attributes: frozenset[frozenset[str]], 

88 parser_doc: ParserDocumentation, 

89 doc_args: Mapping[str, str], 

90 *, 

91 rule_name: str = "<unset>", 

92 is_root_rule: bool = False, 

93 is_interactive: bool = False, 

94) -> Iterable[tuple[frozenset[str], Sequence[str]]]: 

95 provided_attribute_docs = ( 

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

97 ) 

98 

99 for attr_doc in assume_not_none(provided_attribute_docs): 

100 attr_description = attr_doc.description 

101 rendered_doc = [] 

102 

103 for parameter in sorted(attr_doc.attributes): 

104 parameter_details = attributes.get(parameter) 

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

106 source_name = parameter_details.source_attribute_name 

107 describe_type = parameter_details.type_validator.describe_type() 

108 else: 

109 assert isinstance(parser, DispatchingObjectParser) 

110 source_name = parameter 

111 subparser = parser.parser_for(source_name).parser 

112 if isinstance(subparser, InPackageContextParser): 

113 if is_interactive: 

114 describe_type = "PackageContext" 

115 else: 

116 rule_prefix = rule_name if not is_root_rule else "" 

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

118 

119 elif isinstance(subparser, DispatchingObjectParser): 

120 if is_interactive: 

121 describe_type = "Object" 

122 else: 

123 rule_prefix = rule_name if not is_root_rule else "" 

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

125 elif isinstance(subparser, DeclarativeMappingInputParser): 

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

127 elif isinstance(subparser, DeclarativeNonMappingInputParser): 

128 describe_type = ( 

129 subparser.alt_form_parser.type_validator.describe_type() 

130 ) 

131 else: 

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

133 

134 if source_name in required_attributes: 

135 req_str = "required" 

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

137 req_str = "conditional" 

138 else: 

139 req_str = "optional" 

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

141 

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

143 rendered_doc.append("") 

144 attr_doc_rendered = _render_template( 

145 f"attr docs for {rule_name}", 

146 attr_description, 

147 doc_args, 

148 ) 

149 rendered_doc.extend( 

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

151 ) 

152 rendered_doc.append("") 

153 yield attr_doc.attributes, rendered_doc 

154 

155 

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

157 try: 

158 return Template(template_str).substitute(params) 

159 except KeyError as e: 

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

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

162 except ValueError as e: 

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

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

165 

166 

167def _render_integration_mode( 

168 expected_modes: Container[DebputyIntegrationMode] | None, 

169) -> str | None: 

170 if expected_modes: 

171 allowed_modes = set() 

172 for mode in sorted(ALL_DEBPUTY_INTEGRATION_MODES): 

173 if mode in expected_modes: 

174 allowed_modes.add(mode) 

175 

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

177 restriction = "any integration mode" 

178 else: 

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

180 else: 

181 restriction = "any integration mode" 

182 

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

184 

185 

186def render_rule( 

187 rule_name: str, 

188 declarative_parser: DeclarativeInputParser[Any], 

189 plugin_metadata: DebputyPluginMetadata, 

190 color_base: OutputStyle, 

191 *, 

192 is_root_rule: bool = False, 

193 include_ref_doc_link: bool = True, 

194 include_alt_format: bool = True, 

195 base_heading_level: int = 1, 

196 manifest_format_url: str | None = None, 

197 show_integration_mode: bool = True, 

198) -> str: 

199 doc_args, parser_doc = doc_args_for_parser_doc( 

200 "the manifest root" if is_root_rule else rule_name, 

201 declarative_parser, 

202 plugin_metadata, 

203 manifest_format_url=manifest_format_url, 

204 ) 

205 t = _render_template( 

206 f"title of {rule_name}", 

207 assume_not_none(parser_doc.title), 

208 doc_args, 

209 ) 

210 body = _render_template( 

211 f"body of {rule_name}", 

212 assume_not_none(parser_doc.description), 

213 doc_args, 

214 ).rstrip() 

215 r = [ 

216 color_base.heading(t, base_heading_level), 

217 "", 

218 body, 

219 "", 

220 ] 

221 

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

223 allowed_integration_modes = _render_integration_mode( 

224 declarative_parser.expected_debputy_integration_mode 

225 ) 

226 else: 

227 allowed_integration_modes = None 

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

229 is_list_wrapped = False 

230 unwrapped_parser = declarative_parser 

231 if isinstance(declarative_parser, ListWrappedDeclarativeInputParser): 

232 is_list_wrapped = True 

233 unwrapped_parser = declarative_parser.delegate 

234 

235 if isinstance( 

236 unwrapped_parser, (DeclarativeMappingInputParser, DispatchingObjectParser) 

237 ): 

238 

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

240 attributes = unwrapped_parser.source_attributes 

241 required = unwrapped_parser.input_time_required_parameters 

242 conditionally_required = unwrapped_parser.at_least_one_of 

243 mutually_exclusive = unwrapped_parser.mutually_exclusive_attributes 

244 else: 

245 attributes = {} 

246 required = frozenset() 

247 conditionally_required = frozenset() 

248 mutually_exclusive = frozenset() 

249 if is_list_wrapped: 

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

251 else: 

252 r.append("Attributes:") 

253 

254 rendered_attr_doc = render_attribute_doc( 

255 unwrapped_parser, 

256 attributes, 

257 required, 

258 conditionally_required, 

259 parser_doc, 

260 doc_args, 

261 is_root_rule=is_root_rule, 

262 rule_name=rule_name, 

263 is_interactive=False, 

264 ) 

265 for _, rendered_doc in rendered_attr_doc: 

266 prefix = " - " 

267 for line in rendered_doc: 

268 if line: 

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

270 else: 

271 r.append("") 

272 prefix = " " 

273 

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

275 bool(conditionally_required) 

276 or bool(mutually_exclusive) 

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

278 ): 

279 r.append("") 

280 if is_list_wrapped: 

281 r.append( 

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

283 ) 

284 else: 

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

286 

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

288 all_groups = list( 

289 itertools.chain(conditionally_required, mutually_exclusive) 

290 ) 

291 seen = set() 

292 for g in all_groups: 

293 if g in seen: 

294 continue 

295 seen.add(g) 

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

297 is_mx = g in mutually_exclusive 

298 is_cr = g in conditionally_required 

299 if is_mx and is_cr: 

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

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

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

303 else: 

304 assert is_mx 

305 r.append( 

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

307 ) 

308 

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

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

311 ): 

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

313 source_name = parameter_details.source_attribute_name 

314 conflicts = set(parameter_details.conflicting_attributes) 

315 for mx in mutually_exclusive: 

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

317 conflicts |= mx 

318 if conflicts: 

319 conflicts.discard(parameter) 

320 cnames = "`, `".join( 

321 sorted( 

322 attributes[a].source_attribute_name for a in conflicts 

323 ) 

324 ) 

325 r.append( 

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

327 ) 

328 r.append("") 

329 if include_alt_format and alt_form_parser is not None: 

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

331 r.append( 

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

333 ) 

334 alt_parser_desc = parser_doc.alt_parser_description 

335 if alt_parser_desc: 

336 r.extend( 

337 f" {line}" 

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

339 keepends=False 

340 ) 

341 ) 

342 r.append("") 

343 

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

345 r.append(allowed_integration_modes) 

346 

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

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

349 r.append( 

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

351 ) 

352 else: 

353 r.append( 

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

355 ) 

356 elif allowed_integration_modes: 

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

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

359 r.append("") 

360 

361 return "\n".join(r) 

362 

363 

364def render_multiline_documentation( 

365 documentation: str, 

366 *, 

367 first_line_prefix: str = "Documentation: ", 

368 following_line_prefix: str = " ", 

369) -> Iterable[str]: 

370 current_prefix = first_line_prefix 

371 result = [] 

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

373 if line.isspace(): 

374 if not current_prefix.isspace(): 

375 result.append(current_prefix.rstrip()) 

376 current_prefix = following_line_prefix 

377 else: 

378 result.append("") 

379 continue 

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

381 current_prefix = following_line_prefix 

382 return result 

383 

384 

385def _render_type_example( 

386 type_mapping: TypeMapping[Any, Any], 

387 output_style: OutputStyle, 

388 parser_context: ParserContextData, 

389 example: TypeMappingExample, 

390 *, 

391 recover_from_broken_examples: bool, 

392) -> tuple[str, bool]: 

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

394 v = _render_value(example.source_input) 

395 try: 

396 type_mapping.mapper( 

397 example.source_input, 

398 attr_path, 

399 parser_context, 

400 ) 

401 except RuntimeError: 

402 if not recover_from_broken_examples: 

403 raise 

404 return ( 

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

406 True, 

407 ) 

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

409 

410 

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

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

413 if origin_type == Union: 

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

415 name = BASIC_SIMPLE_TYPES.get(t) 

416 if name is not None: 

417 return name 

418 try: 

419 return t.__name__ 

420 except AttributeError: 

421 return str(t) 

422 

423 

424def render_type_mapping( 

425 pptm: PluginProvidedTypeMapping, 

426 output_style: OutputStyle, 

427 parser_context: ParserContextData, 

428 *, 

429 recover_from_broken_examples: bool = False, 

430 base_heading_level: int = 1, 

431) -> str: 

432 type_mapping = pptm.mapped_type 

433 target_type = type_mapping.target_type 

434 ref_doc = pptm.reference_documentation 

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

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

437 base_type = render_source_type(type_mapping.source_type) 

438 lines = [ 

439 output_style.heading( 

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

441 ), 

442 "", 

443 ] 

444 

445 if desc is not None: 

446 lines.extend( 

447 render_multiline_documentation( 

448 desc, 

449 first_line_prefix="", 

450 following_line_prefix="", 

451 ) 

452 ) 

453 else: 

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

455 

456 if examples: 

457 had_issues = False 

458 lines.append("") 

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

460 lines.append("") 

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

462 v, i = _render_type_example( 

463 type_mapping, 

464 output_style, 

465 parser_context, 

466 example, 

467 recover_from_broken_examples=recover_from_broken_examples, 

468 ) 

469 if i and recover_from_broken_examples: 

470 lines.append( 

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

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

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

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

475 ) 

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

477 if i: 

478 had_issues = True 

479 else: 

480 had_issues = False 

481 

482 if had_issues: 

483 lines.append("") 

484 lines.append( 

485 output_style.colored( 

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

487 ) 

488 ) 

489 lines.append("") 

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

491 

492 return "\n".join(lines) 

493 

494 

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

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

497 return f'"{v}"' 

498 return str(v)