Repository: pantsbuild/pants

Issue:
TypeError when using macro in root BUILD file
**Describe the bug**
Given `BUILD.pants`:
```python
docker_environment(
  name="focal-docker",
  image=wo_get_pinned_name("ubuntu:focal")
)
```
and macro:
```python
def wo_get_pinned_name(x):
    return x
```

**Pants version**
2.16.0rc2

**OS**
Linux

**Additional info**
```
Exception caught: (pants.engine.internals.scheduler.ExecutionError)
  File "/home/josh/.cache/nce/ae553d873185fa750061b1a3d4226442fb99fb55d4403b0227ec972406554da3/bindings/venvs/2.16.0rc2/bin/pants", line 8, in <module>
    sys.exit(main())
  File "/home/josh/.cache/nce/ae553d873185fa750061b1a3d4226442fb99fb55d4403b0227ec972406554da3/bindings/venvs/2.16.0rc2/lib/python3.9/site-packages/pants/bin/pants_loader.py", line 123, in main
    PantsLoader.main()
  File "/home/josh/.cache/nce/ae553d873185fa750061b1a3d4226442fb99fb55d4403b0227ec972406554da3/bindings/venvs/2.16.0rc2/lib/python3.9/site-packages/pants/bin/pants_loader.py", line 110, in main
    cls.run_default_entrypoint()
  File "/home/josh/.cache/nce/ae553d873185fa750061b1a3d4226442fb99fb55d4403b0227ec972406554da3/bindings/venvs/2.16.0rc2/lib/python3.9/site-packages/pants/bin/pants_loader.py", line 92, in run_default_entrypoint
    exit_code = runner.run(start_time)
  File "/home/josh/.cache/nce/ae553d873185fa750061b1a3d4226442fb99fb55d4403b0227ec972406554da3/bindings/venvs/2.16.0rc2/lib/python3.9/site-packages/pants/bin/pants_runner.py", line 99, in run
    runner = LocalPantsRunner.create(
  File "/home/josh/.cache/nce/ae553d873185fa750061b1a3d4226442fb99fb55d4403b0227ec972406554da3/bindings/venvs/2.16.0rc2/lib/python3.9/site-packages/pants/bin/local_pants_runner.py", line 90, in create
    build_config = options_initializer.build_config(options_bootstrapper, env)
  File "/home/josh/.cache/nce/ae553d873185fa750061b1a3d4226442fb99fb55d4403b0227ec972406554da3/bindings/venvs/2.16.0rc2/lib/python3.9/site-packages/pants/init/options_initializer.py", line 111, in build_config
    return _initialize_build_configuration(self._plugin_resolver, options_bootstrapper, env)
  File "/home/josh/.cache/nce/ae553d873185fa750061b1a3d4226442fb99fb55d4403b0227ec972406554da3/bindings/venvs/2.16.0rc2/lib/python3.9/site-packages/pants/init/options_initializer.py", line 48, in _initialize_build_configuration
    working_set = plugin_resolver.resolve(options_bootstrapper, env)
  File "/home/josh/.cache/nce/ae553d873185fa750061b1a3d4226442fb99fb55d4403b0227ec972406554da3/bindings/venvs/2.16.0rc2/lib/python3.9/site-packages/pants/init/plugin_resolver.py", line 137, in resolve
    for resolved_plugin_location in self._resolve_plugins(
  File "/home/josh/.cache/nce/ae553d873185fa750061b1a3d4226442fb99fb55d4403b0227ec972406554da3/bindings/venvs/2.16.0rc2/lib/python3.9/site-packages/pants/init/plugin_resolver.py", line 161, in _resolve_plugins
    params = Params(request, determine_bootstrap_environment(session))
  File "/home/josh/.cache/nce/ae553d873185fa750061b1a3d4226442fb99fb55d4403b0227ec972406554da3/bindings/venvs/2.16.0rc2/lib/python3.9/site-packages/pants/core/util_rules/environments.py", line 443, in determine_bootstrap_environment
    session.product_request(ChosenLocalEnvironmentName, [Params()])[0],
  File "/home/josh/.cache/nce/ae553d873185fa750061b1a3d4226442fb99fb55d4403b0227ec972406554da3/bindings/venvs/2.16.0rc2/lib/python3.9/site-packages/pants/engine/internals/scheduler.py", line 569, in product_request
    return self.execute(request)
  File "/home/josh/.cache/nce/ae553d873185fa750061b1a3d4226442fb99fb55d4403b0227ec972406554da3/bindings/venvs/2.16.0rc2/lib/python3.9/site-packages/pants/engine/internals/scheduler.py", line 513, in execute
    self._raise_on_error([t for _, t in throws])
  File "/home/josh/.cache/nce/ae553d873185fa750061b1a3d4226442fb99fb55d4403b0227ec972406554da3/bindings/venvs/2.16.0rc2/lib/python3.9/site-packages/pants/engine/internals/scheduler.py", line 497, in _raise_on_error
    raise ExecutionError(

Exception message: 1 Exception encountered:

MappingError: Failed to parse ./BUILD.pants:
TypeError: _unrecognized_symbol_func() takes 0 positional arguments but 1 was given
```



Discussion:

Resolving patch:
diff --git a/src/python/pants/engine/internals/parser.py b/src/python/pants/engine/internals/parser.py
--- a/src/python/pants/engine/internals/parser.py
+++ b/src/python/pants/engine/internals/parser.py
@@ -401,7 +401,7 @@ def parse(
                         raise
                     defined_symbols.add(bad_symbol)
 
-                    global_symbols[bad_symbol] = _unrecognized_symbol_func
+                    global_symbols[bad_symbol] = _UnrecognizedSymbol(bad_symbol)
                     self._parse_state.reset(
                         filepath=filepath,
                         is_bootstrap=is_bootstrap,
@@ -485,6 +485,38 @@ def _extract_symbol_from_name_error(err: NameError) -> str:
     return result.group(1)
 
 
-def _unrecognized_symbol_func(**kwargs):
-    """Allows us to not choke on unrecognized symbols, including when they're called as
-    functions."""
+class _UnrecognizedSymbol:
+    """Allows us to not choke on unrecognized symbols, including when they're called as functions.
+
+    During bootstrap macros are not loaded and if used in field values to environment targets (which
+    are parsed during the bootstrap phase) those fields will get instances of this class as field
+    values.
+    """
+
+    def __init__(self, name: str) -> None:
+        self.name = name
+        self.args: tuple[Any, ...] = ()
+        self.kwargs: dict[str, Any] = {}
+
+    def __call__(self, *args, **kwargs) -> _UnrecognizedSymbol:
+        self.args = args
+        self.kwargs = kwargs
+        return self
+
+    def __eq__(self, other) -> bool:
+        return (
+            isinstance(other, _UnrecognizedSymbol)
+            and other.name == self.name
+            and other.args == self.args
+            and other.kwargs == self.kwargs
+        )
+
+    def __repr__(self) -> str:
+        args = ", ".join(map(repr, self.args))
+        kwargs = ", ".join(f"{k}={v!r}" for k, v in self.kwargs.items())
+        signature = ", ".join(s for s in (args, kwargs) if s)
+        return f"{self.name}({signature})"
+
+
+# Customize the type name presented by the InvalidFieldTypeException.
+_UnrecognizedSymbol.__name__ = "<unrecognized symbol>"
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
@@ -1592,9 +1592,10 @@ def __init__(
         expected_type: str,
         description_of_origin: str | None = None,
     ) -> None:
+        raw_type = f"with type `{type(raw_value).__name__}`"
         super().__init__(
             f"The {repr(field_alias)} field in target {address} must be {expected_type}, but was "
-            f"`{repr(raw_value)}` with type `{type(raw_value).__name__}`.",
+            f"`{repr(raw_value)}` {raw_type}.",
             description_of_origin=description_of_origin,
         )
 


Test patch:
diff --git a/src/python/pants/engine/internals/mapper_test.py b/src/python/pants/engine/internals/mapper_test.py
--- a/src/python/pants/engine/internals/mapper_test.py
+++ b/src/python/pants/engine/internals/mapper_test.py
@@ -22,7 +22,7 @@
     DuplicateNameError,
     SpecsFilter,
 )
-from pants.engine.internals.parser import BuildFilePreludeSymbols, Parser, _unrecognized_symbol_func
+from pants.engine.internals.parser import BuildFilePreludeSymbols, Parser, _UnrecognizedSymbol
 from pants.engine.internals.target_adaptor import TargetAdaptor as _TargetAdaptor
 from pants.engine.target import RegisteredTargetTypes, Tags, Target
 from pants.engine.unions import UnionMembership
@@ -95,7 +95,7 @@ def test_address_map_unrecognized_symbol() -> None:
     address_map = parse_address_map(build_file, ignore_unrecognized_symbols=True)
     assert {
         "one": TargetAdaptor(type_alias="thing", name="one"),
-        "bad": TargetAdaptor(type_alias="thing", name="bad", age=_unrecognized_symbol_func),
+        "bad": TargetAdaptor(type_alias="thing", name="bad", age=_UnrecognizedSymbol("fake")),
         "two": TargetAdaptor(type_alias="thing", name="two"),
         "three": TargetAdaptor(
             type_alias="thing",
diff --git a/src/python/pants/engine/internals/parser_test.py b/src/python/pants/engine/internals/parser_test.py
--- a/src/python/pants/engine/internals/parser_test.py
+++ b/src/python/pants/engine/internals/parser_test.py
@@ -3,6 +3,7 @@
 
 from __future__ import annotations
 
+import re
 from textwrap import dedent
 from typing import Any
 
@@ -10,6 +11,7 @@
 
 from pants.build_graph.build_file_aliases import BuildFileAliases
 from pants.core.target_types import GenericTarget
+from pants.engine.addresses import Address
 from pants.engine.env_vars import EnvironmentVars
 from pants.engine.internals.defaults import BuildFileDefaults, BuildFileDefaultsParserState
 from pants.engine.internals.parser import (
@@ -18,7 +20,7 @@
     Parser,
     _extract_symbol_from_name_error,
 )
-from pants.engine.target import RegisteredTargetTypes
+from pants.engine.target import InvalidFieldException, RegisteredTargetTypes, StringField
 from pants.engine.unions import UnionMembership
 from pants.testutil.pytest_util import no_exception
 from pants.util.docutil import doc_url
@@ -128,6 +130,47 @@ def perform_test(extra_targets: list[str], dym: str) -> None:
     perform_test(test_targs, dym_many)
 
 
+def test_unrecognized_symbol_during_bootstrap_issue_19156(
+    defaults_parser_state: BuildFileDefaultsParserState,
+) -> None:
+    build_file_aliases = BuildFileAliases(
+        objects={"obj": 0},
+        context_aware_object_factories={"caof": lambda parse_context: lambda _: None},
+    )
+    parser = Parser(
+        build_root="",
+        registered_target_types=RegisteredTargetTypes({"tgt": GenericTarget}),
+        union_membership=UnionMembership({}),
+        object_aliases=build_file_aliases,
+        ignore_unrecognized_symbols=True,
+    )
+    prelude_symbols = BuildFilePreludeSymbols.create({"prelude": 0}, ())
+    target_adaptors = parser.parse(
+        "dir/BUILD",
+        "tgt(field=fake(42,a=(), b='c'))",
+        prelude_symbols,
+        EnvironmentVars({}),
+        False,
+        defaults_parser_state,
+        dependents_rules=None,
+        dependencies_rules=None,
+    )
+
+    assert len(target_adaptors) == 1
+    raw_field = target_adaptors[0].kwargs["field"]
+    assert repr(raw_field) == "fake(42, a=(), b='c')"
+
+    class TestField(StringField):
+        alias = "field"
+
+    err = re.escape(
+        f"The 'field' field in target // must be a string, but was `{raw_field!r}` "
+        "with type `<unrecognized symbol>`."
+    )
+    with pytest.raises(InvalidFieldException, match=err):
+        TestField(raw_field, Address(""))
+
+
 @pytest.mark.parametrize("symbol", ["a", "bad", "BAD", "a___b_c", "a231", "áç"])
 def test_extract_symbol_from_name_error(symbol: str) -> None:
     assert _extract_symbol_from_name_error(NameError(f"name '{symbol}' is not defined")) == symbol