Repository: pantsbuild/pants

Issue:
v2 engine non-deterministically pollutes import universe across BUILD files
```
$ cat play/BUILD
import os

os.path.join("foo", "bar")

java_library()
```

```
$ cat play2/BUILD
# Note: This file *should* import os, but doesn't

os.path.join("foo", "bar")

java_library()
```

v1 engine deterministically errors, as it should:
```
$ ./pants --no-enable-v2-engine list play: play2:
Exception caught: (<class 'pants.build_graph.address_lookup_error.AddressLookupError'>)

Exception message: name 'os' is not defined
 while executing BUILD file BuildFile(play2/BUILD, FileSystemProjectTree(/Users/dwagnerhall/workspace/source))
 Loading addresses from 'play2' failed.
```

v2 engine sometimes succeeds:
```
$ ./pants --enable-v2-engine list play: play2:
play:play
play2:play2
```
and sometimes fails:
```
$ ./pants --enable-v2-engine list play: play2:
Exception caught: (<class 'pants.build_graph.address_lookup_error.AddressLookupError'>)

Exception message: Build graph construction failed for (SiblingAddresses(directory='play2'), Select(Collection.of(HydratedTarget))): MappingError Failed to parse play2/BUILD:
name 'os' is not defined
```

Remove the import in the other file, and the v2 engine deterministically fails, as it should.
```
$ sed -e '/import/d' -i '' play/BUILD
$ ./pants --enable-v2-engine list play: play2:
Exception caught: (<class 'pants.build_graph.address_lookup_error.AddressLookupError'>)

Exception message: Build graph construction failed for (SiblingAddresses(directory='play'), Select(Collection.of(HydratedTarget))): MappingError Failed to parse play/BUILD:
name 'os' is not defined
```

I guess the BUILD file eval environment is consistently polluted, but depending on what order the BUILD files are evaluated (which appears to be non-deterministic with the v2 engine), it non-deterministically covers up the error.


Discussion:
It looks like this is because a statement like:
```
python_code_str = "import thing"
six.exec_(python_code_str, globals)
```
...will mutate the globals to bring `thing` into scope. We used to recreate the globals/symbols list per file, but no longer do that.

A 95% fix would be to shallow clone the symbols, so that newly defined symbols are discarded. The last 5% would require deeply cloning the symbols, which would defend against mutation of members of the symbols.
And the relevant file for this `exec_` is here: https://github.com/pantsbuild/pants/blob/805e4aaec4f2cfdb741e6bf209d3e7c3b337d499/src/python/pants/engine/legacy/parser.py#L124

Resolving patch:
diff --git a/src/python/pants/engine/legacy/parser.py b/src/python/pants/engine/legacy/parser.py
--- a/src/python/pants/engine/legacy/parser.py
+++ b/src/python/pants/engine/legacy/parser.py
@@ -120,6 +120,10 @@ def parse(self, filepath, filecontent):
     python = filecontent
 
     # Mutate the parse context for the new path, then exec, and copy the resulting objects.
+    # We execute with a (shallow) clone of the symbols as a defense against accidental
+    # pollution of the namespace via imports or variable definitions. Defending against
+    # _intentional_ mutation would require a deep clone, which doesn't seem worth the cost at
+    # this juncture.
     self._parse_context._storage.clear(os.path.dirname(filepath))
-    six.exec_(python, self._symbols)
+    six.exec_(python, dict(self._symbols))
     return list(self._parse_context._storage.objects)
diff --git a/src/python/pants/engine/parser.py b/src/python/pants/engine/parser.py
--- a/src/python/pants/engine/parser.py
+++ b/src/python/pants/engine/parser.py
@@ -29,6 +29,11 @@ def constraint(self):
     return Exactly(*symbol_table_types, description='symbol table types')
 
 
+class EmptyTable(SymbolTable):
+  def table(self):
+    return {}
+
+
 class Parser(AbstractClass):
 
   @abstractmethod


Test patch:
diff --git a/tests/python/pants_test/engine/legacy/BUILD b/tests/python/pants_test/engine/legacy/BUILD
--- a/tests/python/pants_test/engine/legacy/BUILD
+++ b/tests/python/pants_test/engine/legacy/BUILD
@@ -133,6 +133,16 @@ python_tests(
   tags = {'integration'}
 )
 
+python_tests(
+  name = 'parser',
+  sources = ['test_parser.py'],
+  dependencies = [
+    'src/python/pants/build_graph',
+    'src/python/pants/engine/legacy:parser',
+    'src/python/pants/engine:parser',
+  ]
+)
+
 python_tests(
   name = 'structs',
   sources = ['test_structs.py'],
diff --git a/tests/python/pants_test/engine/legacy/test_parser.py b/tests/python/pants_test/engine/legacy/test_parser.py
new file mode 100644
--- /dev/null
+++ b/tests/python/pants_test/engine/legacy/test_parser.py
@@ -0,0 +1,24 @@
+# coding=utf-8
+# Copyright 2017 Pants project contributors (see CONTRIBUTORS.md).
+# Licensed under the Apache License, Version 2.0 (see LICENSE).
+
+from __future__ import (absolute_import, division, generators, nested_scopes, print_function,
+                        unicode_literals, with_statement)
+
+import unittest
+
+from pants.build_graph.build_file_aliases import BuildFileAliases
+from pants.engine.legacy.parser import LegacyPythonCallbacksParser
+from pants.engine.parser import EmptyTable
+
+
+class LegacyPythonCallbacksParserTest(unittest.TestCase):
+
+  def test_no_import_sideeffects(self):
+    # A parser with no symbols registered.
+    parser = LegacyPythonCallbacksParser(EmptyTable(), BuildFileAliases())
+    # Call to import a module should succeed.
+    parser.parse('/dev/null', '''import os; os.path.join('x', 'y')''')
+    # But the imported module should not be visible as a symbol in further parses.
+    with self.assertRaises(NameError):
+      parser.parse('/dev/null', '''os.path.join('x', 'y')''')
diff --git a/tests/python/pants_test/engine/scheduler_test_base.py b/tests/python/pants_test/engine/scheduler_test_base.py
--- a/tests/python/pants_test/engine/scheduler_test_base.py
+++ b/tests/python/pants_test/engine/scheduler_test_base.py
@@ -10,19 +10,12 @@
 
 from pants.base.file_system_project_tree import FileSystemProjectTree
 from pants.engine.nodes import Return
-from pants.engine.parser import SymbolTable
 from pants.engine.scheduler import LocalScheduler
 from pants.util.contextutil import temporary_file_path
 from pants.util.dirutil import safe_mkdtemp, safe_rmtree
 from pants_test.engine.util import init_native
 
 
-class EmptyTable(SymbolTable):
-  @classmethod
-  def table(cls):
-    return {}
-
-
 class SchedulerTestBase(object):
   """A mixin for classes (tests, presumably) which need to create temporary schedulers.
 
diff --git a/tests/python/pants_test/engine/test_parsers.py b/tests/python/pants_test/engine/test_parsers.py
--- a/tests/python/pants_test/engine/test_parsers.py
+++ b/tests/python/pants_test/engine/test_parsers.py
@@ -29,12 +29,6 @@ def __eq__(self, other):
     return isinstance(other, Bob) and self._key() == other._key()
 
 
-class EmptyTable(parser.SymbolTable):
-  @classmethod
-  def table(cls):
-    return {}
-
-
 class TestTable(parser.SymbolTable):
   @classmethod
   def table(cls):
@@ -53,7 +47,7 @@ def parse(parser, document, **args):
 
 class JsonParserTest(unittest.TestCase):
   def parse(self, document, symbol_table=None, **kwargs):
-    symbol_table = symbol_table or EmptyTable()
+    symbol_table = symbol_table or parser.EmptyTable()
     return parse(parsers.JsonParser(symbol_table), document, **kwargs)
 
   def round_trip(self, obj, symbol_table=None):
@@ -229,7 +223,7 @@ def test_error_presentation(self):
     """).strip()
     filepath = '/dev/null'
     with self.assertRaises(parser.ParseError) as exc:
-      parsers.JsonParser(EmptyTable()).parse(filepath, document)
+      parsers.JsonParser(parser.EmptyTable()).parse(filepath, document)
 
     # Strip trailing whitespace from the message since our expected literal below will have
     # trailing ws stripped via editors and code reviews calling for it.
@@ -327,7 +321,7 @@ def test_no_symbol_table(self):
       hobbies=[1, 2, 3]
     )
     """)
-    results = parse(parsers.PythonAssignmentsParser(EmptyTable()), document)
+    results = parse(parsers.PythonAssignmentsParser(parser.EmptyTable()), document)
     self.assertEqual([Bob(name='nancy', hobbies=[1, 2, 3])], results)
 
     # No symbol table was used so no `type_alias` plumbing can be expected.