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
« 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
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
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]
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
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
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 )
93 for attr_doc in assume_not_none(provided_attribute_docs):
94 attr_description = attr_doc.description
95 rendered_doc = []
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}`)"
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__}>"
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}")
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
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}")
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)
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"
177 return f"Integration mode availability: {restriction}"
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 ]
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
229 if isinstance(
230 unwrapped_parser, (DeclarativeMappingInputParser, DispatchingObjectParser)
231 ):
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:")
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 = " "
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:")
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 )
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("")
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)
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("")
355 return "\n".join(r)
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
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
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)
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 ]
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.")
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
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")
486 return "\n".join(lines)
489def _render_value(v: Any) -> str:
490 if isinstance(v, str) and '"' not in v:
491 return f'"{v}"'
492 return str(v)