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

247 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-07-22 10:58 +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 AllowNoneDeclarativeInputParser, 

24) 

25from debputy.plugin.api.spec import ( 

26 ParserDocumentation, 

27 reference_documentation, 

28 undocumented_attr, 

29 DebputyIntegrationMode, 

30 ALL_DEBPUTY_INTEGRATION_MODES, 

31 TypeMappingExample, 

32) 

33from debputy.util import assume_not_none, _error, _warn 

34from debputy.version import debputy_doc_root_dir 

35 

36 

37def _provide_placeholder_parser_doc( 

38 parser_doc: ParserDocumentation | None, 

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: str | None = 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 because the condition on line 64 was always true

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: Container[DebputyIntegrationMode] | None, 

164) -> str | None: 

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 color_base: OutputStyle, 

186 *, 

187 is_root_rule: bool = False, 

188 include_ref_doc_link: bool = True, 

189 include_alt_format: bool = True, 

190 base_heading_level: int = 1, 

191 manifest_format_url: str | None = None, 

192 show_integration_mode: bool = True, 

193) -> str: 

194 doc_args, parser_doc = doc_args_for_parser_doc( 

195 "the manifest root" if is_root_rule else rule_name, 

196 declarative_parser, 

197 plugin_metadata, 

198 manifest_format_url=manifest_format_url, 

199 ) 

200 t = _render_template( 

201 f"title of {rule_name}", 

202 assume_not_none(parser_doc.title), 

203 doc_args, 

204 ) 

205 body = _render_template( 

206 f"body of {rule_name}", 

207 assume_not_none(parser_doc.description), 

208 doc_args, 

209 ).rstrip() 

210 r = [ 

211 color_base.heading(t, base_heading_level), 

212 "", 

213 body, 

214 "", 

215 ] 

216 

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

218 allowed_integration_modes = _render_integration_mode( 

219 declarative_parser.expected_debputy_integration_mode 

220 ) 

221 else: 

222 allowed_integration_modes = None 

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

224 is_list_wrapped = False 

225 requires_value = "yes" 

226 unwrapped_parser = declarative_parser 

227 if isinstance(unwrapped_parser, AllowNoneDeclarativeInputParser): 227 ↛ 228line 227 didn't jump to line 228 because the condition on line 227 was never true

228 unwrapped_parser = unwrapped_parser.delegate 

229 requires_value = "no" 

230 if isinstance(unwrapped_parser, ListWrappedDeclarativeInputParser): 

231 is_list_wrapped = True 

232 unwrapped_parser = unwrapped_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 ( 273 ↛ 327line 273 didn't jump to line 327 because the condition on line 273 was always true

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 ↛ 327line 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 

329 r.append(f"Requires value: {requires_value}") 

330 if include_alt_format and alt_form_parser is not None: 

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

332 r.append( 

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

334 ) 

335 alt_parser_desc = parser_doc.alt_parser_description 

336 if alt_parser_desc: 

337 r.extend( 

338 f" {line}" 

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

340 keepends=False 

341 ) 

342 ) 

343 r.append("") 

344 

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

346 r.append(allowed_integration_modes) 

347 

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

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

350 r.append( 

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

352 ) 

353 else: 

354 r.append( 

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

356 ) 

357 elif allowed_integration_modes: 

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

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

360 r.append("") 

361 

362 return "\n".join(r) 

363 

364 

365def render_multiline_documentation( 

366 documentation: str, 

367 *, 

368 first_line_prefix: str = "Documentation: ", 

369 following_line_prefix: str = " ", 

370) -> Iterable[str]: 

371 current_prefix = first_line_prefix 

372 result = [] 

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

374 if line.isspace(): 

375 if not current_prefix.isspace(): 

376 result.append(current_prefix.rstrip()) 

377 current_prefix = following_line_prefix 

378 else: 

379 result.append("") 

380 continue 

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

382 current_prefix = following_line_prefix 

383 return result 

384 

385 

386def _render_type_example( 

387 type_mapping: TypeMapping[Any, Any], 

388 output_style: OutputStyle, 

389 parser_context: ParserContextData, 

390 example: TypeMappingExample, 

391 *, 

392 recover_from_broken_examples: bool, 

393) -> tuple[str, bool]: 

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

395 v = _render_value(example.source_input) 

396 try: 

397 type_mapping.mapper( 

398 example.source_input, 

399 attr_path, 

400 parser_context, 

401 ) 

402 except RuntimeError: 

403 if not recover_from_broken_examples: 

404 raise 

405 return ( 

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

407 True, 

408 ) 

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

410 

411 

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

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

414 if origin_type == Union: 

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

416 name = BASIC_SIMPLE_TYPES.get(t) 

417 if name is not None: 

418 return name 

419 try: 

420 return t.__name__ 

421 except AttributeError: 

422 return str(t) 

423 

424 

425def render_type_mapping( 

426 pptm: PluginProvidedTypeMapping, 

427 output_style: OutputStyle, 

428 parser_context: ParserContextData, 

429 *, 

430 recover_from_broken_examples: bool = False, 

431 base_heading_level: int = 1, 

432) -> str: 

433 type_mapping = pptm.mapped_type 

434 target_type = type_mapping.target_type 

435 ref_doc = pptm.reference_documentation 

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

437 examples = ref_doc.examples if ref_doc is not None else () 

438 base_type = render_source_type(type_mapping.source_type) 

439 lines = [ 

440 output_style.heading( 

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

442 ), 

443 "", 

444 ] 

445 

446 if desc is not None: 

447 lines.extend( 

448 render_multiline_documentation( 

449 desc, 

450 first_line_prefix="", 

451 following_line_prefix="", 

452 ) 

453 ) 

454 else: 

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

456 

457 if examples: 

458 had_issues = False 

459 lines.append("") 

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

461 lines.append("") 

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

463 v, i = _render_type_example( 

464 type_mapping, 

465 output_style, 

466 parser_context, 

467 example, 

468 recover_from_broken_examples=recover_from_broken_examples, 

469 ) 

470 if i and recover_from_broken_examples: 

471 lines.append( 

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

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

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

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

476 ) 

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

478 if i: 

479 had_issues = True 

480 else: 

481 had_issues = False 

482 

483 if had_issues: 

484 lines.append("") 

485 lines.append( 

486 output_style.colored( 

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

488 ) 

489 ) 

490 lines.append("") 

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

492 

493 return "\n".join(lines) 

494 

495 

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

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

498 return f'"{v}"' 

499 return str(v)