Repository: pantsbuild/pants

Issue:
Leading whitespace in `./pants tailor` in 2.8
**Describe the bug**
With the addition of #12979 I noticed leading whitespace when generating `BUILD` files that I didn't see with 2.7.0.

(This is likely do the addition of the build file header support)

**Pants version**
`2.8.0.dev3` (vs. `2.7.0`)

**OS**
Linux

**Additional info**
I think the unit tests use `lstrip` which masks this "bug".



Discussion:
Example output:

```


python_sources()

pex_binary(
    name="gen_codeowners",
    entry_point="gen_codeowners.py",
)

python_tests(
    name="tests",
)

```
FYI I like using `pytest-snapshot` for "does this generated file match what we expected"-like testing.

https://pypi.org/project/pytest-snapshot/
Hi @joshua-cannon-techlabs! I see the incorrect example output. Can you confirm that the correct output is as follows?

```
python_sources()

pex_binary(
    name="gen_codeowners",
    entry_point="gen_codeowners.py",
)

python_tests(
    name="tests",
)

```


Resolving patch:
diff --git a/src/python/pants/core/goals/tailor.py b/src/python/pants/core/goals/tailor.py
--- a/src/python/pants/core/goals/tailor.py
+++ b/src/python/pants/core/goals/tailor.py
@@ -9,7 +9,7 @@
 from abc import ABCMeta
 from collections import defaultdict
 from dataclasses import dataclass
-from typing import Iterable, Mapping, cast
+from typing import Iterable, Mapping, Optional, cast
 
 from pants.base.specs import (
     AddressSpecs,
@@ -259,7 +259,7 @@ def register_options(cls, register):
             "--build-file-header",
             advanced=True,
             type=str,
-            default="",
+            default=None,
             help="A header, e.g., a copyright notice, to add to the content of created BUILD files.",
         )
 
@@ -285,8 +285,8 @@ def build_file_name(self) -> str:
         return cast(str, self.options.build_file_name)
 
     @property
-    def build_file_header(self) -> str:
-        return cast(str, self.options.build_file_header)
+    def build_file_header(self) -> str | None:
+        return cast(Optional[str], self.options.build_file_header)
 
     @property
     def build_file_indent(self) -> str:
@@ -416,7 +416,7 @@ async def restrict_conflicting_sources(ptgt: PutativeTarget) -> DisjointSourcePu
 class EditBuildFilesRequest:
     putative_targets: PutativeTargets
     name: str
-    header: str
+    header: str | None
     indent: str
 
 


Test patch:
diff --git a/src/python/pants/core/goals/tailor_test.py b/src/python/pants/core/goals/tailor_test.py
--- a/src/python/pants/core/goals/tailor_test.py
+++ b/src/python/pants/core/goals/tailor_test.py
@@ -1,8 +1,10 @@
 # Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
 # Licensed under the Apache License, Version 2.0 (see LICENSE).
+from __future__ import annotations
 
 import textwrap
 from dataclasses import dataclass
+from textwrap import dedent
 
 import pytest
 
@@ -123,19 +125,19 @@ def test_make_content_str() -> None:
         ],
     )
     assert (
-        textwrap.dedent(
-            """
-    fortran_library()
+        dedent(
+            """\
+            fortran_library()
 
-    fortran_tests(
-        name="tests",
-        sources=[
-            "test1.f90",
-            "test2.f90",
-        ],
-    )
-    """
-        ).lstrip()
+            fortran_tests(
+                name="tests",
+                sources=[
+                    "test1.f90",
+                    "test2.f90",
+                ],
+            )
+            """
+        )
         == content
     )
 
@@ -285,40 +287,75 @@ def test_edit_build_files(rule_runner: RuleRunner, name: str) -> None:
     expected = [
         FileContent(
             f"src/fortran/baz/{name}.pants",
-            textwrap.dedent(
-                """
-                Copyright © 2021 FooCorp.
+            dedent(
+                """\
+            Copyright © 2021 FooCorp.
 
-                fortran_library()
+            fortran_library()
             """
-            )
-            .lstrip()
-            .encode(),
+            ).encode(),
         ),
         FileContent(
             f"src/fortran/foo/{name}",
             textwrap.dedent(
+                """\
+                fortran_library(sources=["bar1.f90"])
+
+                # A comment spread
+                # over multiple lines.
+                fortran_library(
+                    name="foo0",
+                    sources=[
+                        "bar2.f90",
+                        "bar3.f90",
+                    ],
+                )
+
+                fortran_tests(
+                    name="tests",
+                    life_the_universe_and_everything=42,
+                )
                 """
-            fortran_library(sources=["bar1.f90"])
+            ).encode(),
+        ),
+    ]
+    actual = list(contents)
+    # We do these more laborious asserts instead of just comparing the lists so that
+    # on a text mismatch we see the actual string diff on the decoded strings.
+    assert len(expected) == len(actual)
+    for efc, afc in zip(expected, actual):
+        assert efc.path == afc.path
+        assert efc.content.decode() == afc.content.decode()
+        assert efc.is_executable == afc.is_executable
 
-            # A comment spread
-            # over multiple lines.
-            fortran_library(
-                name="foo0",
-                sources=[
-                    "bar2.f90",
-                    "bar3.f90",
-                ],
-            )
 
-            fortran_tests(
-                name="tests",
-                life_the_universe_and_everything=42,
-            )
-            """
-            )
-            .lstrip()
-            .encode(),
+def test_edit_build_files_without_header_text(rule_runner: RuleRunner) -> None:
+    rule_runner.create_dir("src/fortran/baz/BUILD")  # NB: A directory, not a file.
+    req = EditBuildFilesRequest(
+        PutativeTargets(
+            [
+                PutativeTarget.for_target_type(
+                    FortranLibrary, "src/fortran/baz", "baz", ["qux1.f90"]
+                ),
+            ]
+        ),
+        name="BUILD",
+        header=None,
+        indent="    ",
+    )
+    edited_build_files = rule_runner.request(EditedBuildFiles, [req])
+
+    assert edited_build_files.created_paths == ("src/fortran/baz/BUILD.pants",)
+
+    contents = rule_runner.request(DigestContents, [edited_build_files.digest])
+    expected = [
+        FileContent(
+            "src/fortran/baz/BUILD.pants",
+            dedent(
+                """\
+               fortran_library()
+               """
+            ).encode(),
         ),
     ]
     actual = list(contents)
@@ -331,6 +368,34 @@ def test_edit_build_files(rule_runner: RuleRunner, name: str) -> None:
         assert efc.is_executable == afc.is_executable
 
 
+@pytest.mark.parametrize("header", [None, "I am some header text"])
+def test_build_file_lacks_leading_whitespace(rule_runner: RuleRunner, header: str | None) -> None:
+    rule_runner.create_dir("src/fortran/baz/BUILD")  # NB: A directory, not a file.
+    req = EditBuildFilesRequest(
+        PutativeTargets(
+            [
+                PutativeTarget.for_target_type(
+                    FortranLibrary, "src/fortran/baz", "baz", ["qux1.f90"]
+                ),
+            ]
+        ),
+        name="BUILD",
+        header=header,
+        indent="    ",
+    )
+    edited_build_files = rule_runner.request(EditedBuildFiles, [req])
+
+    assert edited_build_files.created_paths == ("src/fortran/baz/BUILD.pants",)
+
+    contents = rule_runner.request(DigestContents, [edited_build_files.digest])
+    actual = list(contents)
+    # We do these more laborious asserts instead of just comparing the lists so that
+    # on a text mismatch we see the actual string diff on the decoded strings.
+    for afc in actual:
+        content = afc.content.decode()
+        assert content.lstrip() == content
+
+
 def test_group_by_dir() -> None:
     paths = {
         "foo/bar/baz1.ext",