Repository: pantsbuild/pants

Issue:
Add a `long_description_path` to `python_distribution` to read files when generating setup.py
A common desire is to read a file to set the `long_description`. Right now, you have to write a plugin, as described at the bottom of:

https://www.pantsbuild.org/v2.3/docs/plugins-setup-py#2-create-a-rule-with-your-logic

We should automate this, given how common the use case is. Add a field `long_description_path` to `python_distribution`. If set, we'll read the file and set `long_description` in the final kwargs. This is an optional field, users can still use a plugin and/or hardcode the kwarg.


Discussion:

Resolving patch:
diff --git a/src/python/pants/backend/python/goals/setup_py.py b/src/python/pants/backend/python/goals/setup_py.py
--- a/src/python/pants/backend/python/goals/setup_py.py
+++ b/src/python/pants/backend/python/goals/setup_py.py
@@ -19,6 +19,7 @@
 from pants.backend.python.subsystems.setuptools import PythonDistributionFieldSet
 from pants.backend.python.target_types import (
     GenerateSetupField,
+    LongDescriptionPathField,
     PythonDistributionEntryPointsField,
     PythonGeneratingSourcesBase,
     PythonProvidesField,
@@ -46,6 +47,7 @@
     StrippedPythonSourceFiles,
 )
 from pants.backend.python.util_rules.python_sources import rules as python_sources_rules
+from pants.base.glob_match_error_behavior import GlobMatchErrorBehavior
 from pants.base.specs import AddressSpecs, AscendantAddresses
 from pants.core.goals.package import BuiltPackage, BuiltPackageArtifact, PackageFieldSet
 from pants.core.target_types import FileSourceField, ResourceSourceField
@@ -67,6 +69,7 @@
 from pants.engine.target import (
     Dependencies,
     DependenciesRequest,
+    InvalidFieldException,
     SourcesField,
     Target,
     Targets,
@@ -432,7 +435,7 @@ async def package_python_dist(
 
 
 @rule
-async def determine_setup_kwargs(
+async def determine_explicitly_provided_setup_kwargs(
     exported_target: ExportedTarget, union_membership: UnionMembership
 ) -> SetupKwargs:
     target = exported_target.target
@@ -521,7 +524,7 @@ async def generate_setup_py(request: GenerateSetupPyRequest) -> GeneratedSetupPy
 
 
 @rule
-async def generate_setup_py_kwargs(request: GenerateSetupPyRequest) -> FinalizedSetupKwargs:
+async def determine_finalized_setup_kwargs(request: GenerateSetupPyRequest) -> FinalizedSetupKwargs:
     exported_target = request.exported_target
     sources = request.sources
     requirements = await Get(ExportedTargetRequirements, DependencyOwner(exported_target))
@@ -548,6 +551,32 @@ async def generate_setup_py_kwargs(request: GenerateSetupPyRequest) -> Finalized
         }
     )
 
+    long_description_path = exported_target.target.get(LongDescriptionPathField).value
+
+    if "long_description" in setup_kwargs and long_description_path:
+        raise InvalidFieldException(
+            f"The {repr(LongDescriptionPathField.alias)} field of the "
+            f"target {exported_target.target.address} is set, but "
+            f"'long_description' is already provided explicitly in "
+            f"the provides=setup_py() field. You may only set one "
+            f"of these two values."
+        )
+
+    if long_description_path:
+        digest_contents = await Get(
+            DigestContents,
+            PathGlobs(
+                [long_description_path],
+                description_of_origin=(
+                    f"the {LongDescriptionPathField.alias} "
+                    f"field of {exported_target.target.address}"
+                ),
+                glob_match_error_behavior=GlobMatchErrorBehavior.error,
+            ),
+        )
+        long_description_content = digest_contents[0].content.decode()
+        setup_kwargs.update({"long_description": long_description_content})
+
     # Resolve entry points from python_distribution(entry_points=...) and from
     # python_distribution(provides=setup_py(entry_points=...)
     resolved_from_entry_points_field, resolved_from_provides_field = await MultiGet(
diff --git a/src/python/pants/backend/python/target_types.py b/src/python/pants/backend/python/target_types.py
--- a/src/python/pants/backend/python/target_types.py
+++ b/src/python/pants/backend/python/target_types.py
@@ -1209,6 +1209,19 @@ class GenerateSetupField(TriBoolField):
     )
 
 
+class LongDescriptionPathField(StringField):
+    alias = "long_description_path"
+    required = False
+
+    help = (
+        "Path to a file that will be used to fill the long_description field in setup.py.\n\n"
+        "Path is relative to the build root.\n\n"
+        "Alternatively, you can set the `long_description` in the `provides` field, but not both.\n\n"
+        "This field won't automatically set `long_description_content_type` field for you. "
+        "You have to specify this field yourself in the `provides` field."
+    )
+
+
 class PythonDistribution(Target):
     alias = "python_distribution"
     core_fields = (
@@ -1221,6 +1234,7 @@ class PythonDistribution(Target):
         SDistField,
         WheelConfigSettingsField,
         SDistConfigSettingsField,
+        LongDescriptionPathField,
     )
     help = (
         "A publishable Python setuptools distribution (e.g. an sdist or wheel).\n\nSee "


Test patch:
diff --git a/src/python/pants/backend/python/goals/setup_py_test.py b/src/python/pants/backend/python/goals/setup_py_test.py
--- a/src/python/pants/backend/python/goals/setup_py_test.py
+++ b/src/python/pants/backend/python/goals/setup_py_test.py
@@ -30,10 +30,10 @@
     SetupKwargsRequest,
     SetupPyGeneration,
     declares_pkg_resources_namespace_package,
-    determine_setup_kwargs,
+    determine_explicitly_provided_setup_kwargs,
+    determine_finalized_setup_kwargs,
     generate_chroot,
     generate_setup_py,
-    generate_setup_py_kwargs,
     get_exporting_owner,
     get_owned_dependencies,
     get_requirements,
@@ -59,6 +59,7 @@
 from pants.engine.fs import Snapshot
 from pants.engine.internals.scheduler import ExecutionError
 from pants.engine.rules import SubsystemRule, rule
+from pants.engine.target import InvalidFieldException
 from pants.engine.unions import UnionRule
 from pants.testutil.rule_runner import QueryRule, RuleRunner
 
@@ -101,10 +102,10 @@ def chroot_rule_runner() -> RuleRunner:
     return create_setup_py_rule_runner(
         rules=[
             *core_target_types_rules(),
-            determine_setup_kwargs,
+            determine_explicitly_provided_setup_kwargs,
             generate_chroot,
             generate_setup_py,
-            generate_setup_py_kwargs,
+            determine_finalized_setup_kwargs,
             get_sources,
             get_requirements,
             get_owned_dependencies,
@@ -500,6 +501,98 @@ def test_generate_chroot_entry_points(chroot_rule_runner: RuleRunner) -> None:
     )
 
 
+def test_generate_long_description_field_from_file(chroot_rule_runner: RuleRunner) -> None:
+    chroot_rule_runner.write_files(
+        {
+            "src/python/foo/BUILD": textwrap.dedent(
+                """
+                python_distribution(
+                    name='foo-dist',
+                    long_description_path="src/python/foo/readme.md",
+                    provides=setup_py(
+                        name='foo',
+                        version='1.2.3',
+                    )
+                )
+                """
+            ),
+            "src/python/foo/readme.md": "Some long description.",
+        }
+    )
+    assert_chroot(
+        chroot_rule_runner,
+        [
+            "setup.py",
+            "MANIFEST.in",
+        ],
+        {
+            "name": "foo",
+            "version": "1.2.3",
+            "plugin_demo": "hello world",
+            "packages": tuple(),
+            "namespace_packages": tuple(),
+            "package_data": {},
+            "install_requires": tuple(),
+            "long_description": "Some long description.",
+        },
+        Address("src/python/foo", target_name="foo-dist"),
+    )
+
+
+def test_generate_long_description_field_from_file_already_having_it(
+    chroot_rule_runner: RuleRunner,
+) -> None:
+    chroot_rule_runner.write_files(
+        {
+            "src/python/foo/BUILD": textwrap.dedent(
+                """
+                python_distribution(
+                    name='foo-dist',
+                    long_description_path="src/python/foo/readme.md",
+                    provides=setup_py(
+                        name='foo',
+                        version='1.2.3',
+                        long_description="Some long description.",
+                    )
+                )
+                """
+            ),
+            "src/python/foo/readme.md": "Some long description.",
+        }
+    )
+    assert_chroot_error(
+        chroot_rule_runner,
+        Address("src/python/foo", target_name="foo-dist"),
+        InvalidFieldException,
+    )
+
+
+def test_generate_long_description_field_from_non_existing_file(
+    chroot_rule_runner: RuleRunner,
+) -> None:
+    chroot_rule_runner.write_files(
+        {
+            "src/python/foo/BUILD": textwrap.dedent(
+                """
+                python_distribution(
+                    name='foo-dist',
+                    long_description_path="src/python/foo/readme.md",
+                    provides=setup_py(
+                        name='foo',
+                        version='1.2.3',
+                    )
+                )
+                """
+            ),
+        }
+    )
+    assert_chroot_error(
+        chroot_rule_runner,
+        Address("src/python/foo", target_name="foo-dist"),
+        Exception,
+    )
+
+
 def test_invalid_binary(chroot_rule_runner: RuleRunner) -> None:
     chroot_rule_runner.write_files(
         {
@@ -734,7 +827,7 @@ def assert_sources(
 def test_get_requirements() -> None:
     rule_runner = create_setup_py_rule_runner(
         rules=[
-            determine_setup_kwargs,
+            determine_explicitly_provided_setup_kwargs,
             get_requirements,
             get_owned_dependencies,
             get_exporting_owner,
@@ -813,7 +906,7 @@ def test_get_requirements() -> None:
 def test_get_requirements_with_exclude() -> None:
     rule_runner = create_setup_py_rule_runner(
         rules=[
-            determine_setup_kwargs,
+            determine_explicitly_provided_setup_kwargs,
             get_requirements,
             get_owned_dependencies,
             get_exporting_owner,
@@ -1216,10 +1309,10 @@ def test_does_not_declare_pkg_resources_namespace_package(python_src: str) -> No
 def test_no_dist_type_selected() -> None:
     rule_runner = RuleRunner(
         rules=[
-            determine_setup_kwargs,
+            determine_explicitly_provided_setup_kwargs,
             generate_chroot,
             generate_setup_py,
-            generate_setup_py_kwargs,
+            determine_finalized_setup_kwargs,
             get_sources,
             get_requirements,
             get_owned_dependencies,