diff --git a/pants-plugins/internal_plugins/rules_for_testing/register.py b/pants-plugins/internal_plugins/rules_for_testing/register.py index 5d38f04818d..3daa0fe6c3a 100644 --- a/pants-plugins/internal_plugins/rules_for_testing/register.py +++ b/pants-plugins/internal_plugins/rules_for_testing/register.py @@ -11,6 +11,7 @@ class ListAndDieForTestingSubsystem(GoalSubsystem): """A fast and deadly variant of `./pants list`.""" name = "list-and-die-for-testing" + help = "A fast and deadly variant of `./pants list`." class ListAndDieForTesting(Goal): diff --git a/src/python/pants/build_graph/build_configuration.py b/src/python/pants/build_graph/build_configuration.py index f6a77667866..9b014f4dd1e 100644 --- a/src/python/pants/build_graph/build_configuration.py +++ b/src/python/pants/build_graph/build_configuration.py @@ -28,7 +28,7 @@ # No goal or target_type can have a name from this set, so that `./pants help ` # is unambiguous. -_RESERVED_NAMES = {"global", "goals", "targets", "tools"} +_RESERVED_NAMES = {"api-types", "global", "goals", "targets", "tools"} # Subsystems used outside of any rule. diff --git a/src/python/pants/help/help_info_extracter.py b/src/python/pants/help/help_info_extracter.py index 90bd15cd3f4..cf3f473b054 100644 --- a/src/python/pants/help/help_info_extracter.py +++ b/src/python/pants/help/help_info_extracter.py @@ -8,11 +8,13 @@ import json from dataclasses import dataclass from enum import Enum +from itertools import groupby from typing import Any, Callable, Tuple, Type, Union, cast, get_type_hints from pants.base import deprecated from pants.build_graph.build_configuration import BuildConfiguration from pants.engine.goal import GoalSubsystem +from pants.engine.rules import TaskRule from pants.engine.target import Field, RegisteredTargetTypes, StringField, Target from pants.engine.unions import UnionMembership, UnionRule from pants.option.option_util import is_dict_option, is_list_option @@ -228,6 +230,18 @@ def create( ) +@dataclass(frozen=True) +class RuleInfo: + name: str + description: str | None + help: str | None + provider: str + input_types: tuple[str, ...] + input_gets: tuple[str, ...] + output_type: str + output_desc: str | None + + @dataclass(frozen=True) class AllHelpInfo: """All available help info.""" @@ -235,6 +249,7 @@ class AllHelpInfo: scope_to_help_info: dict[str, OptionScopeHelpInfo] name_to_goal_info: dict[str, GoalHelpInfo] name_to_target_type_info: dict[str, TargetTypeHelpInfo] + rule_output_type_to_rule_infos: dict[str, tuple[RuleInfo, ...]] ConsumedScopesMapper = Callable[[str], Tuple[str, ...]] @@ -323,6 +338,7 @@ def get_all_help_info( scope_to_help_info=scope_to_help_info, name_to_goal_info=name_to_goal_info, name_to_target_type_info=name_to_target_type_info, + rule_output_type_to_rule_infos=cls.get_rule_infos(build_configuration), ) @staticmethod @@ -397,6 +413,42 @@ def get_first_provider(providers: tuple[str, ...] | None) -> str: return "" return providers[0] + @staticmethod + def maybe_cleandoc(doc: str | None) -> str | None: + return doc and inspect.cleandoc(doc) + + @staticmethod + def rule_info_output_type(rule_info: RuleInfo) -> str: + return rule_info.output_type + + @classmethod + def get_rule_infos( + cls, build_configuration: BuildConfiguration | None + ) -> dict[str, tuple[RuleInfo, ...]]: + if build_configuration is None: + return {} + + rule_infos = [ + RuleInfo( + name=rule.canonical_name, + description=rule.desc, + help=cls.maybe_cleandoc(rule.func.__doc__), + provider=cls.get_first_provider(providers), + input_types=tuple(selector.__name__ for selector in rule.input_selectors), + input_gets=tuple(str(constraints) for constraints in rule.input_gets), + output_type=rule.output_type.__name__, + output_desc=cls.maybe_cleandoc(rule.output_type.__doc__), + ) + for rule, providers in build_configuration.rule_to_providers.items() + if isinstance(rule, TaskRule) + ] + return { + rule_output_type: tuple(infos) + for rule_output_type, infos in groupby( + sorted(rule_infos, key=cls.rule_info_output_type), key=cls.rule_info_output_type + ) + } + def __init__(self, scope: str): self._scope = scope self._scope_prefix = scope.replace(".", "-") diff --git a/src/python/pants/help/help_info_extracter_test.py b/src/python/pants/help/help_info_extracter_test.py index e97a8471615..c6b11508ab9 100644 --- a/src/python/pants/help/help_info_extracter_test.py +++ b/src/python/pants/help/help_info_extracter_test.py @@ -7,6 +7,7 @@ from pants.build_graph.build_configuration import BuildConfiguration from pants.engine.goal import GoalSubsystem +from pants.engine.rules import collect_rules, rule from pants.engine.target import IntField, RegisteredTargetTypes, StringField, Target from pants.engine.unions import UnionMembership from pants.help.help_info_extracter import HelpInfoExtracter, pretty_print_type_hint, to_help_str @@ -272,12 +273,17 @@ class BazLibrary(Target): Foo.register_options_on_scope(options) Bar.register_options_on_scope(options) + @rule + def rule_info_test(foo: Foo) -> Target: + """This rule is for testing info extraction only.""" + def fake_consumed_scopes_mapper(scope: str) -> Tuple[str, ...]: return ("somescope", f"used_by_{scope or 'GLOBAL_SCOPE'}") bc_builder = BuildConfiguration.Builder() bc_builder.register_subsystems("help_info_extracter_test", (Foo, Bar)) bc_builder.register_target_types("help_info_extracter_test", (BazLibrary,)) + bc_builder.register_rules("help_info_extracter_test", collect_rules(locals())) all_help_info = HelpInfoExtracter.get_all_help_info( options, @@ -386,6 +392,38 @@ def fake_consumed_scopes_mapper(scope: str) -> Tuple[str, ...]: "deprecated": tuple(), }, }, + "rule_output_type_to_rule_infos": { + "Foo": ( + { + "description": None, + "help": "A foo.", + "input_gets": ("Get(ScopedOptions, Scope, ..)",), + "input_types": (), + "name": "construct_scope_foo", + "output_desc": None, + "output_type": "Foo", + "provider": "help_info_extracter_test", + }, + ), + "Target": ( + { + "description": None, + "help": "This rule is for testing info extraction only.", + "input_gets": (), + "input_types": ("Foo",), + "name": "pants.help.help_info_extracter_test.rule_info_test", + "output_desc": ( + "A Target represents an addressable set of metadata.\n\n Set the " + "`help` class property with a description, which will be used in " + "`./pants help`. For the\n best rendering, use soft wrapping (e.g. " + "implicit string concatenation) within paragraphs, but\n hard wrapping " + "(`\n`) to separate distinct paragraphs and/or lists.\n " + ), + "output_type": "Target", + "provider": "help_info_extracter_test", + }, + ), + }, "name_to_goal_info": { "bar": { "name": "bar", diff --git a/src/python/pants/help/help_printer.py b/src/python/pants/help/help_printer.py index cea61bd5eef..08a4ed349e8 100644 --- a/src/python/pants/help/help_printer.py +++ b/src/python/pants/help/help_printer.py @@ -24,7 +24,7 @@ ) from pants.option.scope import GLOBAL_SCOPE from pants.util.docutil import terminal_width -from pants.util.strutil import first_paragraph, hard_wrap +from pants.util.strutil import first_paragraph, hard_wrap, pluralize class HelpPrinter(MaybeColor): @@ -110,10 +110,14 @@ def _print_thing_help(self) -> None: self._print_options_help(GLOBAL_SCOPE, help_request.advanced) elif thing == "tools": self._print_all_tools() + elif thing == "api-types": + self._print_all_api_types() elif thing in self._all_help_info.scope_to_help_info: self._print_options_help(thing, help_request.advanced) elif thing in self._all_help_info.name_to_target_type_info: self._print_target_help(thing) + elif thing in self._all_help_info.rule_output_type_to_rule_infos: + self._print_api_type_help(thing, help_request.advanced) else: print(self.maybe_red(f"Unknown entity: {thing}")) else: @@ -198,6 +202,22 @@ def _print_all_tools(self) -> None: tool_help_cmd = f"{self._bin_name} help $tool" print(f"Use `{self.maybe_green(tool_help_cmd)}` to get help for a specific tool.\n") + def _print_all_api_types(self) -> None: + self._print_title("Plugin API Types") + api_type_descriptions: Dict[str, str] = {} + for api_type, rule_infos in self._all_help_info.rule_output_type_to_rule_infos.items(): + if api_type.startswith("_"): + continue + api_type_descriptions[api_type] = rule_infos[0].output_desc or "" + longest_api_type_name = max(len(name) for name in api_type_descriptions.keys()) + chars_before_description = longest_api_type_name + 2 + for api_type, description in api_type_descriptions.items(): + name = self.maybe_cyan(api_type.ljust(chars_before_description)) + description = self._format_summary_description(description, chars_before_description) + print(f"{name}{description}\n") + api_help_cmd = f"{self._bin_name} help $api_type" + print(f"Use `{self.maybe_green(api_help_cmd)}` to get help for a specific API type.\n") + def _print_global_help(self): def print_cmd(args: str, desc: str): cmd = self.maybe_green(f"{self._bin_name} {args}".ljust(50)) @@ -292,6 +312,42 @@ def _print_target_help(self, target_alias: str) -> None: print("\n" + formatted_desc) print() + def _print_api_type_help(self, output_type: str, show_advanced: bool) -> None: + self._print_title(f"`{output_type}` API type") + rule_infos = self._all_help_info.rule_output_type_to_rule_infos[output_type] + if rule_infos[0].output_desc: + print("\n".join(hard_wrap(rule_infos[0].output_desc, width=self._width))) + print() + print(f"Returned by {pluralize(len(rule_infos), 'rule')}:") + for rule_info in rule_infos: + print() + print(self.maybe_magenta(rule_info.name)) + indent = " " + print(self.maybe_cyan(f"{indent}activated by"), rule_info.provider) + if rule_info.input_types: + print( + self.maybe_cyan(f"{indent}{pluralize(len(rule_info.input_types), 'input')}:"), + ", ".join(rule_info.input_types), + ) + else: + print(self.maybe_cyan(f"{indent}no inputs")) + if show_advanced and rule_info.input_gets: + print( + f"\n{indent}".join( + hard_wrap( + self.maybe_cyan(f"{pluralize(len(rule_info.input_gets), 'get')}: ") + + ", ".join(rule_info.input_gets), + indent=4, + width=self._width - 4, + ) + ) + ) + if rule_info.description: + print(f"{indent}{rule_info.description}") + if rule_info.help: + print("\n" + "\n".join(hard_wrap(rule_info.help, indent=4, width=self._width))) + print() + def _get_help_json(self) -> str: """Return a JSON object containing all the help info we have, for every scope.""" return json.dumps( diff --git a/src/python/pants/jvm/resolve/jvm_tool_test.py b/src/python/pants/jvm/resolve/jvm_tool_test.py index 9a1d44e3b42..0969d9184c3 100644 --- a/src/python/pants/jvm/resolve/jvm_tool_test.py +++ b/src/python/pants/jvm/resolve/jvm_tool_test.py @@ -23,6 +23,7 @@ class MockJvmTool(JvmToolBase): options_scope = "mock-tool" + help = "Hamcrest is a mocking tool for the JVM." default_version = "1.3" default_artifacts = ("org.hamcrest:hamcrest-core:{version}",) diff --git a/src/python/pants/option/subsystem.py b/src/python/pants/option/subsystem.py index 997e7a6c52c..aa4172112e0 100644 --- a/src/python/pants/option/subsystem.py +++ b/src/python/pants/option/subsystem.py @@ -53,6 +53,7 @@ def signature(cls): name = f"construct_scope_{snake_scope}" partial_construct_subsystem.__name__ = name partial_construct_subsystem.__module__ = cls.__module__ + partial_construct_subsystem.__doc__ = cls.help _, class_definition_lineno = inspect.getsourcelines(cls) partial_construct_subsystem.__line_number__ = class_definition_lineno