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