Coverage for src/debputy/manifest_parser/parser_doc.py: 74%
169 statements
« prev ^ index » next coverage.py v7.6.0, created at 2025-01-27 13:59 +0000
« prev ^ index » next coverage.py v7.6.0, created at 2025-01-27 13:59 +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)
14from debputy import DEBPUTY_DOC_ROOT_DIR
15from debputy.manifest_parser.declarative_parser import (
16 DeclarativeMappingInputParser,
17 DeclarativeNonMappingInputParser,
18 AttributeDescription,
19)
20from debputy.plugin.api.impl_types import (
21 DebputyPluginMetadata,
22 DeclarativeInputParser,
23 DispatchingObjectParser,
24 ListWrappedDeclarativeInputParser,
25 InPackageContextParser,
26)
27from debputy.plugin.api.spec import (
28 ParserDocumentation,
29 reference_documentation,
30 undocumented_attr,
31 DebputyIntegrationMode,
32 ALL_DEBPUTY_INTEGRATION_MODES,
33)
34from debputy.util import assume_not_none, _error, _warn
37def _provide_placeholder_parser_doc(
38 parser_doc: Optional[ParserDocumentation],
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: Optional[str] = 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
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: Optional[Container[DebputyIntegrationMode]],
164) -> Optional[str]:
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 *,
186 is_root_rule: bool = False,
187 include_ref_doc_link: bool = True,
188 include_alt_format: bool = True,
189 heading: str = "=",
190 manifest_format_url: Optional[str] = None,
191) -> str:
192 doc_args, parser_doc = doc_args_for_parser_doc(
193 "the manifest root" if is_root_rule else rule_name,
194 declarative_parser,
195 plugin_metadata,
196 manifest_format_url=manifest_format_url,
197 )
198 t = _render_template(
199 f"title of {rule_name}",
200 assume_not_none(parser_doc.title),
201 doc_args,
202 )
203 body = _render_template(
204 f"body of {rule_name}",
205 assume_not_none(parser_doc.description),
206 doc_args,
207 ).rstrip()
208 if heading.startswith("#"): 208 ↛ 209line 208 didn't jump to line 209
209 r = [
210 f"{heading} {t}",
211 "",
212 body,
213 "",
214 ]
215 else:
216 r = [
217 t,
218 heading * len(t),
219 "",
220 body,
221 "",
222 ]
224 allowed_integration_modes = _render_integration_mode(
225 declarative_parser.expected_debputy_integration_mode
226 )
227 alt_form_parser = getattr(declarative_parser, "alt_form_parser", None)
228 is_list_wrapped = False
229 unwrapped_parser = declarative_parser
230 if isinstance(declarative_parser, ListWrappedDeclarativeInputParser):
231 is_list_wrapped = True
232 unwrapped_parser = declarative_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 (
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 ↛ exit, 308 ↛ 3272 missed branches: 1) line 308 didn't run the generator expression on line 308, 2) line 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 if include_alt_format and alt_form_parser is not None:
329 # FIXME: Mapping[str, Any] ends here, which is ironic given the headline.
330 r.append(
331 f"Non-mapping format: {alt_form_parser.type_validator.describe_type()}"
332 )
333 alt_parser_desc = parser_doc.alt_parser_description
334 if alt_parser_desc:
335 r.extend(
336 f" {line}"
337 for line in alt_parser_desc.format(**doc_args).splitlines(
338 keepends=False
339 )
340 )
341 r.append("")
343 if allowed_integration_modes: 343 ↛ 346line 343 didn't jump to line 346 because the condition on line 343 was always true
344 r.append(allowed_integration_modes)
346 if include_ref_doc_link: 346 ↛ 355line 346 didn't jump to line 355 because the condition on line 346 was always true
347 if declarative_parser.reference_documentation_url is not None: 347 ↛ 352line 347 didn't jump to line 352 because the condition on line 347 was always true
348 r.append(
349 f"Reference documentation: {declarative_parser.reference_documentation_url}"
350 )
351 else:
352 r.append(
353 "Reference documentation: No reference documentation link provided by the plugin"
354 )
355 elif allowed_integration_modes:
356 # Better spacing in the generated docs, but it looks weird with this newline
357 # in `debputy plugin show p-m-r ...`
358 r.append("")
360 return "\n".join(r)