Repository: pantsbuild/pants

Issue:
Show the default version of built-in (Python) tools in docs somewhere
**Describe the bug**

Currently the default version of a Python tool built-in to Pants (like black) doesn't seem to be shown in the documentation anywhere, it's just implied by the lockfile, which is hidden: e.g. https://www.pantsbuild.org/2.19/reference/subsystems/black

(Compare a JVM tool like `openapi-generator` which has a default value for the version field: https://www.pantsbuild.org/2.19/reference/subsystems/openapi-generator#version)

This leads to confusion when newer releases of a tool change behaviour, deprecate options or even introduce new ones (like #20707) and a user can't tell why their configuration isn't working when run with Pants.

Suggestion: have `PythonToolRequirementsBase` dynamically argument the subsystem's help text by reading the lockfile. For instance, for the page above, adding the second paragraph in:

> The Black Python code formatter (https://black.readthedocs.io/).
>
> This version of Pants uses Black 23.3.0 by default. Use [a dedicated lockfile](https://www.pantsbuild.org/2.19/docs/python/overview/lockfiles#lockfiles-for-tools) and [the `install_from_resolve` option](https://www.pantsbuild.org/2.19/reference/subsystems/black#install_from_resolve) to control this.

This could potentially also be applied to the `install_from_resolve` and/or `requirements` fields.

**Pants version**
2.19

**OS**
N/A

**Additional info**
N/A


Discussion:
Happy to take this one! Assigned to myself

Resolving patch:
diff --git a/src/python/pants/backend/python/subsystems/python_tool_base.py b/src/python/pants/backend/python/subsystems/python_tool_base.py
--- a/src/python/pants/backend/python/subsystems/python_tool_base.py
+++ b/src/python/pants/backend/python/subsystems/python_tool_base.py
@@ -3,9 +3,14 @@
 
 from __future__ import annotations
 
+import importlib.resources
+import json
 import logging
 import os
-from typing import ClassVar, Iterable, Sequence
+from dataclasses import dataclass
+from functools import cache
+from typing import ClassVar, Iterable, Optional, Sequence
+from urllib.parse import urlparse
 
 from pants.backend.python.target_types import ConsoleScript, EntryPoint, MainSpecification
 from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints
@@ -18,6 +23,7 @@
     Lockfile,
     PexRequirements,
     Resolve,
+    strip_comments_from_pex_json_lockfile,
 )
 from pants.core.goals.resolves import ExportableTool
 from pants.engine.fs import Digest
@@ -27,11 +33,18 @@
 from pants.option.subsystem import Subsystem
 from pants.util.docutil import doc_url, git_url
 from pants.util.meta import classproperty
+from pants.util.pip_requirement import PipRequirement
 from pants.util.strutil import softwrap
 
 logger = logging.getLogger(__name__)
 
 
+@dataclass(frozen=True)
+class _PackageNameAndVersion:
+    name: str
+    version: str
+
+
 class PythonToolRequirementsBase(Subsystem, ExportableTool):
     """Base class for subsystems that configure a set of requirements for a python tool."""
 
@@ -42,6 +55,7 @@ class PythonToolRequirementsBase(Subsystem, ExportableTool):
 
     # Subclasses may set to override the value computed from default_version and
     # default_extra_requirements.
+    # The primary package used in the subsystem must always be the first requirement.
     # TODO: Once we get rid of those options, subclasses must set this to loose
     #  requirements that reflect any minimum capabilities Pants assumes about the tool.
     default_requirements: Sequence[str] = []
@@ -51,15 +65,20 @@ class PythonToolRequirementsBase(Subsystem, ExportableTool):
 
     default_lockfile_resource: ClassVar[tuple[str, str] | None] = None
 
-    install_from_resolve = StrOption(
-        advanced=True,
-        default=None,
-        help=lambda cls: softwrap(
+    @classmethod
+    def _install_from_resolve_help(cls) -> str:
+        package_and_version = cls._default_package_name_and_version()
+        version_clause = (
+            f", which uses `{package_and_version.name}` version {package_and_version.version}"
+            if package_and_version
+            else ""
+        )
+        return softwrap(
             f"""\
             If specified, install the tool using the lockfile for this named resolve.
 
             This resolve must be defined in `[python].resolves`, as described in
-            {doc_url("docs/python/overview/third-party-dependencies#user-lockfiles")}.
+            {doc_url("docs/python/overview/lockfiles#lockfiles-for-tools")}.
 
             The resolve's entire lockfile will be installed, unless specific requirements are
             listed via the `requirements` option, in which case only those requirements
@@ -67,13 +86,18 @@ class PythonToolRequirementsBase(Subsystem, ExportableTool):
             outputs when the resolve incurs changes to unrelated requirements.
 
             If unspecified, and the `lockfile` option is unset, the tool will be installed
-            using the default lockfile shipped with Pants.
+            using the default lockfile shipped with Pants{version_clause}.
 
             If unspecified, and the `lockfile` option is set, the tool will use the custom
             `{cls.options_scope}` "tool lockfile" generated from the `version` and
             `extra_requirements` options. But note that this mechanism is deprecated.
             """
-        ),
+        )
+
+    install_from_resolve = StrOption(
+        advanced=True,
+        default=None,
+        help=lambda cls: cls._install_from_resolve_help(),
     )
 
     requirements = StrListOption(
@@ -169,6 +193,42 @@ def pex_requirements_for_default_lockfile(cls):
             resolve_name=cls.options_scope,
         )
 
+    @classmethod
+    @cache
+    def _default_package_name_and_version(cls) -> Optional[_PackageNameAndVersion]:
+        if cls.default_lockfile_resource is None:
+            return None
+
+        lockfile = cls.pex_requirements_for_default_lockfile()
+        parts = urlparse(lockfile.url)
+        # urlparse retains the leading / in URLs with a netloc.
+        lockfile_path = parts.path[1:] if parts.path.startswith("/") else parts.path
+        if parts.scheme in {"", "file"}:
+            with open(lockfile_path, "rb") as fp:
+                lock_bytes = fp.read()
+        elif parts.scheme == "resource":
+            # The "netloc" in our made-up "resource://" scheme is the package.
+            lock_bytes = importlib.resources.read_binary(parts.netloc, lockfile_path)
+        else:
+            raise ValueError(
+                f"Unsupported scheme {parts.scheme} for lockfile URL: {lockfile.url} "
+                f"(origin: {lockfile.url_description_of_origin})"
+            )
+
+        stripped_lock_bytes = strip_comments_from_pex_json_lockfile(lock_bytes)
+        lockfile_contents = json.loads(stripped_lock_bytes)
+        # The first requirement must contain the primary package for this tool, otherwise
+        # this will pick up the wrong requirement.
+        first_default_requirement = PipRequirement.parse(cls.default_requirements[0])
+        return next(
+            _PackageNameAndVersion(
+                name=first_default_requirement.project_name, version=requirement["version"]
+            )
+            for resolve in lockfile_contents["locked_resolves"]
+            for requirement in resolve["locked_requirements"]
+            if requirement["project_name"] == first_default_requirement.project_name
+        )
+
     def pex_requirements(
         self,
         *,
diff --git a/src/python/pants/option/scope.py b/src/python/pants/option/scope.py
--- a/src/python/pants/option/scope.py
+++ b/src/python/pants/option/scope.py
@@ -41,7 +41,7 @@ class ScopeInfo:
 
     @property
     def description(self) -> str:
-        return cast(str, getattr(self.subsystem_cls, "help"))
+        return cast(str, self._subsystem_cls_attr("help"))
 
     @property
     def deprecated_scope(self) -> Optional[str]:


Test patch: