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
« 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
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
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]
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
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
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 )
99 for attr_doc in assume_not_none(provided_attribute_docs):
100 attr_description = attr_doc.description
101 rendered_doc = []
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}`)"
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__}>"
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}")
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
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}")
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)
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"
183 return f"Integration mode availability: {restriction}"
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 ]
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
235 if isinstance(
236 unwrapped_parser, (DeclarativeMappingInputParser, DispatchingObjectParser)
237 ):
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:")
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 = " "
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:")
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 )
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("")
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)
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("")
361 return "\n".join(r)
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
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
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)
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 ]
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.")
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
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")
492 return "\n".join(lines)
495def _render_value(v: Any) -> str:
496 if isinstance(v, str) and '"' not in v:
497 return f'"{v}"'
498 return str(v)