Repository: pantsbuild/pants

Issue:
Hook up python_requirements and pyproject.toml to tailor
**Is your feature request related to a problem? Please describe.**
PR #16932 added a PEP 621 parser for `python_requirement`s coming from a `pyproject.toml` file. At the moment, users must explicitly give the source as `pyproject.toml` since there's only logic to search for `requirements.txt` files as the source. In the PR, @Eric-Arellano [suggested](https://github.com/pantsbuild/pants/pull/16932#pullrequestreview-1199652866) that I open an issue to track automatically adding `pyproject.toml` as the source of the `python_requirements` target when it makes sense to do so.

**Describe the solution you'd like**
If I specify a `python_requirements` target, and there is no `requirements.txt` file present, I would like pantsbuild to automatically add `pyproject.toml` as the source file, presuming one exists.

**Describe alternatives you've considered**
The alternative, which works currently, is to manually add `pyproject.toml` as the `source.


Discussion:
Thanks! Specifically, I recommend we hook this up with `tailor` here:

https://github.com/pantsbuild/pants/blob/2d8f1e34fee52aec7437c21da425eeb67d17306d/src/python/pants/backend/python/goals/tailor.py#L156-L225

I think the default `source` field for `python_requirements` is good, and in fact we can't set the default to be A or B. It has to be one value.

Help definitely appreciated if you're interested in working on this :)

Resolving patch:
diff --git a/src/python/pants/backend/python/goals/tailor.py b/src/python/pants/backend/python/goals/tailor.py
--- a/src/python/pants/backend/python/goals/tailor.py
+++ b/src/python/pants/backend/python/goals/tailor.py
@@ -13,6 +13,9 @@
 from pants.backend.python.dependency_inference.module_mapper import module_from_stripped_path
 from pants.backend.python.macros.pipenv_requirements import parse_pipenv_requirements
 from pants.backend.python.macros.poetry_requirements import PyProjectToml, parse_pyproject_toml
+from pants.backend.python.macros.python_requirements import (
+    parse_pyproject_toml as parse_pep621_pyproject_toml,
+)
 from pants.backend.python.subsystems.setup import PythonSetup
 from pants.backend.python.target_types import (
     PexBinary,
@@ -200,7 +203,7 @@ def add_req_targets(files: Iterable[FileContent], alias: str, target_name: str)
                 path, name = os.path.split(fp)
 
                 try:
-                    validate(fp, contents[fp], alias)
+                    validate(fp, contents[fp], alias, target_name)
                 except Exception as e:
                     logger.warning(
                         f"An error occurred when validating `{fp}`: {e}.\n\n"
@@ -225,8 +228,10 @@ def add_req_targets(files: Iterable[FileContent], alias: str, target_name: str)
                     )
                 )
 
-        def validate(path: str, contents: bytes, alias: str) -> None:
+        def validate(path: str, contents: bytes, alias: str, target_name: str) -> None:
             if alias == "python_requirements":
+                if path.endswith("pyproject.toml"):
+                    return validate_pep621_requirements(path, contents)
                 return validate_python_requirements(path, contents)
             elif alias == "pipenv_requirements":
                 return validate_pipenv_requirements(contents)
@@ -237,6 +242,9 @@ def validate_python_requirements(path: str, contents: bytes) -> None:
             for _ in parse_requirements_file(contents.decode(), rel_path=path):
                 pass
 
+        def validate_pep621_requirements(path: str, contents: bytes) -> None:
+            list(parse_pep621_pyproject_toml(contents.decode(), rel_path=path))
+
         def validate_pipenv_requirements(contents: bytes) -> None:
             parse_pipenv_requirements(contents)
 
@@ -252,6 +260,21 @@ def validate_poetry_requirements(contents: bytes) -> None:
             "poetry",
         )
 
+        def pyproject_toml_has_pep621(fc) -> bool:
+            try:
+                return (
+                    len(list(parse_pep621_pyproject_toml(fc.content.decode(), rel_path=fc.path)))
+                    > 0
+                )
+            except Exception:
+                return False
+
+        add_req_targets(
+            {fc for fc in all_pyproject_toml_contents if pyproject_toml_has_pep621(fc)},
+            "python_requirements",
+            "reqs",
+        )
+
     if python_setup.tailor_pex_binary_targets:
         # Find binary targets.
 


Test patch:
diff --git a/src/python/pants/backend/python/goals/tailor_test.py b/src/python/pants/backend/python/goals/tailor_test.py
--- a/src/python/pants/backend/python/goals/tailor_test.py
+++ b/src/python/pants/backend/python/goals/tailor_test.py
@@ -69,6 +69,15 @@ def test_find_putative_targets(rule_runner: RuleRunner) -> None:
             "3rdparty/Pipfile.lock": "{}",
             "3rdparty/pyproject.toml": "[tool.poetry]",
             "3rdparty/requirements-test.txt": "",
+            "pep621/pyproject.toml": textwrap.dedent(
+                """\
+                [project]
+                dependencies = [
+                    "ansicolors>=1.18.0",
+                ]
+            """
+            ),
+            "pep621/requirements.txt": "",  # requirements in same dir as pep621 pyproject.toml causes conflict for name
             "already_owned/requirements.txt": "",
             "already_owned/Pipfile.lock": "",
             "already_owned/pyproject.toml": "[tool.poetry]",
@@ -92,7 +101,14 @@ def test_find_putative_targets(rule_runner: RuleRunner) -> None:
         PutativeTargets,
         [
             PutativePythonTargetsRequest(
-                ("3rdparty", "already_owned", "no_match", "src/python/foo", "src/python/foo/bar")
+                (
+                    "3rdparty",
+                    "already_owned",
+                    "no_match",
+                    "src/python/foo",
+                    "src/python/foo/bar",
+                    "pep621",
+                )
             ),
             AllOwnedSources(
                 [
@@ -127,6 +143,19 @@ def test_find_putative_targets(rule_runner: RuleRunner) -> None:
                     triggering_sources=["3rdparty/requirements-test.txt"],
                     kwargs={"source": "requirements-test.txt"},
                 ),
+                PutativeTarget.for_target_type(
+                    PythonRequirementsTargetGenerator,
+                    path="pep621",
+                    name="reqs",
+                    triggering_sources=["pep621/pyproject.toml"],
+                    kwargs={"source": "pyproject.toml"},
+                ),
+                PutativeTarget.for_target_type(
+                    PythonRequirementsTargetGenerator,
+                    path="pep621",
+                    name="reqs",
+                    triggering_sources=["pep621/requirements.txt"],
+                ),
                 PutativeTarget.for_target_type(
                     PythonSourcesGeneratorTarget, "src/python/foo", None, ["__init__.py"]
                 ),