Coverage for src/debputy/commands/debputy_cmd/plugin_cmds.py: 13%
490 statements
« prev ^ index » next coverage.py v7.8.2, created at 2026-01-26 19:30 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2026-01-26 19:30 +0000
1import argparse
2import dataclasses
3import operator
4import os
5import sys
6import textwrap
7from itertools import chain
8from typing import (
9 Union,
10 Tuple,
11 Any,
12 Optional,
13 Type,
14)
15from collections.abc import Sequence, Iterable, Callable
17from debputy.analysis.analysis_util import flatten_ppfs
18from debputy.commands.debputy_cmd.context import (
19 CommandContext,
20 add_arg,
21 ROOT_COMMAND,
22)
23from debputy.commands.debputy_cmd.output import (
24 _stream_to_pager,
25 _output_styling,
26 IOBasedOutputStyling,
27)
28from debputy.exceptions import DebputySubstitutionError
29from debputy.filesystem_scan import build_virtual_fs
30from debputy.manifest_parser.declarative_parser import (
31 BASIC_SIMPLE_TYPES,
32)
33from debputy.manifest_parser.parser_doc import (
34 render_rule,
35 render_multiline_documentation,
36 render_type_mapping,
37 render_source_type,
38)
39from debputy.manifest_parser.util import unpack_type
40from debputy.packager_provided_files import detect_all_packager_provided_files
41from debputy.plugin.api.doc_parsing import parser_type_name
42from debputy.plugin.api.example_processing import (
43 process_discard_rule_example,
44 DiscardVerdict,
45)
46from debputy.plugin.api.feature_set import PluginProvidedFeatureSet
47from debputy.plugin.api.impl import plugin_metadata_for_debputys_own_plugin
48from debputy.plugin.api.impl_types import (
49 PackagerProvidedFileClassSpec,
50 PluginProvidedManifestVariable,
51 DispatchingParserBase,
52 PluginProvidedDiscardRule,
53 AutomaticDiscardRuleExample,
54 MetadataOrMaintscriptDetector,
55 DebputyPluginMetadata,
56 DeclarativeInputParser,
57)
58from debputy.plugin.api.parser_tables import (
59 SUPPORTED_DISPATCHABLE_TABLE_PARSERS,
60 OPARSER_MANIFEST_ROOT,
61)
62from debputy.substitution import Substitution
63from debputy.util import _error, _warn, manifest_format_doc
65plugin_dispatcher = ROOT_COMMAND.add_dispatching_subcommand(
66 "plugin",
67 "plugin_subcommand",
68 default_subcommand="--help",
69 help_description="Interact with debputy plugins",
70 metavar="command",
71)
73plugin_list_cmds = plugin_dispatcher.add_dispatching_subcommand(
74 "list",
75 "plugin_subcommand_list",
76 metavar="topic",
77 default_subcommand="plugins",
78 help_description="List plugins or things provided by plugins (unstable format)."
79 " Pass `--help` *after* `list` get a topic listing",
80)
82plugin_show_cmds = plugin_dispatcher.add_dispatching_subcommand(
83 "show",
84 "plugin_subcommand_show",
85 metavar="topic",
86 help_description="Show details about a plugin or things provided by plugins (unstable format)."
87 " Pass `--help` *after* `show` get a topic listing",
88)
91def format_output_arg(
92 default_format: str,
93 allowed_formats: Sequence[str],
94 help_text: str,
95) -> Callable[[argparse.ArgumentParser], None]:
96 if default_format not in allowed_formats: 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true
97 raise ValueError("The default format must be in the allowed_formats...")
99 def _configurator(argparser: argparse.ArgumentParser) -> None:
100 argparser.add_argument(
101 "--output-format",
102 dest="output_format",
103 default=default_format,
104 choices=allowed_formats,
105 help=help_text,
106 )
108 return _configurator
111# To let --output-format=... "always" work
112TEXT_ONLY_FORMAT = format_output_arg(
113 "text",
114 ["text"],
115 "Select a given output format (options and output are not stable between releases)",
116)
119TEXT_CSV_FORMAT_NO_STABILITY_PROMISE = format_output_arg(
120 "text",
121 ["text", "csv"],
122 "Select a given output format (options and output are not stable between releases)",
123)
126@plugin_list_cmds.register_subcommand(
127 "plugins",
128 help_description="List known plugins",
129 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE,
130)
131def _plugin_cmd_list_plugins(context: CommandContext) -> None:
132 plugin_metadata_entries = context.load_plugins().plugin_data.values()
133 # Because the "plugins" part is optional, we are not guaranteed that TEXT_CSV_FORMAT applies
134 output_format = getattr(context.parsed_args, "output_format", "text")
135 assert output_format in {"text", "csv"}
136 with _stream_to_pager(context.parsed_args) as (fd, fo):
137 fo.print_list_table(
138 headers=("Plugin Name", "Plugin Path"),
139 rows=tuple((p.plugin_name, p.plugin_path) for p in plugin_metadata_entries),
140 )
143def _path(path: str) -> str:
144 if path.startswith("./"):
145 return path[1:]
146 return path
149def _ppf_flags(ppf: PackagerProvidedFileClassSpec) -> str:
150 flags = []
151 if ppf.allow_name_segment:
152 flags.append("named")
153 if ppf.allow_architecture_segment:
154 flags.append("arch")
155 if ppf.supports_priority:
156 flags.append(f"priority={ppf.default_priority}")
157 if ppf.packageless_is_fallback_for_all_packages:
158 flags.append("main-all-fallback")
159 if ppf.post_formatting_rewrite:
160 flags.append("post-format-hook")
161 return ",".join(flags)
164@plugin_list_cmds.register_subcommand(
165 ["used-packager-provided-files", "uppf", "u-p-p-f"],
166 help_description="List packager provided files used by this package (debian/pkg.foo)",
167 argparser=TEXT_ONLY_FORMAT,
168)
169def _plugin_cmd_list_uppf(context: CommandContext) -> None:
170 plugin_feature_set = context.load_plugins()
171 all_ppfs = detect_all_packager_provided_files(
172 plugin_feature_set,
173 context.debian_dir,
174 context.binary_packages(),
175 )
176 requested_plugins = set(context.requested_plugins())
177 requested_plugins.add("debputy")
178 all_detected_ppfs = list(flatten_ppfs(all_ppfs))
180 used_ppfs = [
181 p
182 for p in all_detected_ppfs
183 if p.definition.debputy_plugin_metadata.plugin_name in requested_plugins
184 ]
185 inactive_ppfs = [
186 p
187 for p in all_detected_ppfs
188 if p.definition.debputy_plugin_metadata.plugin_name not in requested_plugins
189 ]
191 if not used_ppfs and not inactive_ppfs:
192 print("No packager provided files detected; not even a changelog... ?")
193 return
195 with _stream_to_pager(context.parsed_args) as (fd, fo):
196 if used_ppfs:
197 fo.print_list_table(
198 headers=(
199 "File",
200 "Matched Stem",
201 "Installed Into",
202 "Installed As",
203 ),
204 rows=tuple(
205 (
206 ppf.path.path,
207 ppf.definition.stem,
208 ppf.package_name,
209 "/".join(ppf.compute_dest()).lstrip("."),
210 )
211 for ppf in sorted(
212 used_ppfs, key=operator.attrgetter("package_name")
213 )
214 ),
215 )
217 if inactive_ppfs:
218 fo.print_list_table(
219 headers=(
220 "UNUSED FILE",
221 "Matched Stem",
222 "Installed Into",
223 "Could Be Installed As",
224 "If B-D Had",
225 ),
226 rows=tuple(
227 (
228 f"~{ppf.path.path}~",
229 ppf.definition.stem,
230 f"~{ppf.package_name}~",
231 "/".join(ppf.compute_dest()).lstrip("."),
232 f"debputy-plugin-{ppf.definition.debputy_plugin_metadata.plugin_name}",
233 )
234 for ppf in sorted(
235 inactive_ppfs, key=operator.attrgetter("package_name")
236 )
237 ),
238 )
241@plugin_list_cmds.register_subcommand(
242 ["packager-provided-files", "ppf", "p-p-f"],
243 help_description="List packager provided file definitions (debian/pkg.foo)",
244 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE,
245)
246def _plugin_cmd_list_ppf(context: CommandContext) -> None:
247 ppfs: Iterable[PackagerProvidedFileClassSpec]
248 ppfs = context.load_plugins().packager_provided_files.values()
249 with _stream_to_pager(context.parsed_args) as (fd, fo):
250 fo.print_list_table(
251 headers=(
252 "Stem",
253 "Installed As",
254 ("Mode", ">"),
255 "Features",
256 "Provided by",
257 ),
258 rows=tuple(
259 (
260 ppf.stem,
261 _path(ppf.installed_as_format),
262 "0" + oct(ppf.default_mode)[2:],
263 _ppf_flags(ppf),
264 ppf.debputy_plugin_metadata.plugin_name,
265 )
266 for ppf in sorted(ppfs, key=operator.attrgetter("stem"))
267 ),
268 )
270 if os.path.isdir("debian/") and fo.output_format == "text":
271 fo.print()
272 fo.print(
273 "Hint: You can use `debputy plugin list used-packager-provided-files` to have `debputy`",
274 )
275 fo.print("list all the files in debian/ that matches these definitions.")
278@plugin_list_cmds.register_subcommand(
279 ["metadata-detectors"],
280 help_description="List metadata detectors",
281 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE,
282)
283def _plugin_cmd_list_metadata_detectors(context: CommandContext) -> None:
284 mds = list(
285 chain.from_iterable(
286 context.load_plugins().metadata_maintscript_detectors.values()
287 )
288 )
290 def _sort_key(md: "MetadataOrMaintscriptDetector") -> Any:
291 return md.plugin_metadata.plugin_name, md.detector_id
293 with _stream_to_pager(context.parsed_args) as (fd, fo):
294 fo.print_list_table(
295 headers=("Provided by", "Detector Id"),
296 rows=tuple(
297 (md.plugin_metadata.plugin_name, md.detector_id)
298 for md in sorted(mds, key=_sort_key)
299 ),
300 )
303def _resolve_variable_for_list(
304 substitution: Substitution,
305 variable: PluginProvidedManifestVariable,
306) -> str:
307 var = "{{" + variable.variable_name + "}}"
308 try:
309 value = substitution.substitute(var, "CLI request")
310 except DebputySubstitutionError:
311 value = None
312 return _render_manifest_variable_value(value)
315def _render_manifest_variable_flag(variable: PluginProvidedManifestVariable) -> str:
316 flags = []
317 if variable.is_for_special_case:
318 flags.append("special-use-case")
319 if variable.is_internal:
320 flags.append("internal")
321 return ",".join(flags)
324def _render_list_filter(v: bool | None) -> str:
325 if v is None:
326 return "N/A"
327 return "shown" if v else "hidden"
330@plugin_list_cmds.register_subcommand(
331 ["manifest-variables"],
332 help_description="List plugin provided manifest variables (such as `{{path:FOO}}`)",
333)
334def plugin_cmd_list_manifest_variables(context: CommandContext) -> None:
335 variables = context.load_plugins().manifest_variables
336 substitution = context.substitution.with_extra_substitutions(
337 PACKAGE="<package-name>"
338 )
339 parsed_args = context.parsed_args
340 show_special_case_vars = parsed_args.show_special_use_variables
341 show_token_vars = parsed_args.show_token_variables
342 show_all_vars = parsed_args.show_all_variables
344 def _include_var(var: PluginProvidedManifestVariable) -> bool:
345 if show_all_vars:
346 return True
347 if var.is_internal:
348 return False
349 if var.is_for_special_case and not show_special_case_vars:
350 return False
351 if var.is_token and not show_token_vars:
352 return False
353 return True
355 with _stream_to_pager(context.parsed_args) as (fd, fo):
356 fo.print_list_table(
357 headers=(
358 "Variable (use via: `{{ NAME }}`)",
359 "Value",
360 "Flag",
361 "Provided by",
362 ),
363 rows=tuple(
364 (
365 k,
366 _resolve_variable_for_list(substitution, var),
367 _render_manifest_variable_flag(var),
368 var.plugin_metadata.plugin_name,
369 )
370 for k, var in sorted(variables.items())
371 if _include_var(var)
372 ),
373 )
375 fo.print()
377 filters = [
378 (
379 "Token variables",
380 show_token_vars if not show_all_vars else None,
381 "--show-token-variables",
382 ),
383 (
384 "Special use variables",
385 show_special_case_vars if not show_all_vars else None,
386 "--show-special-case-variables",
387 ),
388 ]
390 fo.print_list_table(
391 headers=("Variable type", "Value", "Option"),
392 rows=tuple(
393 (
394 fname,
395 _render_list_filter(value or show_all_vars),
396 f"{option} OR --show-all-variables",
397 )
398 for fname, value, option in filters
399 ),
400 )
403@plugin_cmd_list_manifest_variables.configure_handler
404def list_manifest_variable_arg_parser(
405 plugin_list_manifest_variables_parser: argparse.ArgumentParser,
406) -> None:
407 plugin_list_manifest_variables_parser.add_argument(
408 "--show-special-case-variables",
409 dest="show_special_use_variables",
410 default=False,
411 action="store_true",
412 help="Show variables that are only used in special / niche cases",
413 )
414 plugin_list_manifest_variables_parser.add_argument(
415 "--show-token-variables",
416 dest="show_token_variables",
417 default=False,
418 action="store_true",
419 help="Show token (syntactical) variables like {{token:TAB}}",
420 )
421 plugin_list_manifest_variables_parser.add_argument(
422 "--show-all-variables",
423 dest="show_all_variables",
424 default=False,
425 action="store_true",
426 help="Show all variables regardless of type/kind (overrules other filter settings)",
427 )
428 TEXT_ONLY_FORMAT(plugin_list_manifest_variables_parser)
431@plugin_list_cmds.register_subcommand(
432 ["pluggable-manifest-rules", "p-m-r", "pmr"],
433 help_description="Pluggable manifest rules (such as install rules)",
434 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE,
435)
436def _plugin_cmd_list_manifest_rules(context: CommandContext) -> None:
437 feature_set = context.load_plugins()
439 # Type hint to make the chain call easier for the type checker, which does not seem
440 # to derive to this common base type on its own.
441 base_type = Iterable[tuple[Union[str, type[Any]], DispatchingParserBase[Any]]]
443 parser_generator = feature_set.manifest_parser_generator
444 table_parsers: base_type = parser_generator.dispatchable_table_parsers.items()
445 object_parsers: base_type = parser_generator.dispatchable_object_parsers.items()
447 parsers = chain(
448 table_parsers,
449 object_parsers,
450 )
452 with _stream_to_pager(context.parsed_args) as (fd, fo):
453 fo.print_list_table(
454 headers=("Rule Name", "Rule Type", "Provided By"),
455 rows=tuple(
456 (
457 rn,
458 parser_type_name(rt),
459 pt.parser_for(rn).plugin_metadata.plugin_name,
460 )
461 for rt, pt in parsers
462 for rn in pt.registered_keywords()
463 ),
464 )
467@plugin_list_cmds.register_subcommand(
468 ["automatic-discard-rules", "a-d-r"],
469 help_description="List automatic discard rules",
470 argparser=TEXT_CSV_FORMAT_NO_STABILITY_PROMISE,
471)
472def _plugin_cmd_list_automatic_discard_rules(context: CommandContext) -> None:
473 auto_discard_rules = context.load_plugins().auto_discard_rules
475 with _stream_to_pager(context.parsed_args) as (fd, fo):
476 fo.print_list_table(
477 headers=("Name", "Provided By"),
478 rows=tuple(
479 (
480 name,
481 ppdr.plugin_metadata.plugin_name,
482 )
483 for name, ppdr in auto_discard_rules.items()
484 ),
485 )
488def _render_manifest_variable_value(v: str | None) -> str:
489 if v is None:
490 return "(N/A: Cannot resolve the variable)"
491 v = v.replace("\n", "\\n").replace("\t", "\\t")
492 return v
495@plugin_show_cmds.register_subcommand(
496 ["manifest-variables"],
497 help_description="Plugin provided manifest variables (such as `{{path:FOO}}`)",
498 argparser=add_arg(
499 "manifest_variable",
500 metavar="manifest-variable",
501 help="Name of the variable (such as `path:FOO` or `{{path:FOO}}`) to display details about",
502 ),
503)
504def _plugin_cmd_show_manifest_variables(context: CommandContext) -> None:
505 plugin_feature_set = context.load_plugins()
506 variables = plugin_feature_set.manifest_variables
507 substitution = context.substitution
508 parsed_args = context.parsed_args
509 variable_name = parsed_args.manifest_variable
510 fo = _output_styling(context.parsed_args, sys.stdout)
511 if variable_name.startswith("{{") and variable_name.endswith("}}"):
512 variable_name = variable_name[2:-2]
513 variable: PluginProvidedManifestVariable | None
514 if variable_name.startswith("env:") and len(variable_name) > 4:
515 env_var = variable_name[4:]
516 variable = PluginProvidedManifestVariable(
517 plugin_feature_set.plugin_data["debputy"],
518 variable_name,
519 variable_value=None,
520 is_context_specific_variable=False,
521 is_documentation_placeholder=True,
522 variable_reference_documentation=textwrap.dedent(
523 f"""\
524 Environment variable "{env_var}"
526 Note that uses beneath `builds:` may use the environment variable defined by
527 `build-environment:` (depends on whether the rule uses eager or lazy
528 substitution) while uses outside `builds:` will generally not use a definition
529 from `build-environment:`.
530 """
531 ),
532 )
533 else:
534 variable = variables.get(variable_name)
535 if variable is None:
536 _error(
537 f'Cannot resolve "{variable_name}" as a known variable from any of the available'
538 f" plugins. Please use `debputy plugin list manifest-variables` to list all known"
539 f" provided variables."
540 )
542 var_with_braces = "{{" + variable_name + "}}"
543 try:
544 source_value = substitution.substitute(var_with_braces, "CLI request")
545 except DebputySubstitutionError:
546 source_value = None
547 binary_value = source_value
548 print(f"Variable: {variable_name}")
549 fo.print_visual_formatting(f"=========={'=' * len(variable_name)}")
550 print()
552 if variable.is_context_specific_variable:
553 try:
554 binary_value = substitution.with_extra_substitutions(
555 PACKAGE="<package-name>",
556 ).substitute(var_with_braces, "CLI request")
557 except DebputySubstitutionError:
558 binary_value = None
560 doc = variable.variable_reference_documentation or "No documentation provided"
561 for line in render_multiline_documentation(doc):
562 print(line)
564 if source_value == binary_value:
565 print(f"Resolved: {_render_manifest_variable_value(source_value)}")
566 else:
567 print("Resolved:")
568 print(f" [source context]: {_render_manifest_variable_value(source_value)}")
569 print(f" [binary context]: {_render_manifest_variable_value(binary_value)}")
571 if variable.is_for_special_case:
572 print(
573 'Special-case: The variable has been marked as a "special-case"-only variable.'
574 )
576 if not variable.is_documentation_placeholder:
577 print(f"Plugin: {variable.plugin_metadata.plugin_name}")
579 if variable.is_internal:
580 print()
581 # I knew everything I felt was showing on my face, and I hate that. I grated out,
582 print("That was private.")
585def _determine_ppf(
586 context: CommandContext,
587) -> tuple[PackagerProvidedFileClassSpec, bool]:
588 feature_set = context.load_plugins()
589 ppf_name = context.parsed_args.ppf_name
590 try:
591 return feature_set.packager_provided_files[ppf_name], False
592 except KeyError:
593 pass
595 orig_ppf_name = ppf_name
596 if (
597 ppf_name.startswith("d/")
598 and not os.path.lexists(ppf_name)
599 and os.path.lexists("debian/" + ppf_name[2:])
600 ):
601 ppf_name = "debian/" + ppf_name[2:]
603 if ppf_name in ("debian/control", "debian/debputy.manifest", "debian/rules"):
604 if ppf_name == "debian/debputy.manifest":
605 doc = manifest_format_doc("")
606 else:
607 doc = "Debian Policy Manual or a packaging tutorial"
608 _error(
609 f"Sorry. While {orig_ppf_name} is a well-defined packaging file, it does not match the definition of"
610 f" a packager provided file. Please see {doc} for more information about this file"
611 )
613 if context.has_dctrl_file and os.path.lexists(ppf_name):
614 basename = ppf_name[7:]
615 if "/" not in basename:
616 debian_dir = build_virtual_fs([basename])
617 all_ppfs = detect_all_packager_provided_files(
618 feature_set,
619 debian_dir,
620 context.binary_packages(),
621 )
622 if all_ppfs:
623 matched = next(iter(all_ppfs.values()))
624 if len(matched.auto_installable) == 1 and not matched.reserved_only:
625 return matched.auto_installable[0].definition, True
626 if not matched.auto_installable and len(matched.reserved_only) == 1:
627 reserved = next(iter(matched.reserved_only.values()))
628 if len(reserved) == 1:
629 return reserved[0].definition, True
631 _error(
632 f'Unknown packager provided file "{orig_ppf_name}". Please use'
633 f" `debputy plugin list packager-provided-files` to see them all."
634 )
637@plugin_show_cmds.register_subcommand(
638 ["packager-provided-files", "ppf", "p-p-f"],
639 help_description="Show details about a given packager provided file (debian/pkg.foo)",
640 argparser=add_arg(
641 "ppf_name",
642 metavar="name",
643 help="Name of the packager provided file (such as `changelog`) to display details about",
644 ),
645)
646def _plugin_cmd_show_ppf(context: CommandContext) -> None:
647 ppf, matched_file = _determine_ppf(context)
649 fo = _output_styling(context.parsed_args, sys.stdout)
651 fo.print(f"Packager Provided File: {ppf.stem}")
652 fo.print_visual_formatting(f"========================{'=' * len(ppf.stem)}")
653 fo.print()
654 ref_doc = ppf.reference_documentation
655 description = ref_doc.description if ref_doc else None
656 doc_uris = ref_doc.format_documentation_uris if ref_doc else tuple()
657 if description is None:
658 fo.print(
659 f"Sorry, no description provided by the plugin {ppf.debputy_plugin_metadata.plugin_name}."
660 )
661 else:
662 for line in description.splitlines(keepends=False):
663 fo.print(line)
665 fo.print()
666 fo.print("Features:")
667 if ppf.packageless_is_fallback_for_all_packages:
668 fo.print(f" * debian/{ppf.stem} is used for *ALL* packages")
669 else:
670 fo.print(f' * debian/{ppf.stem} is used for only for the "main" package')
671 if ppf.allow_name_segment:
672 fo.print(" * Supports naming segment (multiple files and custom naming).")
673 else:
674 fo.print(
675 " * No naming support; at most one per package and it is named after the package."
676 )
677 if ppf.allow_architecture_segment:
678 fo.print(" * Supports architecture specific variants.")
679 else:
680 fo.print(" * No architecture specific variants.")
681 if ppf.supports_priority:
682 fo.print(
683 f" * Has a priority system (default priority: {ppf.default_priority})."
684 )
686 fo.print()
687 fo.print("Examples matches:")
689 if context.has_dctrl_file:
690 first_pkg = next(iter(context.binary_packages()))
691 else:
692 first_pkg = "example-package"
693 example_files = [
694 (f"debian/{ppf.stem}", first_pkg),
695 (f"debian/{first_pkg}.{ppf.stem}", first_pkg),
696 ]
697 if ppf.allow_name_segment:
698 example_files.append(
699 (f"debian/{first_pkg}.my.custom.name.{ppf.stem}", "my.custom.name")
700 )
701 if ppf.allow_architecture_segment:
702 example_files.append((f"debian/{first_pkg}.{ppf.stem}.amd64", first_pkg))
703 if ppf.allow_name_segment:
704 example_files.append(
705 (
706 f"debian/{first_pkg}.my.custom.name.{ppf.stem}.amd64",
707 "my.custom.name",
708 )
709 )
710 fs_root = build_virtual_fs([x for x, _ in example_files])
711 priority = ppf.default_priority if ppf.supports_priority else None
712 rendered_examples = []
713 for example_file, assigned_name in example_files:
714 example_path = fs_root.lookup(example_file)
715 assert example_path is not None and example_path.is_file
716 dest = ppf.compute_dest(
717 assigned_name,
718 owning_package=first_pkg,
719 assigned_priority=priority,
720 path=example_path,
721 )
722 dest_path = "/".join(dest).lstrip(".")
723 rendered_examples.append((example_file, dest_path))
725 fo.print_list_table(
726 headers=("Source file", "Installed As"),
727 rows=rendered_examples,
728 )
730 if doc_uris:
731 fo.print()
732 fo.print("Documentation URIs:")
733 for uri in doc_uris:
734 fo.print(f" * {fo.render_url(uri)}")
736 plugin_name = ppf.debputy_plugin_metadata.plugin_name
737 fo.print()
738 fo.print(f"Install Mode: 0{oct(ppf.default_mode)[2:]}")
739 fo.print(f"Provided by plugin: {plugin_name}")
740 if (
741 matched_file
742 and plugin_name != "debputy"
743 and plugin_name not in context.requested_plugins()
744 ):
745 fo.print()
746 _warn(
747 f"The file might *NOT* be used due to missing Build-Depends on debputy-plugin-{plugin_name}"
748 )
751class UnresolvableRuleError(ValueError):
752 pass
755@dataclasses.dataclass
756class PMRRuleLookup:
757 rule_name: str
758 parser: DeclarativeInputParser
759 parser_type_name: str
760 plugin_metadata: DebputyPluginMetadata
761 is_root_rule: bool
762 manifest_attribute_path: str
765def lookup_pmr_rule(
766 feature_set: PluginProvidedFeatureSet,
767 pmr_rule_name: str,
768) -> PMRRuleLookup:
769 req_rule_type = None
770 rule_name = pmr_rule_name
771 if "::" in rule_name and rule_name != "::":
772 req_rule_type, rule_name = rule_name.split("::", 1)
774 matched = []
776 base_type = Iterable[tuple[Union[str, type[Any]], DispatchingParserBase[Any]]]
777 parser_generator = feature_set.manifest_parser_generator
778 table_parsers: base_type = parser_generator.dispatchable_table_parsers.items()
779 object_parsers: base_type = parser_generator.dispatchable_object_parsers.items()
781 parsers = chain(
782 table_parsers,
783 object_parsers,
784 )
786 for rule_type, dispatching_parser in parsers:
787 if req_rule_type is not None and req_rule_type not in parser_type_name(
788 rule_type
789 ):
790 continue
791 if dispatching_parser.is_known_keyword(rule_name):
792 matched.append((rule_type, dispatching_parser))
794 if len(matched) != 1 and (matched or rule_name != "::"):
795 if not matched:
796 raise UnresolvableRuleError(
797 f"Could not find any pluggable manifest rule related to {pmr_rule_name}."
798 f" Please use `debputy plugin list pluggable-manifest-rules` to see the list of rules."
799 )
800 match_a = matched[0][0]
801 match_b = matched[1][0]
802 raise UnresolvableRuleError(
803 f"The name {rule_name} was ambiguous and matched multiple rule types. Please use"
804 f" <rule-type>::{rule_name} to clarify which rule to use"
805 f" (such as {parser_type_name(match_a)}::{rule_name} or {parser_type_name(match_b)}::{rule_name})."
806 f" Please use `debputy plugin list pluggable-manifest-rules` to see the list of rules."
807 )
809 if matched:
810 rule_type, matched_dispatching_parser = matched[0]
811 plugin_provided_parser = matched_dispatching_parser.parser_for(rule_name)
812 if isinstance(rule_type, str):
813 manifest_attribute_path = rule_type
814 else:
815 manifest_attribute_path = SUPPORTED_DISPATCHABLE_TABLE_PARSERS[rule_type]
816 full_parser_type_name = parser_type_name(rule_type)
817 parser = plugin_provided_parser.parser
818 plugin_metadata = plugin_provided_parser.plugin_metadata
819 else:
820 rule_name = "::"
821 parser = parser_generator.dispatchable_object_parsers[OPARSER_MANIFEST_ROOT]
822 full_parser_type_name = ""
823 plugin_metadata = plugin_metadata_for_debputys_own_plugin()
824 manifest_attribute_path = ""
826 is_root_rule = rule_name == "::"
827 return PMRRuleLookup(
828 rule_name,
829 parser,
830 full_parser_type_name,
831 plugin_metadata,
832 is_root_rule,
833 manifest_attribute_path,
834 )
837@plugin_show_cmds.register_subcommand(
838 ["pluggable-manifest-rules", "p-m-r", "pmr"],
839 help_description="Pluggable manifest rules (such as install rules)",
840 argparser=add_arg(
841 "pmr_rule_name",
842 metavar="rule-name",
843 help="Name of the rule (such as `install`) to display details about",
844 ),
845)
846def _plugin_cmd_show_manifest_rule(context: CommandContext) -> None:
847 feature_set = context.load_plugins()
848 parsed_args = context.parsed_args
849 fo = _output_styling(parsed_args, sys.stdout)
851 try:
852 pmr_rule = lookup_pmr_rule(feature_set, parsed_args.pmr_rule_name)
853 except UnresolvableRuleError as e:
854 _error(e.args[0])
856 print(
857 render_rule(
858 pmr_rule.rule_name,
859 pmr_rule.parser,
860 pmr_rule.plugin_metadata,
861 fo,
862 is_root_rule=pmr_rule.is_root_rule,
863 )
864 )
866 if not pmr_rule.is_root_rule:
867 manifest_attribute_path = pmr_rule.manifest_attribute_path
868 print(
869 f"Used in: {manifest_attribute_path if manifest_attribute_path != '<ROOT>' else 'The manifest root'}"
870 )
871 print(f"Rule reference: {pmr_rule.parser_type_name}::{pmr_rule.rule_name}")
872 print(f"Plugin: {pmr_rule.plugin_metadata.plugin_name}")
873 else:
874 print(f"Rule reference: {pmr_rule.rule_name}")
876 print()
877 print(
878 "PS: If you want to know more about a non-trivial type of an attribute such as `FileSystemMatchRule`,"
879 )
880 print(
881 "you can use `debputy plugin show type-mappings FileSystemMatchRule` to look it up "
882 )
885def _render_discard_rule_example(
886 fo: IOBasedOutputStyling,
887 discard_rule: PluginProvidedDiscardRule,
888 example: AutomaticDiscardRuleExample,
889) -> None:
890 processed = process_discard_rule_example(discard_rule, example)
892 if processed.inconsistent_paths:
893 plugin_name = discard_rule.plugin_metadata.plugin_name
894 _warn(
895 f"This example is inconsistent with what the code actually does."
896 f" Please consider filing a bug against the plugin {plugin_name}"
897 )
899 doc = example.description
900 if doc:
901 print(doc)
903 print("Consider the following source paths matched by a glob or directory match:")
904 print()
905 if fo.optimize_for_screen_reader:
906 for p, _ in processed.rendered_paths:
907 path_name = p.absolute
908 print(
909 f"The path {path_name} is a {'directory' if p.is_dir else 'file or symlink.'}"
910 )
912 print()
913 if any(v.is_consistent and v.is_discarded for _, v in processed.rendered_paths):
914 print("The following paths will be discarded by this rule:")
915 for p, verdict in processed.rendered_paths:
916 path_name = p.absolute
917 if verdict.is_consistent and verdict.is_discarded:
918 print()
919 if p.is_dir:
920 print(f"{path_name} along with anything beneath it")
921 else:
922 print(path_name)
923 else:
924 print("No paths will be discarded in this example.")
926 print()
927 if any(v.is_consistent and v.is_kept for _, v in processed.rendered_paths):
928 print("The following paths will be not be discarded by this rule:")
929 for p, verdict in processed.rendered_paths:
930 path_name = p.absolute
931 if verdict.is_consistent and verdict.is_kept:
932 print()
933 print(path_name)
935 if any(not v.is_consistent for _, v in processed.rendered_paths):
936 print()
937 print(
938 "The example was inconsistent with the code. These are the paths where the code disagrees with"
939 " the provided example:"
940 )
941 for p, verdict in processed.rendered_paths:
942 path_name = p.absolute
943 if not verdict.is_consistent:
944 print()
945 if verdict == DiscardVerdict.DISCARDED_BY_CODE:
946 print(
947 f"The path {path_name} was discarded by the code, but the example said it should"
948 f" have been installed."
949 )
950 else:
951 print(
952 f"The path {path_name} was not discarded by the code, but the example said it should"
953 f" have been discarded."
954 )
955 return
957 # Add +1 for dirs because we want trailing slashes in the output
958 max_len = max(
959 (len(p.absolute) + (1 if p.is_dir else 0)) for p, _ in processed.rendered_paths
960 )
961 for p, verdict in processed.rendered_paths:
962 path_name = p.absolute
963 if p.is_dir:
964 path_name += "/"
966 if not verdict.is_consistent:
967 print(f" {path_name:<{max_len}} !! {verdict.message}")
968 elif verdict.is_discarded:
969 print(f" {path_name:<{max_len}} << {verdict.message}")
970 else:
971 print(f" {path_name:<{max_len}}")
974def _render_discard_rule(
975 context: CommandContext,
976 discard_rule: PluginProvidedDiscardRule,
977) -> None:
978 fo = _output_styling(context.parsed_args, sys.stdout)
979 print(fo.colored(f"Automatic Discard Rule: {discard_rule.name}", style="bold"))
980 fo.print_visual_formatting(
981 f"========================{'=' * len(discard_rule.name)}"
982 )
983 print()
984 doc = discard_rule.reference_documentation or "No documentation provided"
985 for line in render_multiline_documentation(
986 doc, first_line_prefix="", following_line_prefix=""
987 ):
988 print(line)
990 if len(discard_rule.examples) > 1:
991 print()
992 fo.print_visual_formatting("Examples")
993 fo.print_visual_formatting("--------")
994 print()
995 for no, example in enumerate(discard_rule.examples, start=1):
996 print(
997 fo.colored(
998 f"Example {no} of {len(discard_rule.examples)}", style="bold"
999 )
1000 )
1001 fo.print_visual_formatting(f"........{'.' * len(str(no))}")
1002 _render_discard_rule_example(fo, discard_rule, example)
1003 elif discard_rule.examples:
1004 print()
1005 print(fo.colored("Example", style="bold"))
1006 fo.print_visual_formatting("-------")
1007 print()
1008 _render_discard_rule_example(fo, discard_rule, discard_rule.examples[0])
1011@plugin_show_cmds.register_subcommand(
1012 ["automatic-discard-rules", "a-d-r"],
1013 help_description="Automatic discard rules",
1014 argparser=add_arg(
1015 "discard_rule",
1016 metavar="automatic-discard-rule",
1017 help="Name of the automatic discard rule (such as `backup-files`)",
1018 ),
1019)
1020def _plugin_cmd_show_automatic_discard_rules(context: CommandContext) -> None:
1021 auto_discard_rules = context.load_plugins().auto_discard_rules
1022 name = context.parsed_args.discard_rule
1023 discard_rule = auto_discard_rules.get(name)
1024 if discard_rule is None:
1025 _error(
1026 f'No automatic discard rule with the name "{name}". Please use'
1027 f" `debputy plugin list automatic-discard-rules` to see the list of automatic discard rules"
1028 )
1030 _render_discard_rule(context, discard_rule)
1033@plugin_list_cmds.register_subcommand(
1034 "type-mappings",
1035 help_description="Registered type mappings/descriptions",
1036)
1037def _plugin_cmd_list_type_mappings(context: CommandContext) -> None:
1038 type_mappings = context.load_plugins().mapped_types
1040 with _stream_to_pager(context.parsed_args) as (fd, fo):
1041 fo.print_list_table(
1042 headers=("Type", "Base Type", "Provided By"),
1043 rows=tuple(
1044 (
1045 target_type.__name__,
1046 render_source_type(type_mapping.mapped_type.source_type),
1047 type_mapping.plugin_metadata.plugin_name,
1048 )
1049 for target_type, type_mapping in type_mappings.items()
1050 ),
1051 )
1054@plugin_show_cmds.register_subcommand(
1055 "type-mappings",
1056 help_description="Registered type mappings/descriptions",
1057 argparser=add_arg(
1058 "type_mapping",
1059 metavar="type-mapping",
1060 help="Name of the type",
1061 ),
1062)
1063def _plugin_cmd_show_type_mappings(context: CommandContext) -> None:
1064 type_mapping_name = context.parsed_args.type_mapping
1065 type_mappings = context.load_plugins().mapped_types
1066 fo = _output_styling(context.parsed_args, sys.stdout)
1068 matches = []
1069 for type_ in type_mappings:
1070 if type_.__name__ == type_mapping_name:
1071 matches.append(type_)
1073 if not matches:
1074 simple_types = set(BASIC_SIMPLE_TYPES.values())
1075 simple_types.update(t.__name__ for t in BASIC_SIMPLE_TYPES)
1077 if type_mapping_name in simple_types:
1078 print(f"The type {type_mapping_name} is a YAML scalar.")
1079 return
1080 if type_mapping_name == "Any":
1081 print(
1082 "The Any type is a placeholder for when no typing information is provided. Often this implies"
1083 " custom parse logic."
1084 )
1085 return
1087 if type_mapping_name in ("List", "list"):
1088 print(
1089 f"The {type_mapping_name} is a YAML Sequence. Please see the YAML documentation for examples."
1090 )
1091 return
1093 if type_mapping_name in ("Mapping", "dict"):
1094 print(
1095 f"The {type_mapping_name} is a YAML mapping. Please see the YAML documentation for examples."
1096 )
1097 return
1099 if "[" in type_mapping_name:
1100 _error(
1101 f"No known matches for {type_mapping_name}. Note: It looks like a composite type. Try searching"
1102 " for its component parts. As an example, replace List[FileSystemMatchRule] with FileSystemMatchRule."
1103 )
1105 _error(f"Sorry, no known matches for {type_mapping_name}")
1107 if len(matches) > 1:
1108 _error(
1109 f"Too many matches for {type_mapping_name}... Sorry, there is no way to avoid this right now :'("
1110 )
1112 match = matches[0]
1113 manifest_parser = context.manifest_parser()
1115 pptm = type_mappings[match]
1116 fo.print(
1117 render_type_mapping(
1118 pptm,
1119 fo,
1120 manifest_parser,
1121 recover_from_broken_examples=context.parsed_args.debug_mode,
1122 )
1123 )
1125 fo.print()
1126 fo.print(f"Provided by plugin: {pptm.plugin_metadata.plugin_name}")
1129def ensure_plugin_commands_are_loaded() -> None:
1130 # Loading the module does the heavy lifting
1131 # However, having this function means that we do not have an "unused" import that some tool
1132 # gets tempted to remove
1133 assert ROOT_COMMAND.has_command("plugin")