Repository: pantsbuild/pants

Issue:
Macros cannot raise exceptions
Apply this diff:

```diff
diff --git a/BUILD b/BUILD
index 698df038d..05a1328e7 100644
--- a/BUILD
+++ b/BUILD
@@ -8,3 +8,5 @@ shell_sources(name="scripts", sources=["cargo", "pants"])
 files(name="files", sources=["BUILD_ROOT", ".gitignore", "pants.toml"])
 
 python_test_utils(name="test_utils")
+
+foo()
\ No newline at end of file
diff --git a/black.lock b/black.lock
new file mode 100644
index 000000000..7abc4835a
--- /dev/null
+++ b/black.lock
@@ -0,0 +1 @@
+{"allow_builds": true, "allow_prereleases": false, "allow_wheels": true, "build_isolation": true, "constraints": [], "locked_resolves": [{"locked_requirements": [{"artifacts": [{"algorithm": "sha256", "hash": "a615e69ae185e08fdd73e4715e260e2479c861b5740057fde6e8b4e3b7dd589f", "url": "https://files.pythonhosted.org/packages/9b/27/b2f98b627738b02dcac06ae9e2ab13f14ab906fe6dd6366050c76883d4b5/black-21.12b0-py3-none-any.whl"}, {"algorithm": "sha256", "hash": "77b80f693a569e2e527958459634f18df9b0ba2625ba4e0c2d5da5be42e6f2b3", "url": "https://files.pythonhosted.org/packages/f7/60/7a9775dc1b81a572eb26836c7e77c92bf454ada00693af4b2d2f2614971a/black-21.12b0.tar.gz"}], "project_name": "black", "requires_dists": ["aiohttp>=3.7.4; extra == \"d\"", "click>=7.1.2", "colorama>=0.4.3; extra == \"colorama\"", "dataclasses>=0.6; python_version < \"3.7\"", "ipython>=7.8.0; extra == \"jupyter\"", "mypy-extensions>=0.4.3", "pathspec<1,>=0.9.0", "platformdirs>=2", "tokenize-rt>=3.2.0; extra == \"jupyter\"", "tomli<2.0.0,>=0.2.6", "typed-ast>=1.4.2; python_version < \"3.8\" and implementation_name == \"cpython\"", "typed-ast>=1.4.3; extra == \"python2\"", "typing-extensions!=3.10.0.1; python_version >= \"3.10\"", "typing-extensions>=3.10.0.0", "uvloop>=0.15.2; extra == \"uvloop\""], "requires_python": ">=3.6.2", "version": "21.12b0"}, {"artifacts": [{"algorithm": "sha256", "hash": "6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1", "url": "https://files.pythonhosted.org/packages/4a/a8/0b2ced25639fb20cc1c9784de90a8c25f9504a7f18cd8b5397bd61696d7d/click-8.0.4-py3-none-any.whl"}, {"algorithm": "sha256", "hash": "8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb", "url": "https://files.pythonhosted.org/packages/dd/cf/706c1ad49ab26abed0b77a2f867984c1341ed7387b8030a6aa914e2942a0/click-8.0.4.tar.gz"}], "project_name": "click", "requires_dists": ["colorama; platform_system == \"Windows\"", "importlib-metadata; python_version < \"3.8\""], "requires_python": ">=3.6", "version": "8.0.4"}, {"artifacts": [{"algorithm": "sha256", "hash": "9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2", "url": "https://files.pythonhosted.org/packages/44/98/5b86278fbbf250d239ae0ecb724f8572af1c91f4a11edf4d36a206189440/colorama-0.4.4-py2.py3-none-any.whl"}, {"algorithm": "sha256", "hash": "5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", "url": "https://files.pythonhosted.org/packages/1f/bb/5d3246097ab77fa083a61bd8d3d527b7ae063c7d8e8671b1cf8c4ec10cbe/colorama-0.4.4.tar.gz"}], "project_name": "colorama", "requires_dists": [], "requires_python": "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7", "version": "0.4.4"}, {"artifacts": [{"algorithm": "sha256", "hash": "090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", "url": "https://files.pythonhosted.org/packages/5c/eb/975c7c080f3223a5cdaff09612f3a5221e4ba534f7039db34c35d95fa6a5/mypy_extensions-0.4.3-py2.py3-none-any.whl"}, {"algorithm": "sha256", "hash": "2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8", "url": "https://files.pythonhosted.org/packages/63/60/0582ce2eaced55f65a4406fc97beba256de4b7a95a0034c6576458c6519f/mypy_extensions-0.4.3.tar.gz"}], "project_name": "mypy-extensions", "requires_dists": ["typing>=3.5.3; python_version < \"3.5\""], "requires_python": null, "version": "0.4.3"}, {"artifacts": [{"algorithm": "sha256", "hash": "7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a", "url": "https://files.pythonhosted.org/packages/42/ba/a9d64c7bcbc7e3e8e5f93a52721b377e994c22d16196e2b0f1236774353a/pathspec-0.9.0-py2.py3-none-any.whl"}, {"algorithm": "sha256", "hash": "e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1", "url": "https://files.pythonhosted.org/packages/f6/33/436c5cb94e9f8902e59d1d544eb298b83c84b9ec37b5b769c5a0ad6edb19/pathspec-0.9.0.tar.gz"}], "project_name": "pathspec", "requires_dists": [], "requires_python": "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7", "version": "0.9"}, {"artifacts": [{"algorithm": "sha256", "hash": "027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788", "url": "https://files.pythonhosted.org/packages/ed/22/967181c94c3a4063fe64e15331b4cb366bdd7dfbf46fcb8ad89650026fec/platformdirs-2.5.2-py3-none-any.whl"}, {"algorithm": "sha256", "hash": "58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19", "url": "https://files.pythonhosted.org/packages/ff/7b/3613df51e6afbf2306fc2465671c03390229b55e3ef3ab9dd3f846a53be6/platformdirs-2.5.2.tar.gz"}], "project_name": "platformdirs", "requires_dists": ["appdirs==1.4.4; extra == \"test\"", "furo>=2021.7.5b38; extra == \"docs\"", "proselint>=0.10.2; extra == \"docs\"", "pytest-cov>=2.7; extra == \"test\"", "pytest-mock>=3.6; extra == \"test\"", "pytest>=6; extra == \"test\"", "sphinx-autodoc-typehints>=1.12; extra == \"docs\"", "sphinx>=4; extra == \"docs\""], "requires_python": ">=3.7", "version": "2.5.2"}, {"artifacts": [{"algorithm": "sha256", "hash": "e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c", "url": "https://files.pythonhosted.org/packages/05/e4/74f9440db36734d7ba83c574c1e7024009ce849208a41f90e94a134dc6d1/tomli-1.2.3-py3-none-any.whl"}, {"algorithm": "sha256", "hash": "05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f", "url": "https://files.pythonhosted.org/packages/fb/2e/d0a8276b0cf9b9e34fd0660c330acc59656f53bb2209adc75af863a3582d/tomli-1.2.3.tar.gz"}], "project_name": "tomli", "requires_dists": [], "requires_python": ">=3.6", "version": "1.2.3"}, {"artifacts": [{"algorithm": "sha256", "hash": "6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708", "url": "https://files.pythonhosted.org/packages/75/e1/932e06004039dd670c9d5e1df0cd606bf46e29a28e65d5bb28e894ea29c9/typing_extensions-4.2.0-py3-none-any.whl"}, {"algorithm": "sha256", "hash": "f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376", "url": "https://files.pythonhosted.org/packages/fe/71/1df93bd59163c8084d812d166c907639646e8aac72886d563851b966bf18/typing_extensions-4.2.0.tar.gz"}], "project_name": "typing-extensions", "requires_dists": [], "requires_python": ">=3.7", "version": "4.2"}], "platform_tag": ["cp39", "cp39", "macosx_11_0_arm64"]}], "pex_version": "2.1.71", "prefer_older_binary": false, "requirements": ["black==21.12b0", "click<8.1"], "requires_python": [">=3.9"], "resolver_version": "pip-2020-resolver", "style": "universal", "transitive": true, "use_pep517": null}
\ No newline at end of file
diff --git a/macros.py b/macros.py
new file mode 100644
index 000000000..8239c6dab
--- /dev/null
+++ b/macros.py
@@ -0,0 +1,3 @@
+def foo(**kwargs):
+    raise Exception("test")
+
diff --git a/pants.toml b/pants.toml
index 23411acd6..5dfee7b3b 100644
--- a/pants.toml
+++ b/pants.toml
@@ -34,6 +34,10 @@ plugins = [
   "toolchain.pants.plugin==0.19.0",
 ]
 
+build_file_prelude_globs = [
+  "macros.py",
+]
+
 # The invalidation globs cover the PYTHONPATH by default, but we exclude some files that are on the
 # path but not consumed by python, and additionally add the rust code.
 pantsd_invalidation_globs.add = [

```

Then you'll get 

```
./pants list : 
pants.engine.internals.parser.ParseError: Name 'exception' is not defined.

If you expect to see more symbols activated in the below list, refer to https://www.pantsbuild.org/v2.13/docs/enabling-backends for all available backends to activate.

All registered symbols: ['_generator_sources_helper', 'archive', 'build_file_dir', 'deploy_jar', 'docker_image', 'experimental_run_shell_command', 'experimental_shell_command', 'file', 'files', 'foo', 'go_binary', 'go_mod', 'go_package', 'go_third_party_package', 'java_source', 'java_sources', 'junit_test', 'junit_tests', 'jvm_artifact', 'jvm_war', 'parametrize', 'pex_binaries', 'pex_binary', 'pipenv_requirements', 'poetry_requirements', 'pyoxidizer_binary', 'python_artifact', 'python_distribution', 'python_requirement', 'python_requirements', 'python_source', 'python_sources', 'python_test', 'python_test_utils', 'python_tests', 'relocated_files', 'resource', 'resources', 'scala_junit_test', 'scala_junit_tests', 'scala_source', 'scala_sources', 'scalac_plugin', 'scalatest_test', 'scalatest_tests', 'setup_py', 'shell_source', 'shell_sources', 'shunit2_test', 'shunit2_tests', 'target', 'vcs_version']
```

I can't tell if this is a regression. Our docs have claimed from the start that you can raise exceptions.


Discussion:
I can confirm this is happening.

```py
def some_macro(**kwargs):
    try:
        resolve_val = kwargs['resolve']
    except KeyError:
        raise KeyError("Specify the `resolve` field.")
```
outputs
```
15:21:41.17 [ERROR] 1 Exception encountered:

  MappingError: Failed to parse ./foo/BUILD:
Name 'keyerror' is not defined.
```

Are there any workarounds?
None that I know of. Any interest in tackling this? I'm happy to point you to the relevant code.
One workaround would be to define an `object` (any symbol, really) for your exception type in a plugin. Example: https://github.com/pantsbuild/pants/blob/9ca84b5eb6ff66e224ee4f4e0a5a7aec0c922843/src/python/pants/backend/python/register.py#L57-L58 ... that would put it in scope in your macros.
A very nasty but backwards compatible workaround:
```
$ cat expose-builtins 
exec(f"from builtins {'IMPORT'.lower()} *")

$ cat macro-uses-builtins 
def foo(name=None):
  if not name:
     raise Exception("A name is required.")
  python_sources(name=name)
   
$ cat BUILD.example 
foo(name="jake")
foo()

$ ./pants --build-file-prelude-globs='*-builtins' list :
15:12:53.20 [ERROR] 1 Exception encountered:

Engine traceback:
  in select
  in pants.backend.project_info.list_targets.list_targets
  in pants.engine.internals.specs_rules.resolve_addresses_from_specs
  in pants.engine.internals.specs_rules.resolve_addresses_from_raw_specs
  in pants.engine.internals.specs_rules.addresses_from_raw_specs_without_file_owners
  in pants.engine.internals.build_files.ensure_address_family ()
  in pants.engine.internals.build_files.parse_address_family ()
Traceback (most recent call last):
  File "/home/jsirois/dev/pantsbuild/pants/src/python/pants/engine/internals/mapper.py", line 47, in parse
    target_adaptors = parser.parse(filepath, build_file_content, extra_symbols, defaults)
  File "/home/jsirois/dev/pantsbuild/pants/src/python/pants/engine/internals/parser.py", line 167, in parse
    exec(build_file_content, global_symbols)
  File "<string>", line 2, in <module>
  File "macro-uses-builtins", line 3, in foo
    raise Exception("A name is required.")
Exception: A name is required.

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/jsirois/dev/pantsbuild/pants/src/python/pants/engine/internals/selectors.py", line 705, in native_engine_generator_send
    res = func.send(arg)
  File "/home/jsirois/dev/pantsbuild/pants/src/python/pants/engine/internals/build_files.py", line 181, in parse_address_family
    for fc in digest_contents
  File "/home/jsirois/dev/pantsbuild/pants/src/python/pants/engine/internals/build_files.py", line 181, in <listcomp>
    for fc in digest_contents
  File "/home/jsirois/dev/pantsbuild/pants/src/python/pants/engine/internals/mapper.py", line 49, in parse
    raise MappingError(f"Failed to parse ./{filepath}:\n{e}")
pants.base.exceptions.MappingError: Failed to parse ./BUILD.example:
A name is required.
```

When we ship support for builtins, just remove `expose-builtins` from your preludes.

Resolving patch:
diff --git a/src/python/pants/engine/internals/build_files.py b/src/python/pants/engine/internals/build_files.py
--- a/src/python/pants/engine/internals/build_files.py
+++ b/src/python/pants/engine/internals/build_files.py
@@ -3,6 +3,7 @@
 
 from __future__ import annotations
 
+import builtins
 import os.path
 from dataclasses import dataclass
 from pathlib import PurePath
@@ -55,20 +56,23 @@ async def evaluate_preludes(build_file_options: BuildFileOptions) -> BuildFilePr
             glob_match_error_behavior=GlobMatchErrorBehavior.ignore,
         ),
     )
-    values: dict[str, Any] = {}
+    globals: dict[str, Any] = {
+        **{name: getattr(builtins, name) for name in dir(builtins) if name.endswith("Error")},
+    }
+    locals: dict[str, Any] = {}
     for file_content in prelude_digest_contents:
         try:
             file_content_str = file_content.content.decode()
             content = compile(file_content_str, file_content.path, "exec")
-            exec(content, values)
+            exec(content, globals, locals)
         except Exception as e:
             raise Exception(f"Error parsing prelude file {file_content.path}: {e}")
         error_on_imports(file_content_str, file_content.path)
     # __builtins__ is a dict, so isn't hashable, and can't be put in a FrozenDict.
     # Fortunately, we don't care about it - preludes should not be able to override builtins, so we just pop it out.
     # TODO: Give a nice error message if a prelude tries to set a expose a non-hashable value.
-    values.pop("__builtins__", None)
-    return BuildFilePreludeSymbols(FrozenDict(values))
+    locals.pop("__builtins__", None)
+    return BuildFilePreludeSymbols(FrozenDict(locals))
 
 
 @rule


Test patch:
diff --git a/src/python/pants/engine/internals/build_files_test.py b/src/python/pants/engine/internals/build_files_test.py
--- a/src/python/pants/engine/internals/build_files_test.py
+++ b/src/python/pants/engine/internals/build_files_test.py
@@ -118,6 +118,19 @@ def make_target():
         run_prelude_parsing_rule(prelude_content)
 
 
+def test_prelude_exceptions() -> None:
+    prelude_content = dedent(
+        """\
+        def abort():
+            raise ValueError
+        """
+    )
+    result = run_prelude_parsing_rule(prelude_content)
+    assert "ValueError" not in result.symbols
+    with pytest.raises(ValueError):
+        result.symbols["abort"]()
+
+
 class ResolveField(StringField):
     alias = "resolve"