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
« 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
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
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]
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
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
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 )
94 for attr_doc in assume_not_none(provided_attribute_docs):
95 attr_description = attr_doc.description
96 rendered_doc = []
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}`)"
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__}>"
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}")
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
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}")
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)
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"
178 return f"Integration mode availability: {restriction}"
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 ]
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
234 if isinstance(
235 unwrapped_parser, (DeclarativeMappingInputParser, DispatchingObjectParser)
236 ):
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:")
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 = " "
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:")
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 )
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("")
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("")
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)
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("")
362 return "\n".join(r)
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
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
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)
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 ]
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.")
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
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")
493 return "\n".join(lines)
496def _render_value(v: Any) -> str:
497 if isinstance(v, str) and '"' not in v:
498 return f'"{v}"'
499 return str(v)