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

242 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-01-16 17:20 +0000

1import itertools 

2from string import Template 

3from typing import Any, Union 

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

5 

6from debputy.commands.debputy_cmd.output import OutputStyle 

7from debputy.manifest_parser.declarative_parser import ( 

8 DeclarativeMappingInputParser, 

9 DeclarativeNonMappingInputParser, 

10 AttributeDescription, 

11 BASIC_SIMPLE_TYPES, 

12) 

13from debputy.manifest_parser.parser_data import ParserContextData 

14from debputy.manifest_parser.tagging_types import TypeMapping 

15from debputy.manifest_parser.util import AttributePath, unpack_type 

16from debputy.plugin.api.impl_types import ( 

17 DebputyPluginMetadata, 

18 DeclarativeInputParser, 

19 DispatchingObjectParser, 

20 ListWrappedDeclarativeInputParser, 

21 InPackageContextParser, 

22 PluginProvidedTypeMapping, 

23) 

24from debputy.plugin.api.spec import ( 

25 ParserDocumentation, 

26 reference_documentation, 

27 undocumented_attr, 

28 DebputyIntegrationMode, 

29 ALL_DEBPUTY_INTEGRATION_MODES, 

30 TypeMappingExample, 

31) 

32from debputy.util import assume_not_none, _error, _warn 

33from debputy.version import debputy_doc_root_dir 

34 

35 

36def _provide_placeholder_parser_doc( 

37 parser_doc: ParserDocumentation | None, 

38 attributes: Iterable[str], 

39) -> ParserDocumentation: 

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

41 parser_doc = reference_documentation() 

42 changes = {} 

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

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

45 

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

47 return parser_doc.replace(**changes) 

48 return parser_doc 

49 

50 

51def doc_args_for_parser_doc( 

52 rule_name: str, 

53 declarative_parser: DeclarativeInputParser[Any], 

54 plugin_metadata: DebputyPluginMetadata, 

55 *, 

56 manifest_format_url: str | None = None, 

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

58 attributes: Iterable[str] 

59 if isinstance(declarative_parser, DeclarativeMappingInputParser): 

60 attributes = declarative_parser.source_attributes.keys() 

61 else: 

62 attributes = [] 

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

64 manifest_format_url = f"{debputy_doc_root_dir()}/MANIFEST-FORMAT.md" 

65 doc_args = { 

66 "RULE_NAME": rule_name, 

67 "MANIFEST_FORMAT_DOC": manifest_format_url, 

68 "PLUGIN_NAME": plugin_metadata.plugin_name, 

69 } 

70 parser_doc = _provide_placeholder_parser_doc( 

71 declarative_parser.inline_reference_documentation, 

72 attributes, 

73 ) 

74 return doc_args, parser_doc 

75 

76 

77def render_attribute_doc( 

78 parser: Any, 

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

80 required_attributes: frozenset[str], 

81 conditionally_required_attributes: frozenset[frozenset[str]], 

82 parser_doc: ParserDocumentation, 

83 doc_args: Mapping[str, str], 

84 *, 

85 rule_name: str = "<unset>", 

86 is_root_rule: bool = False, 

87 is_interactive: bool = False, 

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

89 provided_attribute_docs = ( 

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

91 ) 

92 

93 for attr_doc in assume_not_none(provided_attribute_docs): 

94 attr_description = attr_doc.description 

95 rendered_doc = [] 

96 

97 for parameter in sorted(attr_doc.attributes): 

98 parameter_details = attributes.get(parameter) 

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

100 source_name = parameter_details.source_attribute_name 

101 describe_type = parameter_details.type_validator.describe_type() 

102 else: 

103 assert isinstance(parser, DispatchingObjectParser) 

104 source_name = parameter 

105 subparser = parser.parser_for(source_name).parser 

106 if isinstance(subparser, InPackageContextParser): 

107 if is_interactive: 

108 describe_type = "PackageContext" 

109 else: 

110 rule_prefix = rule_name if not is_root_rule else "" 

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

112 

113 elif isinstance(subparser, DispatchingObjectParser): 

114 if is_interactive: 

115 describe_type = "Object" 

116 else: 

117 rule_prefix = rule_name if not is_root_rule else "" 

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

119 elif isinstance(subparser, DeclarativeMappingInputParser): 

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

121 elif isinstance(subparser, DeclarativeNonMappingInputParser): 

122 describe_type = ( 

123 subparser.alt_form_parser.type_validator.describe_type() 

124 ) 

125 else: 

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

127 

128 if source_name in required_attributes: 

129 req_str = "required" 

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

131 req_str = "conditional" 

132 else: 

133 req_str = "optional" 

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

135 

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

137 rendered_doc.append("") 

138 attr_doc_rendered = _render_template( 

139 f"attr docs for {rule_name}", 

140 attr_description, 

141 doc_args, 

142 ) 

143 rendered_doc.extend( 

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

145 ) 

146 rendered_doc.append("") 

147 yield attr_doc.attributes, rendered_doc 

148 

149 

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

151 try: 

152 return Template(template_str).substitute(params) 

153 except KeyError as e: 

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

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

156 except ValueError as e: 

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

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

159 

160 

161def _render_integration_mode( 

162 expected_modes: Container[DebputyIntegrationMode] | None, 

163) -> str | None: 

164 if expected_modes: 

165 allowed_modes = set() 

166 for mode in sorted(ALL_DEBPUTY_INTEGRATION_MODES): 

167 if mode in expected_modes: 

168 allowed_modes.add(mode) 

169 

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

171 restriction = "any integration mode" 

172 else: 

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

174 else: 

175 restriction = "any integration mode" 

176 

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

178 

179 

180def render_rule( 

181 rule_name: str, 

182 declarative_parser: DeclarativeInputParser[Any], 

183 plugin_metadata: DebputyPluginMetadata, 

184 color_base: OutputStyle, 

185 *, 

186 is_root_rule: bool = False, 

187 include_ref_doc_link: bool = True, 

188 include_alt_format: bool = True, 

189 base_heading_level: int = 1, 

190 manifest_format_url: str | None = None, 

191 show_integration_mode: bool = True, 

192) -> str: 

193 doc_args, parser_doc = doc_args_for_parser_doc( 

194 "the manifest root" if is_root_rule else rule_name, 

195 declarative_parser, 

196 plugin_metadata, 

197 manifest_format_url=manifest_format_url, 

198 ) 

199 t = _render_template( 

200 f"title of {rule_name}", 

201 assume_not_none(parser_doc.title), 

202 doc_args, 

203 ) 

204 body = _render_template( 

205 f"body of {rule_name}", 

206 assume_not_none(parser_doc.description), 

207 doc_args, 

208 ).rstrip() 

209 r = [ 

210 color_base.heading(t, base_heading_level), 

211 "", 

212 body, 

213 "", 

214 ] 

215 

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

217 allowed_integration_modes = _render_integration_mode( 

218 declarative_parser.expected_debputy_integration_mode 

219 ) 

220 else: 

221 allowed_integration_modes = None 

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

223 is_list_wrapped = False 

224 unwrapped_parser = declarative_parser 

225 if isinstance(declarative_parser, ListWrappedDeclarativeInputParser): 

226 is_list_wrapped = True 

227 unwrapped_parser = declarative_parser.delegate 

228 

229 if isinstance( 

230 unwrapped_parser, (DeclarativeMappingInputParser, DispatchingObjectParser) 

231 ): 

232 

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

234 attributes = unwrapped_parser.source_attributes 

235 required = unwrapped_parser.input_time_required_parameters 

236 conditionally_required = unwrapped_parser.at_least_one_of 

237 mutually_exclusive = unwrapped_parser.mutually_exclusive_attributes 

238 else: 

239 attributes = {} 

240 required = frozenset() 

241 conditionally_required = frozenset() 

242 mutually_exclusive = frozenset() 

243 if is_list_wrapped: 

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

245 else: 

246 r.append("Attributes:") 

247 

248 rendered_attr_doc = render_attribute_doc( 

249 unwrapped_parser, 

250 attributes, 

251 required, 

252 conditionally_required, 

253 parser_doc, 

254 doc_args, 

255 is_root_rule=is_root_rule, 

256 rule_name=rule_name, 

257 is_interactive=False, 

258 ) 

259 for _, rendered_doc in rendered_attr_doc: 

260 prefix = " - " 

261 for line in rendered_doc: 

262 if line: 

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

264 else: 

265 r.append("") 

266 prefix = " " 

267 

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

269 bool(conditionally_required) 

270 or bool(mutually_exclusive) 

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

272 ): 

273 r.append("") 

274 if is_list_wrapped: 

275 r.append( 

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

277 ) 

278 else: 

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

280 

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

282 all_groups = list( 

283 itertools.chain(conditionally_required, mutually_exclusive) 

284 ) 

285 seen = set() 

286 for g in all_groups: 

287 if g in seen: 

288 continue 

289 seen.add(g) 

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

291 is_mx = g in mutually_exclusive 

292 is_cr = g in conditionally_required 

293 if is_mx and is_cr: 

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

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

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

297 else: 

298 assert is_mx 

299 r.append( 

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

301 ) 

302 

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

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

305 ): 

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

307 source_name = parameter_details.source_attribute_name 

308 conflicts = set(parameter_details.conflicting_attributes) 

309 for mx in mutually_exclusive: 

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

311 conflicts |= mx 

312 if conflicts: 

313 conflicts.discard(parameter) 

314 cnames = "`, `".join( 

315 sorted( 

316 attributes[a].source_attribute_name for a in conflicts 

317 ) 

318 ) 

319 r.append( 

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

321 ) 

322 r.append("") 

323 if include_alt_format and alt_form_parser is not None: 

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

325 r.append( 

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

327 ) 

328 alt_parser_desc = parser_doc.alt_parser_description 

329 if alt_parser_desc: 

330 r.extend( 

331 f" {line}" 

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

333 keepends=False 

334 ) 

335 ) 

336 r.append("") 

337 

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

339 r.append(allowed_integration_modes) 

340 

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

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

343 r.append( 

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

345 ) 

346 else: 

347 r.append( 

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

349 ) 

350 elif allowed_integration_modes: 

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

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

353 r.append("") 

354 

355 return "\n".join(r) 

356 

357 

358def render_multiline_documentation( 

359 documentation: str, 

360 *, 

361 first_line_prefix: str = "Documentation: ", 

362 following_line_prefix: str = " ", 

363) -> Iterable[str]: 

364 current_prefix = first_line_prefix 

365 result = [] 

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

367 if line.isspace(): 

368 if not current_prefix.isspace(): 

369 result.append(current_prefix.rstrip()) 

370 current_prefix = following_line_prefix 

371 else: 

372 result.append("") 

373 continue 

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

375 current_prefix = following_line_prefix 

376 return result 

377 

378 

379def _render_type_example( 

380 type_mapping: TypeMapping[Any, Any], 

381 output_style: OutputStyle, 

382 parser_context: ParserContextData, 

383 example: TypeMappingExample, 

384 *, 

385 recover_from_broken_examples: bool, 

386) -> tuple[str, bool]: 

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

388 v = _render_value(example.source_input) 

389 try: 

390 type_mapping.mapper( 

391 example.source_input, 

392 attr_path, 

393 parser_context, 

394 ) 

395 except RuntimeError: 

396 if not recover_from_broken_examples: 

397 raise 

398 return ( 

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

400 True, 

401 ) 

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

403 

404 

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

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

407 if origin_type == Union: 

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

409 name = BASIC_SIMPLE_TYPES.get(t) 

410 if name is not None: 

411 return name 

412 try: 

413 return t.__name__ 

414 except AttributeError: 

415 return str(t) 

416 

417 

418def render_type_mapping( 

419 pptm: PluginProvidedTypeMapping, 

420 output_style: OutputStyle, 

421 parser_context: ParserContextData, 

422 *, 

423 recover_from_broken_examples: bool = False, 

424 base_heading_level: int = 1, 

425) -> str: 

426 type_mapping = pptm.mapped_type 

427 target_type = type_mapping.target_type 

428 ref_doc = pptm.reference_documentation 

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

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

431 base_type = render_source_type(type_mapping.source_type) 

432 lines = [ 

433 output_style.heading( 

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

435 ), 

436 "", 

437 ] 

438 

439 if desc is not None: 

440 lines.extend( 

441 render_multiline_documentation( 

442 desc, 

443 first_line_prefix="", 

444 following_line_prefix="", 

445 ) 

446 ) 

447 else: 

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

449 

450 if examples: 

451 had_issues = False 

452 lines.append("") 

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

454 lines.append("") 

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

456 v, i = _render_type_example( 

457 type_mapping, 

458 output_style, 

459 parser_context, 

460 example, 

461 recover_from_broken_examples=recover_from_broken_examples, 

462 ) 

463 if i and recover_from_broken_examples: 

464 lines.append( 

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

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

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

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

469 ) 

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

471 if i: 

472 had_issues = True 

473 else: 

474 had_issues = False 

475 

476 if had_issues: 

477 lines.append("") 

478 lines.append( 

479 output_style.colored( 

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

481 ) 

482 ) 

483 lines.append("") 

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

485 

486 return "\n".join(lines) 

487 

488 

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

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

491 return f'"{v}"' 

492 return str(v)