Repository: pantsbuild/pants

Issue:
Default source glob should not unconditionally trigger file not found behaviour
**Describe the bug**

Source fields with a non-empty default value may still result in an empty match. Currently this warns (or errors) depending on the `--files-not-found-behavior` option.

This should not be triggered, if the source field accepts 0 files, as long as the source field value is the default value.

If the source field value is anything else, the expected_num_files should not matter.

**Pants version**
2.9

**OS**
N/A

**Additional info**
This will be pre-work for supporting synthetic Dockerfiles for the `docker_image` target.



Discussion:

Resolving patch:
diff --git a/src/python/pants/engine/target.py b/src/python/pants/engine/target.py
--- a/src/python/pants/engine/target.py
+++ b/src/python/pants/engine/target.py
@@ -1502,6 +1502,11 @@ class SourcesField(AsyncFieldMixin, Field):
         files. The default is no limit on the number of source files.
     - `uses_source_roots` -- Whether the concept of "source root" pertains to the source files
         referenced by this field.
+    - `default` -- A default value for this field.
+    - `default_glob_match_error_behavior` -- Advanced option, should very rarely be used. Override
+        glob match error behavior when using the default value. If setting this to
+        `GlobMatchErrorBehavior.ignore`, make sure you have other validation in place in case the
+        default glob doesn't match any files if required, to alert the user appropriately.
     """
 
     expected_file_extensions: ClassVar[tuple[str, ...] | None] = None
@@ -1509,6 +1514,7 @@ class SourcesField(AsyncFieldMixin, Field):
     uses_source_roots: ClassVar[bool] = True
 
     default: ClassVar[ImmutableValue] = None
+    default_glob_match_error_behavior: ClassVar[GlobMatchErrorBehavior | None] = None
 
     @property
     def globs(self) -> tuple[str, ...]:
@@ -1612,12 +1618,26 @@ def can_generate(
     def path_globs(self, files_not_found_behavior: FilesNotFoundBehavior) -> PathGlobs:
         if not self.globs:
             return PathGlobs([])
-        error_behavior = files_not_found_behavior.to_glob_match_error_behavior()
+
+        # SingleSourceField has str as default type.
+        default_globs = (
+            [self.default] if self.default and isinstance(self.default, str) else self.default
+        )
+
+        # Match any if we use default globs, else match all.
         conjunction = (
             GlobExpansionConjunction.all_match
-            if not self.default or (set(self.globs) != set(self.default))
+            if not default_globs or (set(self.globs) != set(default_globs))
             else GlobExpansionConjunction.any_match
         )
+        # Use fields default error behavior if defined, if we use default globs else the provided
+        # error behavior.
+        error_behavior = (
+            files_not_found_behavior.to_glob_match_error_behavior()
+            if conjunction == GlobExpansionConjunction.all_match
+            or self.default_glob_match_error_behavior is None
+            else self.default_glob_match_error_behavior
+        )
         return PathGlobs(
             (self._prefix_glob_with_address(glob) for glob in self.globs),
             conjunction=conjunction,


Test patch:
diff --git a/src/python/pants/engine/target_test.py b/src/python/pants/engine/target_test.py
--- a/src/python/pants/engine/target_test.py
+++ b/src/python/pants/engine/target_test.py
@@ -1,6 +1,7 @@
 # Copyright 2020 Pants project contributors (see CONTRIBUTORS.md).
 # Licensed under the Apache License, Version 2.0 (see LICENSE).
 
+from collections import namedtuple
 from dataclasses import dataclass
 from enum import Enum
 from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
@@ -8,7 +9,7 @@
 import pytest
 
 from pants.engine.addresses import Address
-from pants.engine.fs import Paths
+from pants.engine.fs import GlobExpansionConjunction, GlobMatchErrorBehavior, Paths
 from pants.engine.target import (
     AsyncFieldMixin,
     BoolField,
@@ -1008,6 +1009,124 @@ class GenSources(GenerateSourcesRequest):
     assert set(result) == {tgt2}
 
 
+SKIP = object()
+expected_path_globs = namedtuple(
+    "expected_path_globs",
+    ["globs", "glob_match_error_behavior", "conjunction", "description_of_origin"],
+    defaults=(SKIP, SKIP, SKIP, SKIP),
+)
+
+
+@pytest.mark.parametrize(
+    "default_value, field_value, expected",
+    [
+        pytest.param(
+            None,
+            None,
+            expected_path_globs(globs=()),
+            id="empty",
+        ),
+        pytest.param(
+            ["*"],
+            None,
+            expected_path_globs(
+                globs=("test/*",),
+                glob_match_error_behavior=GlobMatchErrorBehavior.ignore,
+                conjunction=GlobExpansionConjunction.any_match,
+                description_of_origin=None,
+            ),
+            id="default ignores glob match error",
+        ),
+        pytest.param(
+            ["*"],
+            ["a", "b"],
+            expected_path_globs(
+                globs=(
+                    "test/a",
+                    "test/b",
+                ),
+                glob_match_error_behavior=GlobMatchErrorBehavior.warn,
+                conjunction=GlobExpansionConjunction.all_match,
+                description_of_origin="test:test's `sources` field",
+            ),
+            id="provided value warns on glob match error",
+        ),
+    ],
+)
+def test_multiple_sources_path_globs(
+    default_value: Any, field_value: Any, expected: expected_path_globs
+) -> None:
+    class TestMultipleSourcesField(MultipleSourcesField):
+        default = default_value
+        default_glob_match_error_behavior = GlobMatchErrorBehavior.ignore
+
+    sources = TestMultipleSourcesField(field_value, Address("test"))
+    actual = sources.path_globs(FilesNotFoundBehavior.warn)
+    for attr, expect in zip(expected._fields, expected):
+        if expect is not SKIP:
+            assert getattr(actual, attr) == expect
+
+
+@pytest.mark.parametrize(
+    "default_value, field_value, expected",
+    [
+        pytest.param(
+            None,
+            None,
+            expected_path_globs(globs=()),
+            id="empty",
+        ),
+        pytest.param(
+            "file",
+            None,
+            expected_path_globs(
+                globs=("test/file",),
+                glob_match_error_behavior=GlobMatchErrorBehavior.ignore,
+                conjunction=GlobExpansionConjunction.any_match,
+                description_of_origin=None,
+            ),
+            id="default ignores glob match error",
+        ),
+        pytest.param(
+            "default_file",
+            "other_file",
+            expected_path_globs(
+                globs=("test/other_file",),
+                glob_match_error_behavior=GlobMatchErrorBehavior.warn,
+                conjunction=GlobExpansionConjunction.all_match,
+                description_of_origin="test:test's `source` field",
+            ),
+            id="provided value warns on glob match error",
+        ),
+        pytest.param(
+            "file",
+            "life",
+            expected_path_globs(
+                globs=("test/life",),
+                glob_match_error_behavior=GlobMatchErrorBehavior.warn,
+                conjunction=GlobExpansionConjunction.all_match,
+                description_of_origin="test:test's `source` field",
+            ),
+            id="default glob conjunction",
+        ),
+    ],
+)
+def test_single_source_path_globs(
+    default_value: Any, field_value: Any, expected: expected_path_globs
+) -> None:
+    class TestSingleSourceField(SingleSourceField):
+        default = default_value
+        default_glob_match_error_behavior = GlobMatchErrorBehavior.ignore
+        required = False
+
+    sources = TestSingleSourceField(field_value, Address("test"))
+
+    actual = sources.path_globs(FilesNotFoundBehavior.warn)
+    for attr, expect in zip(expected._fields, expected):
+        if expect is not SKIP:
+            assert getattr(actual, attr) == expect
+
+
 # -----------------------------------------------------------------------------------------------
 # Test `ExplicitlyProvidedDependencies` helper functions
 # -----------------------------------------------------------------------------------------------