Coverage for src/debputy/manifest_parser/parser_doc.py: 56%
241 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-07-18 20:51 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2025-07-18 20:51 +0000
1import itertools
2from string import Template
3from typing import (
4 Optional,
5 Iterable,
6 Any,
7 Tuple,
8 Mapping,
9 Sequence,
10 FrozenSet,
11 Container,
12 Union,
13)
15from debputy import DEBPUTY_DOC_ROOT_DIR
16from debputy.commands.debputy_cmd.output import OutputStyle
17from debputy.manifest_parser.declarative_parser import (
18 DeclarativeMappingInputParser,
19 DeclarativeNonMappingInputParser,
20 AttributeDescription,
21 BASIC_SIMPLE_TYPES,
22)
23from debputy.manifest_parser.parser_data import ParserContextData
24from debputy.manifest_parser.tagging_types import TypeMapping
25from debputy.manifest_parser.util import AttributePath, unpack_type
26from debputy.plugin.api.impl_types import (
27 DebputyPluginMetadata,
28 DeclarativeInputParser,
29 DispatchingObjectParser,
30 ListWrappedDeclarativeInputParser,
31 InPackageContextParser,
32 PluginProvidedTypeMapping,
33)
34from debputy.plugin.api.spec import (
35 ParserDocumentation,
36 reference_documentation,
37 undocumented_attr,
38 DebputyIntegrationMode,
39 ALL_DEBPUTY_INTEGRATION_MODES,
40 TypeMappingExample,
41)
42from debputy.util import assume_not_none, _error, _warn
45def _provide_placeholder_parser_doc(
46 parser_doc: Optional[ParserDocumentation],
47 attributes: Iterable[str],
48) -> ParserDocumentation:
49 if parser_doc is None: 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true
50 parser_doc = reference_documentation()
51 changes = {}
52 if parser_doc.attribute_doc is None: 52 ↛ 53line 52 didn't jump to line 53 because the condition on line 52 was never true
53 changes["attribute_doc"] = [undocumented_attr(attr) for attr in attributes]
55 if changes: 55 ↛ 56line 55 didn't jump to line 56 because the condition on line 55 was never true
56 return parser_doc.replace(**changes)
57 return parser_doc
60def doc_args_for_parser_doc(
61 rule_name: str,
62 declarative_parser: DeclarativeInputParser[Any],
63 plugin_metadata: DebputyPluginMetadata,
64 *,
65 manifest_format_url: Optional[str] = None,
66) -> Tuple[Mapping[str, str], ParserDocumentation]:
67 attributes: Iterable[str]
68 if isinstance(declarative_parser, DeclarativeMappingInputParser):
69 attributes = declarative_parser.source_attributes.keys()
70 else:
71 attributes = []
72 if manifest_format_url is None: 72 ↛ 74line 72 didn't jump to line 74 because the condition on line 72 was always true
73 manifest_format_url = f"{DEBPUTY_DOC_ROOT_DIR}/MANIFEST-FORMAT.md"
74 doc_args = {
75 "RULE_NAME": rule_name,
76 "MANIFEST_FORMAT_DOC": manifest_format_url,
77 "PLUGIN_NAME": plugin_metadata.plugin_name,
78 }
79 parser_doc = _provide_placeholder_parser_doc(
80 declarative_parser.inline_reference_documentation,
81 attributes,
82 )
83 return doc_args, parser_doc
86def render_attribute_doc(
87 parser: Any,
88 attributes: Mapping[str, "AttributeDescription"],
89 required_attributes: FrozenSet[str],
90 conditionally_required_attributes: FrozenSet[FrozenSet[str]],
91 parser_doc: ParserDocumentation,
92 doc_args: Mapping[str, str],
93 *,
94 rule_name: str = "<unset>",
95 is_root_rule: bool = False,
96 is_interactive: bool = False,
97) -> Iterable[Tuple[FrozenSet[str], Sequence[str]]]:
98 provided_attribute_docs = (
99 parser_doc.attribute_doc if parser_doc.attribute_doc is not None else []
100 )
102 for attr_doc in assume_not_none(provided_attribute_docs):
103 attr_description = attr_doc.description
104 rendered_doc = []
106 for parameter in sorted(attr_doc.attributes):
107 parameter_details = attributes.get(parameter)
108 if parameter_details is not None: 108 ↛ 112line 108 didn't jump to line 112 because the condition on line 108 was always true
109 source_name = parameter_details.source_attribute_name
110 describe_type = parameter_details.type_validator.describe_type()
111 else:
112 assert isinstance(parser, DispatchingObjectParser)
113 source_name = parameter
114 subparser = parser.parser_for(source_name).parser
115 if isinstance(subparser, InPackageContextParser):
116 if is_interactive:
117 describe_type = "PackageContext"
118 else:
119 rule_prefix = rule_name if not is_root_rule else ""
120 describe_type = f"PackageContext (chains to `{rule_prefix}::{subparser.manifest_attribute_path_template}`)"
122 elif isinstance(subparser, DispatchingObjectParser):
123 if is_interactive:
124 describe_type = "Object"
125 else:
126 rule_prefix = rule_name if not is_root_rule else ""
127 describe_type = f"Object (see `{rule_prefix}::{subparser.manifest_attribute_path_template}`)"
128 elif isinstance(subparser, DeclarativeMappingInputParser):
129 describe_type = "<Type definition not implemented yet>" # TODO: Derive from subparser
130 elif isinstance(subparser, DeclarativeNonMappingInputParser):
131 describe_type = (
132 subparser.alt_form_parser.type_validator.describe_type()
133 )
134 else:
135 describe_type = f"<Unknown: Non-introspectable subparser - {subparser.__class__.__name__}>"
137 if source_name in required_attributes:
138 req_str = "required"
139 elif any(source_name in s for s in conditionally_required_attributes):
140 req_str = "conditional"
141 else:
142 req_str = "optional"
143 rendered_doc.append(f"`{source_name}` ({req_str}): {describe_type}")
145 if attr_description: 145 ↛ 156line 145 didn't jump to line 156 because the condition on line 145 was always true
146 rendered_doc.append("")
147 attr_doc_rendered = _render_template(
148 f"attr docs for {rule_name}",
149 attr_description,
150 doc_args,
151 )
152 rendered_doc.extend(
153 line for line in attr_doc_rendered.splitlines(keepends=False)
154 )
155 rendered_doc.append("")
156 yield attr_doc.attributes, rendered_doc
159def _render_template(name: str, template_str: str, params: Mapping[str, str]) -> str:
160 try:
161 return Template(template_str).substitute(params)
162 except KeyError as e:
163 _warn(f"Render issue: {str(e)}")
164 _error(f"Failed to render {name}: Missing key {e.args[0]}")
165 except ValueError as e:
166 _warn(f"Render issue: {str(e)}")
167 _error(f"Failed to render {name}")
170def _render_integration_mode(
171 expected_modes: Optional[Container[DebputyIntegrationMode]],
172) -> Optional[str]:
173 if expected_modes:
174 allowed_modes = set()
175 for mode in sorted(ALL_DEBPUTY_INTEGRATION_MODES):
176 if mode in expected_modes:
177 allowed_modes.add(mode)
179 if allowed_modes == ALL_DEBPUTY_INTEGRATION_MODES: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 restriction = "any integration mode"
181 else:
182 restriction = ", ".join(sorted(allowed_modes))
183 else:
184 restriction = "any integration mode"
186 return f"Integration mode availability: {restriction}"
189def render_rule(
190 rule_name: str,
191 declarative_parser: DeclarativeInputParser[Any],
192 plugin_metadata: DebputyPluginMetadata,
193 color_base: OutputStyle,
194 *,
195 is_root_rule: bool = False,
196 include_ref_doc_link: bool = True,
197 include_alt_format: bool = True,
198 base_heading_level: int = 1,
199 manifest_format_url: Optional[str] = None,
200 show_integration_mode: bool = True,
201) -> str:
202 doc_args, parser_doc = doc_args_for_parser_doc(
203 "the manifest root" if is_root_rule else rule_name,
204 declarative_parser,
205 plugin_metadata,
206 manifest_format_url=manifest_format_url,
207 )
208 t = _render_template(
209 f"title of {rule_name}",
210 assume_not_none(parser_doc.title),
211 doc_args,
212 )
213 body = _render_template(
214 f"body of {rule_name}",
215 assume_not_none(parser_doc.description),
216 doc_args,
217 ).rstrip()
218 r = [
219 color_base.heading(t, base_heading_level),
220 "",
221 body,
222 "",
223 ]
225 if show_integration_mode: 225 ↛ 230line 225 didn't jump to line 230 because the condition on line 225 was always true
226 allowed_integration_modes = _render_integration_mode(
227 declarative_parser.expected_debputy_integration_mode
228 )
229 else:
230 allowed_integration_modes = None
231 alt_form_parser = getattr(declarative_parser, "alt_form_parser", None)
232 is_list_wrapped = False
233 unwrapped_parser = declarative_parser
234 if isinstance(declarative_parser, ListWrappedDeclarativeInputParser):
235 is_list_wrapped = True
236 unwrapped_parser = declarative_parser.delegate
238 if isinstance(
239 unwrapped_parser, (DeclarativeMappingInputParser, DispatchingObjectParser)
240 ):
242 if isinstance(unwrapped_parser, DeclarativeMappingInputParser): 242 ↛ 248line 242 didn't jump to line 248 because the condition on line 242 was always true
243 attributes = unwrapped_parser.source_attributes
244 required = unwrapped_parser.input_time_required_parameters
245 conditionally_required = unwrapped_parser.at_least_one_of
246 mutually_exclusive = unwrapped_parser.mutually_exclusive_attributes
247 else:
248 attributes = {}
249 required = frozenset()
250 conditionally_required = frozenset()
251 mutually_exclusive = frozenset()
252 if is_list_wrapped:
253 r.append("List where each element has the following attributes:")
254 else:
255 r.append("Attributes:")
257 rendered_attr_doc = render_attribute_doc(
258 unwrapped_parser,
259 attributes,
260 required,
261 conditionally_required,
262 parser_doc,
263 doc_args,
264 is_root_rule=is_root_rule,
265 rule_name=rule_name,
266 is_interactive=False,
267 )
268 for _, rendered_doc in rendered_attr_doc:
269 prefix = " - "
270 for line in rendered_doc:
271 if line:
272 r.append(f"{prefix}{line}")
273 else:
274 r.append("")
275 prefix = " "
277 if ( 277 ↛ 331line 277 didn't jump to line 331 because the condition on line 277 was always true
278 bool(conditionally_required)
279 or bool(mutually_exclusive)
280 or any(pd.conflicting_attributes for pd in attributes.values())
281 ):
282 r.append("")
283 if is_list_wrapped:
284 r.append(
285 "This rule enforces the following restrictions on each element in the list:"
286 )
287 else:
288 r.append("This rule enforces the following restrictions:")
290 if conditionally_required or mutually_exclusive: 290 ↛ 312line 290 didn't jump to line 312 because the condition on line 290 was always true
291 all_groups = list(
292 itertools.chain(conditionally_required, mutually_exclusive)
293 )
294 seen = set()
295 for g in all_groups:
296 if g in seen:
297 continue
298 seen.add(g)
299 anames = "`, `".join(sorted(g))
300 is_mx = g in mutually_exclusive
301 is_cr = g in conditionally_required
302 if is_mx and is_cr:
303 r.append(f" - The rule must use exactly one of: `{anames}`")
304 elif is_cr: 304 ↛ 305line 304 didn't jump to line 305 because the condition on line 304 was never true
305 r.append(f" - The rule must use at least one of: `{anames}`")
306 else:
307 assert is_mx
308 r.append(
309 f" - The following attributes are mutually exclusive: `{anames}`"
310 )
312 if mutually_exclusive or any( 312 ↛ 331line 312 didn't jump to line 331 because the condition on line 312 was always true
313 pd.conflicting_attributes for pd in attributes.values()
314 ):
315 for parameter, parameter_details in sorted(attributes.items()):
316 source_name = parameter_details.source_attribute_name
317 conflicts = set(parameter_details.conflicting_attributes)
318 for mx in mutually_exclusive:
319 if parameter in mx and mx not in conditionally_required: 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true
320 conflicts |= mx
321 if conflicts:
322 conflicts.discard(parameter)
323 cnames = "`, `".join(
324 sorted(
325 attributes[a].source_attribute_name for a in conflicts
326 )
327 )
328 r.append(
329 f" - The attribute `{source_name}` cannot be used with any of: `{cnames}`"
330 )
331 r.append("")
332 if include_alt_format and alt_form_parser is not None:
333 # FIXME: Mapping[str, Any] ends here, which is ironic given the headline.
334 r.append(
335 f"Non-mapping format: {alt_form_parser.type_validator.describe_type()}"
336 )
337 alt_parser_desc = parser_doc.alt_parser_description
338 if alt_parser_desc:
339 r.extend(
340 f" {line}"
341 for line in alt_parser_desc.format(**doc_args).splitlines(
342 keepends=False
343 )
344 )
345 r.append("")
347 if allowed_integration_modes: 347 ↛ 350line 347 didn't jump to line 350 because the condition on line 347 was always true
348 r.append(allowed_integration_modes)
350 if include_ref_doc_link: 350 ↛ 359line 350 didn't jump to line 359 because the condition on line 350 was always true
351 if declarative_parser.reference_documentation_url is not None: 351 ↛ 356line 351 didn't jump to line 356 because the condition on line 351 was always true
352 r.append(
353 f"Reference documentation: {declarative_parser.reference_documentation_url}"
354 )
355 else:
356 r.append(
357 "Reference documentation: No reference documentation link provided by the plugin"
358 )
359 elif allowed_integration_modes:
360 # Better spacing in the generated docs, but it looks weird with this newline
361 # in `debputy plugin show p-m-r ...`
362 r.append("")
364 return "\n".join(r)
367def render_multiline_documentation(
368 documentation: str,
369 *,
370 first_line_prefix: str = "Documentation: ",
371 following_line_prefix: str = " ",
372) -> Iterable[str]:
373 current_prefix = first_line_prefix
374 result = []
375 for line in documentation.splitlines(keepends=False):
376 if line.isspace():
377 if not current_prefix.isspace():
378 result.append(current_prefix.rstrip())
379 current_prefix = following_line_prefix
380 else:
381 result.append("")
382 continue
383 result.append(f"{current_prefix}{line}")
384 current_prefix = following_line_prefix
385 return result
388def _render_type_example(
389 type_mapping: TypeMapping[Any, Any],
390 output_style: OutputStyle,
391 parser_context: ParserContextData,
392 example: TypeMappingExample,
393 *,
394 recover_from_broken_examples: bool,
395) -> Tuple[str, bool]:
396 attr_path = AttributePath.builtin_path()["Render Request"]
397 v = _render_value(example.source_input)
398 try:
399 type_mapping.mapper(
400 example.source_input,
401 attr_path,
402 parser_context,
403 )
404 except RuntimeError:
405 if not recover_from_broken_examples:
406 raise
407 return (
408 output_style.colored(v, fg="red") + " [Example value could not be parsed]",
409 True,
410 )
411 return output_style.colored(v, fg="green"), False
414def render_source_type(t: Any) -> str:
415 _, origin_type, args = unpack_type(t, False)
416 if origin_type == Union:
417 return " | ".join(render_source_type(st) for st in args)
418 name = BASIC_SIMPLE_TYPES.get(t)
419 if name is not None:
420 return name
421 try:
422 return t.__name__
423 except AttributeError:
424 return str(t)
427def render_type_mapping(
428 pptm: PluginProvidedTypeMapping,
429 output_style: OutputStyle,
430 parser_context: ParserContextData,
431 *,
432 recover_from_broken_examples: bool = False,
433 base_heading_level: int = 1,
434) -> str:
435 type_mapping = pptm.mapped_type
436 target_type = type_mapping.target_type
437 ref_doc = pptm.reference_documentation
438 desc = ref_doc.description if ref_doc is not None else None
439 examples = ref_doc.examples if ref_doc is not None else tuple()
440 base_type = render_source_type(type_mapping.source_type)
441 lines = [
442 output_style.heading(
443 f"Type Mapping: {target_type.__name__} [{base_type}]", base_heading_level
444 ),
445 "",
446 ]
448 if desc is not None:
449 lines.extend(
450 render_multiline_documentation(
451 desc,
452 first_line_prefix="",
453 following_line_prefix="",
454 )
455 )
456 else:
457 lines.append("No documentation provided.")
459 if examples:
460 had_issues = False
461 lines.append("")
462 lines.append(output_style.heading("Example values", base_heading_level + 1))
463 lines.append("")
464 for no, example in enumerate(examples, start=1):
465 v, i = _render_type_example(
466 type_mapping,
467 output_style,
468 parser_context,
469 example,
470 recover_from_broken_examples=recover_from_broken_examples,
471 )
472 if i and recover_from_broken_examples:
473 lines.append(
474 output_style.colored("Broken example: ", fg="red")
475 + f"Provided example input ({v})"
476 + " caused an exception when parsed. Please file a bug against the plugin."
477 + " Use --debug/DEBPUTY_DEBUG=1 to see the stack trace"
478 )
479 lines.append(f" * {v}")
480 if i:
481 had_issues = True
482 else:
483 had_issues = False
485 if had_issues:
486 lines.append("")
487 lines.append(
488 output_style.colored(
489 "Examples had issues. Please file a bug against the plugin", fg="red"
490 )
491 )
492 lines.append("")
493 lines.append("Use --debug/DEBPUTY_DEBUG=1 to see the stacktrace")
495 return "\n".join(lines)
498def _render_value(v: Any) -> str:
499 if isinstance(v, str) and '"' not in v:
500 return f'"{v}"'
501 return str(v)