Repository: pantsbuild/pants

Issue:
[wip] Refactor `RuleInfo` in help_info_extracter.py
Prework for https://github.com/pantsbuild/pants/pull/14227.

[ci skip-rust]
[ci skip-build-wheels]


Discussion:

Resolving patch:
diff --git a/src/python/pants/backend/docker/goals/package_image.py b/src/python/pants/backend/docker/goals/package_image.py
--- a/src/python/pants/backend/docker/goals/package_image.py
+++ b/src/python/pants/backend/docker/goals/package_image.py
@@ -225,6 +225,7 @@ async def build_docker_image(
     docker: DockerBinary,
     process_cleanup: ProcessCleanupOption,
 ) -> BuiltPackage:
+    """Build a Docker image using `docker build`."""
     context, wrapped_target = await MultiGet(
         Get(
             DockerBuildContext,
diff --git a/src/python/pants/help/help_info_extracter.py b/src/python/pants/help/help_info_extracter.py
--- a/src/python/pants/help/help_info_extracter.py
+++ b/src/python/pants/help/help_info_extracter.py
@@ -8,8 +8,7 @@
 import json
 from dataclasses import dataclass
 from enum import Enum
-from itertools import groupby
-from typing import Any, Callable, Iterable, Optional, Tuple, Type, Union, cast, get_type_hints
+from typing import Any, Callable, Optional, Tuple, Type, Union, cast, get_type_hints
 
 from pants.base import deprecated
 from pants.build_graph.build_configuration import BuildConfiguration
@@ -249,16 +248,37 @@ def create(
         )
 
 
+def maybe_cleandoc(doc: str | None) -> str | None:
+    return doc and inspect.cleandoc(doc)
+
+
 @dataclass(frozen=True)
 class RuleInfo:
+    """A container for help information for a rule.
+
+    The `description` is the `desc` provided to the `@rule` decorator, and `documentation` is the
+    rule's doc string.
+    """
+
     name: str
     description: str | None
-    help: str | None
+    documentation: str | None
     provider: str
+    output_type: str
     input_types: tuple[str, ...]
     input_gets: tuple[str, ...]
-    output_type: str
-    output_desc: str | None
+
+    @classmethod
+    def create(cls, rule: TaskRule, provider: str) -> RuleInfo:
+        return cls(
+            name=rule.canonical_name,
+            description=rule.desc,
+            documentation=maybe_cleandoc(rule.func.__doc__),
+            provider=provider,
+            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__,
+        )
 
 
 @dataclass(frozen=True)
@@ -268,7 +288,7 @@ class AllHelpInfo:
     scope_to_help_info: LazyFrozenDict[str, OptionScopeHelpInfo]
     name_to_goal_info: LazyFrozenDict[str, GoalHelpInfo]
     name_to_target_type_info: LazyFrozenDict[str, TargetTypeHelpInfo]
-    rule_output_type_to_rule_infos: LazyFrozenDict[str, tuple[RuleInfo, ...]]
+    name_to_rule_info: LazyFrozenDict[str, RuleInfo]
 
     def non_deprecated_option_scope_help_infos(self):
         for oshi in self.scope_to_help_info.values():
@@ -277,14 +297,7 @@ def non_deprecated_option_scope_help_infos(self):
 
     def asdict(self) -> dict[str, Any]:
         return {
-            field: {
-                thing: (
-                    tuple(dataclasses.asdict(t) for t in info)
-                    if isinstance(info, tuple)
-                    else dataclasses.asdict(info)
-                )
-                for thing, info in value.items()
-            }
+            field: {thing: dataclasses.asdict(info) for thing, info in value.items()}
             for field, value in dataclasses.asdict(self).items()
         }
 
@@ -408,7 +421,7 @@ def load() -> TargetTypeHelpInfo:
             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),
+            name_to_rule_info=cls.get_rule_infos(build_configuration),
         )
 
     @staticmethod
@@ -488,48 +501,24 @@ def get_provider(providers: tuple[str, ...] | None) -> str:
     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
-    ) -> LazyFrozenDict[str, tuple[RuleInfo, ...]]:
+    ) -> LazyFrozenDict[str, RuleInfo]:
         if build_configuration is None:
             return LazyFrozenDict({})
 
-        rule_infos = [
-            RuleInfo(
-                name=rule.canonical_name,
-                description=rule.desc,
-                help=cls.maybe_cleandoc(rule.func.__doc__),
-                provider=cls.get_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)
-        ]
-
-        def _capture(t: Iterable[RuleInfo]) -> Callable[[], tuple[RuleInfo, ...]]:
-            # The `t` value from `groupby` is only live for the duration of the current for-loop
-            # iteration, so we capture the data here now.
-            data = tuple(t)
-
-            def load() -> tuple[RuleInfo, ...]:
-                return data
+        def rule_info_loader(rule: TaskRule, provider: str) -> Callable[[], RuleInfo]:
+            def load() -> RuleInfo:
+                return RuleInfo.create(rule, provider)
 
             return load
 
         return LazyFrozenDict(
             {
-                rule_output_type: _capture(infos)
-                for rule_output_type, infos in groupby(
-                    sorted(rule_infos, key=cls.rule_info_output_type), key=cls.rule_info_output_type
-                )
+                rule.canonical_name: rule_info_loader(rule, cls.get_provider(providers))
+                for rule, providers in build_configuration.rule_to_providers.items()
+                if isinstance(rule, TaskRule)
             }
         )
 
diff --git a/src/python/pants/help/help_printer.py b/src/python/pants/help/help_printer.py
--- a/src/python/pants/help/help_printer.py
+++ b/src/python/pants/help/help_printer.py
@@ -52,8 +52,10 @@ def print_hint() -> None:
 
         if isinstance(self._help_request, VersionHelp):
             print(pants_version())
+            return 0
         elif isinstance(self._help_request, AllHelp):
             self._print_all_help()
+            return 0
         elif isinstance(self._help_request, ThingHelp):
             return self._print_thing_help()
         elif isinstance(self._help_request, UnknownGoalHelp):
@@ -68,7 +70,9 @@ def print_hint() -> None:
             print("No goals specified.")
             print_hint()
             return 1
-        return 0
+        else:
+            # Unexpected.
+            return 1
 
     def _print_alternatives(self, match: str, all_things: Iterable[str]) -> None:
         did_you_mean = list(difflib.get_close_matches(match, all_things))
@@ -108,9 +112,7 @@ def _help_table(
             **_help_table(
                 self._all_help_info.name_to_target_type_info.keys(), self._print_target_help
             ),
-            **_help_table(
-                self._all_help_info.rule_output_type_to_rule_infos.keys(), self._print_api_type_help
-            ),
+            **_help_table(self._all_help_info.name_to_rule_info.keys(), self._print_rule_help),
         }
 
     def _disambiguate_things(
@@ -282,19 +284,7 @@ def _print_all_tools(self) -> None:
 
     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"{bin_name()} help $api_type"
-        print(f"Use `{self.maybe_green(api_help_cmd)}` to get help for a specific API type.\n")
+        print("TODO: #14227 will bring this back.")
 
     def _print_global_help(self):
         def print_cmd(args: str, desc: str):
@@ -394,40 +384,24 @@ def _print_target_help(self, target_alias: str, show_advanced: bool) -> 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)))
+    def _print_rule_help(self, rule_name: str, show_advanced: bool) -> None:
+        rule = self._all_help_info.name_to_rule_info[rule_name]
+        title = f"`{rule_name}` rule"
+        self._print_title(title)
+        if rule.description:
+            print(rule.description + "\n")
+        print("\n".join(hard_wrap(rule.documentation or "Undocumented.", width=self._width)))
+        print()
+        self._print_table(
+            {
+                "activated by": rule.provider,
+                "returns": rule.output_type,
+                f"takes {pluralize(len(rule.input_types), 'input')}": ", ".join(rule.input_types),
+                f"awaits {pluralize(len(rule.input_gets), 'get')}": "\n".join(rule.input_gets)
+                if show_advanced
+                else None,
+            }
+        )
         print()
 
     def _get_help_json(self) -> str:


Test patch:
diff --git a/src/python/pants/help/help_info_extracter_test.py b/src/python/pants/help/help_info_extracter_test.py
--- a/src/python/pants/help/help_info_extracter_test.py
+++ b/src/python/pants/help/help_info_extracter_test.py
@@ -408,38 +408,6 @@ 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.test_get_all_help_info.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",
@@ -483,6 +451,26 @@ def fake_consumed_scopes_mapper(scope: str) -> Tuple[str, ...]:
                 ),
             }
         },
+        "name_to_rule_info": {
+            "construct_scope_foo": {
+                "description": None,
+                "documentation": "A foo.",
+                "input_gets": ("Get(ScopedOptions, Scope, ..)",),
+                "input_types": (),
+                "name": "construct_scope_foo",
+                "output_type": "Foo",
+                "provider": "help_info_extracter_test",
+            },
+            "pants.help.help_info_extracter_test.test_get_all_help_info.rule_info_test": {
+                "description": None,
+                "documentation": "This rule is for testing info extraction only.",
+                "input_gets": (),
+                "input_types": ("Foo",),
+                "name": "pants.help.help_info_extracter_test.test_get_all_help_info.rule_info_test",
+                "output_type": "Target",
+                "provider": "help_info_extracter_test",
+            },
+        },
     }
 
     # Break down this colossal structure into pieces so it is easier to spot where the issue is.