Merge branch 'master' into master
diff --git a/compiler/cli.py b/compiler/cli.py
index 6efde2f..91fd853 100644
--- a/compiler/cli.py
+++ b/compiler/cli.py
@@ -23,6 +23,17 @@
 from subpar.compiler import python_archive
 
 
+def bool_from_string(raw_value):
+    """Parse a boolean command line argument value"""
+    if raw_value == 'True':
+        return True
+    elif raw_value == 'False':
+        return False
+    else:
+        raise argparse.ArgumentTypeError(
+            'Value must be True or False, got %r instead.' % raw_value)
+
+
 def make_command_line_parser():
     """Return an object that can parse this program's command line"""
     parser = argparse.ArgumentParser(
@@ -57,8 +68,8 @@
         help='Safe to import modules and access datafiles straight from zip ' +
         'archive?  If False, all files will be extracted to a temporary ' +
         'directory at the start of execution.',
-        type=bool,
-        default=True)
+        type=bool_from_string,
+        required=True)
     return parser
 
 
@@ -120,5 +131,6 @@
         output_filename=args.outputpar,
         manifest_filename=args.manifest_file,
         manifest_root=args.manifest_root,
+        zip_safe=args.zip_safe,
     )
     par.create()
diff --git a/compiler/cli_test.py b/compiler/cli_test.py
index 7db41b4..56a6f18 100644
--- a/compiler/cli_test.py
+++ b/compiler/cli_test.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import argparse
 import unittest
 
 from subpar.compiler import cli
@@ -21,6 +22,14 @@
 
 class CliTest(unittest.TestCase):
 
+    def test_bool_from_string(self):
+        self.assertIs(cli.bool_from_string('True'), True)
+        self.assertIs(cli.bool_from_string('False'), False)
+        with self.assertRaises(argparse.ArgumentTypeError):
+            cli.bool_from_string('')
+        with self.assertRaises(argparse.ArgumentTypeError):
+            cli.bool_from_string('Yes')
+
     def test_make_command_line_parser(self):
         parser = cli.make_command_line_parser()
         args = parser.parse_args([
diff --git a/compiler/python_archive.py b/compiler/python_archive.py
index 832cb28..3dfdba0 100755
--- a/compiler/python_archive.py
+++ b/compiler/python_archive.py
@@ -45,7 +45,7 @@
 _boilerplate_template = """\
 # Boilerplate added by subpar/compiler/python_archive.py
 from %(runtime_package)s import support as _
-_.setup(import_roots=%(import_roots)s)
+_.setup(import_roots=%(import_roots)s, zip_safe=%(zip_safe)s)
 del _
 # End boilerplate
 """
@@ -76,6 +76,7 @@
                  manifest_filename,
                  manifest_root,
                  output_filename,
+                 zip_safe,
                  ):
         self.main_filename = main_filename
 
@@ -84,6 +85,7 @@
         self.manifest_filename = manifest_filename
         self.manifest_root = manifest_root
         self.output_filename = output_filename
+        self.zip_safe = zip_safe
 
         self.compression = zipfile.ZIP_DEFLATED
 
@@ -141,6 +143,7 @@
         boilerplate_contents = _boilerplate_template % {
             'runtime_package': _runtime_package,
             'import_roots': str(import_roots),
+            'zip_safe': self.zip_safe,
         }
         return boilerplate_contents.encode('ascii').decode('ascii')
 
diff --git a/compiler/python_archive_test.py b/compiler/python_archive_test.py
index 67ae497..17d5049 100644
--- a/compiler/python_archive_test.py
+++ b/compiler/python_archive_test.py
@@ -46,6 +46,7 @@
         self.output_filename = os.path.join(self.output_dir, 'output.par')
         self.interpreter = '/usr/bin/python2'
         self.import_roots = []
+        self.zip_safe = True
 
     def _construct(self, manifest_filename=None):
         return python_archive.PythonArchive(
@@ -55,6 +56,7 @@
             manifest_filename=(manifest_filename or self.manifest_file.name),
             manifest_root=os.getcwd(),
             output_filename=self.output_filename,
+            zip_safe=self.zip_safe,
         )
 
     def test_create_manifest_not_found(self):
diff --git a/runtime/BUILD b/runtime/BUILD
index 5f8edff..7666720 100644
--- a/runtime/BUILD
+++ b/runtime/BUILD
@@ -18,5 +18,6 @@
     srcs_version = "PY2AND3",
     deps = [
         ":support",
+        "//compiler:test_utils",
     ],
 )
diff --git a/runtime/support.py b/runtime/support.py
index 8d25dc6..fcfc04e 100644
--- a/runtime/support.py
+++ b/runtime/support.py
@@ -52,10 +52,14 @@
 
 """
 
+import atexit
 import os
 import pkgutil
+import shutil
 import sys
+import tempfile
 import warnings
+import zipfile
 import zipimport
 
 
@@ -67,22 +71,53 @@
 
 
 def _find_archive():
-    """Find the path to the currently executing .par file"""
+    """Find the path to the currently executing .par file
+
+    We don't handle the case where prefix is non-empty.
+    """
     main = sys.modules.get('__main__')
     if not main:
         _log('# __main__ module not found')
         return None
-    main_file = getattr(main, '__file__', None)
-    if not main_file:
-        _log('# __main__.__file__ not set')
+    main_loader = getattr(main, '__loader__')
+    if not main_loader:
+        _log('# __main__.__loader__ not set')
         return None
-    archive_path = os.path.dirname(main_file)
-    if archive_path == '':
-        _log('# unexpected __main__.__file__ is %s' % main_file)
+    prefix = getattr(main_loader, 'prefix')
+    if prefix != '':
+        _log('# unexpected prefix for __main__.__loader__ is %s' %
+             main_loader.prefix)
+        return None
+    archive_path = getattr(main_loader, 'archive')
+    if not archive_path:
+        _log('# missing archive for __main__.__loader__')
         return None
     return archive_path
 
 
+def _extract_files(archive_path):
+    """Extract the contents of this .par file to disk.
+
+    This creates a temporary directory, and registers an atexit
+    handler to clean that directory on program exit.  Extraction and
+    cleanup will potentially use significant time and disk space.
+
+    Returns:
+        Directory where contents were extracted to.
+    """
+    extract_dir = tempfile.mkdtemp()
+    def _extract_files_cleanup():
+        shutil.rmtree(extract_dir, ignore_errors=True)
+    atexit.register(_extract_files_cleanup)
+    _log('# extracting %s to %s' % (archive_path, extract_dir))
+
+    zip_file = zipfile.ZipFile(archive_path, mode='r')
+    zip_file.extractall(extract_dir)
+    zip_file.close()
+
+    return extract_dir
+
+
 def _version_check_pkg_resources(pkg_resources):
     """Check that pkg_resources supports the APIs we need."""
     # Check that pkg_resources is new enough.
@@ -147,8 +182,7 @@
             # Convert a virtual filename (full path to file) into a
             # zipfile subpath usable with the zipimport directory
             # cache for our target archive
-            while fspath.endswith(os.sep):
-                fspath = fspath[:-1]
+            fspath = fspath.rstrip(os.sep)
             if fspath == self.loader.archive:
                 return ''
             if fspath.startswith(self.zip_pre):
@@ -236,22 +270,58 @@
                     pkg_resources.working_set.add(dist, entry, insert=False,
                                                   replace=True)
 
+def _initialize_import_path(import_roots, import_prefix):
+    """Add extra entries to PYTHONPATH so that modules can be imported."""
+    # We try to match to order of Bazel's stub
+    full_roots = [
+        os.path.join(import_prefix, import_root)
+        for import_root in import_roots]
+    sys.path[1:1] = full_roots
+    _log('# adding %s to sys.path' % full_roots)
 
-def setup(import_roots=None):
-    """Initialize subpar run-time support"""
-    # Add third-party library entries to sys.path
+
+def setup(import_roots, zip_safe):
+    """Initialize subpar run-time support
+
+    Args:
+      import_root (list): subdirs inside .par file to add to the
+                          module import path at runtime.
+      zip_safe (bool): If False, extract the .par file contents to a
+                       temporary directory, and import everything from
+                       that directory.
+
+    Returns:
+      True if setup was successful, else False
+    """
     archive_path = _find_archive()
     if not archive_path:
         warnings.warn('Failed to initialize .par file runtime support',
-                      ImportWarning)
-        return
+                      UserWarning)
+        return False
+    if sys.path[0] != archive_path:
+        warnings.warn('Failed to initialize .par file runtime support. ' +
+                      'archive_path was %r, sys.path was %r' % (
+                          archive_path, sys.path),
+                      UserWarning)
+        return False
 
-    # We try to match to order of Bazel's stub
-    for import_root in reversed(import_roots or []):
-        new_path = os.path.join(archive_path, import_root)
-        _log('# adding %s to sys.path' % new_path)
-        sys.path.insert(1, new_path)
+    # Extract files to disk if necessary
+    if not zip_safe:
+        extract_dir = _extract_files(archive_path)
+        # sys.path[0] is the name of the executing .par file.  Point
+        # it to the extract directory instead, so that Python searches
+        # there for imports.
+        sys.path[0] = extract_dir
+        import_prefix = extract_dir
+    else: # Import directly from .par file
+        extract_dir = None
+        import_prefix = archive_path
+
+    # Initialize import path
+    _initialize_import_path(import_roots, import_prefix)
 
     # Add hook for package metadata
     _setup_pkg_resources('pkg_resources')
     _setup_pkg_resources('pip._vendor.pkg_resources')
+
+    return True
diff --git a/runtime/support_test.py b/runtime/support_test.py
index de0b9c9..e3a2741 100644
--- a/runtime/support_test.py
+++ b/runtime/support_test.py
@@ -13,13 +13,69 @@
 # limitations under the License.
 
 import io
+import os
 import sys
+import tempfile
 import unittest
+import zipfile
 
+from subpar.compiler import test_utils
 from subpar.runtime import support
 
+# `import pip` can cause arbitrary sys.path changes,
+# especially if using the Debian `python-pip` package or
+# similar.  Do it first to get those changes out of the
+# way.
+old_sys_path = sys.path
+try:
+    mock_sys_path = list(sys.path)
+    sys.path = mock_sys_path
+    import pip  # noqa
+except ImportError:
+    pass
+finally:
+    sys.path = old_sys_path
 
+
+# We assume this test isn't run as a par file.
 class SupportTest(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        # Create zipfile for tests to read
+        tmpdir = test_utils.mkdtemp()
+        zipfile_name = os.path.join(tmpdir, '_support_test_sample.par')
+
+        z = zipfile.ZipFile(zipfile_name, 'w')
+        entry_name = '_support_test_helper_script.py'
+        entry_data = b'print("Hello world")'
+        z.writestr(entry_name, entry_data)
+
+        z.close()
+
+        cls.zipfile_name = zipfile_name
+        cls.entry_name = entry_name
+        cls.entry_data = entry_data
+
+        # Create mock loader object
+        class MockLoader(object): pass
+        mock_loader = MockLoader()
+        mock_loader.archive = zipfile_name
+        mock_loader.prefix = ''
+        main = sys.modules.get('__main__')
+        old_loader = getattr(main, '__loader__', None)
+        main.__loader__ = mock_loader
+
+        cls.mock_loader = mock_loader
+        cls.old_loader = old_loader
+
+    @classmethod
+    def tearDownClass(cls):
+        # Cleanup zipfile
+        os.remove(cls.zipfile_name)
+
+        # Cleanup loader mock
+        main = sys.modules.get('__main__')
+        main.__loader__ = cls.old_loader
 
     def test__log(self):
         old_stderr = sys.stderr
@@ -38,8 +94,22 @@
 
     def test__find_archive(self):
         # pylint: disable=protected-access
-        path = support._find_archive()
-        self.assertNotEqual(path, None)
+        archive_path = support._find_archive()
+
+        self.assertNotEqual(archive_path, None)
+        self.assertTrue(zipfile.is_zipfile(archive_path))
+
+    def test__extract_files(self):
+        # Extract zipfile
+        extract_path = support._extract_files(self.zipfile_name)
+
+        # Check results
+        self.assertTrue(os.path.isdir(extract_path))
+        extracted_file = os.path.join(extract_path, self.entry_name)
+        self.assertTrue(os.path.isfile(extracted_file))
+        with open(extracted_file, 'rb') as f:
+            actual_data = f.read()
+            self.assertEqual(actual_data, self.entry_data)
 
     def test__version_check(self):
         class MockModule(object): pass
@@ -60,42 +130,48 @@
         self.assertTrue(support._version_check_pkg_resources(pkg_resources))
 
     def test_setup(self):
-        old_sys_path = sys.path
-        mock_sys_path = list(sys.path)
-        sys.path = mock_sys_path
-        # `import pip` can cause arbitrary sys.path changes,
-        # especially if using the Debian `python-pip` package or
-        # similar.  Do it first to get those changes out of the
-        # way.
-        try:
-            import pip  # noqa
-        except ImportError:
-            pass
-        finally:
-            sys.path = old_sys_path
-
-        # Run setup()
+        # Run setup() without file extraction
         old_sys_path = sys.path
         try:
             mock_sys_path = list(sys.path)
+            mock_sys_path[0] = self.zipfile_name
             sys.path = mock_sys_path
-            support.setup(import_roots=['some_root', 'another_root'])
+            success = support.setup(import_roots=['some_root', 'another_root'], zip_safe=True)
+            self.assertTrue(success)
         finally:
             sys.path = old_sys_path
 
         # Check results
-        self.assertTrue(mock_sys_path[1].endswith('subpar/runtime/some_root'),
-                        mock_sys_path)
         self.assertTrue(
-            mock_sys_path[2].endswith('subpar/runtime/another_root'),
+            mock_sys_path[1].endswith('some_root'),
             mock_sys_path)
-        self.assertEqual(mock_sys_path[0], sys.path[0])
+        self.assertTrue(
+            mock_sys_path[2].endswith('another_root'),
+            mock_sys_path)
         # If we have no pkg_resources, or a really old version of
         # pkg_resources, setup skips some things
         module = sys.modules.get('pkg_resources', None)
         if module and support._version_check_pkg_resources(module):
             self.assertEqual(mock_sys_path[3:], sys.path[1:])
 
+    def test_setup__extract(self):
+        # Run setup() with file extraction
+        old_sys_path = sys.path
+        try:
+            mock_sys_path = list(sys.path)
+            mock_sys_path[0] = self.zipfile_name
+            sys.path = mock_sys_path
+            success = support.setup(import_roots=['some_root'], zip_safe=False)
+            self.assertTrue(success)
+        finally:
+            sys.path = old_sys_path
+
+        # Check results
+        self.assertNotEqual(mock_sys_path[0], self.zipfile_name)
+        self.assertTrue(
+            os.path.isdir(mock_sys_path[0]),
+            mock_sys_path)
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/subpar.bzl b/subpar.bzl
index 378e328..b433115 100644
--- a/subpar.bzl
+++ b/subpar.bzl
@@ -190,13 +190,14 @@
     for arguments and usage.
     """
     compiler = kwargs.pop('compiler', DEFAULT_COMPILER)
+    zip_safe = kwargs.pop('zip_safe', True)
     native.py_binary(name=name, **kwargs)
+
     main = kwargs.get('main', name + '.py')
     imports = kwargs.get('imports')
     default_python_version = kwargs.get('default_python_version', 'PY2')
     visibility = kwargs.get('visibility')
     testonly = kwargs.get('testonly', False)
-    zip_safe = kwargs.get('zip_safe', True)
     parfile(name=name + '.par', src=name, main=main, imports=imports,
             default_python_version=default_python_version, visibility=visibility,
             compiler=compiler,
@@ -209,13 +210,14 @@
     Just like par_binary, but for py_test instead of py_binary.  Useful if you
     specifically need to test a module's behaviour when used in a .par binary.
     """
+    zip_safe = kwargs.pop('zip_safe', True)
     native.py_test(name=name, **kwargs)
+
     main = kwargs.get('main', name + '.py')
     imports = kwargs.get('imports')
     default_python_version = kwargs.get('default_python_version', 'PY2')
     visibility = kwargs.get('visibility')
     testonly = kwargs.get('testonly', True)
-    zip_safe = kwargs.get('zip_safe', True)
     parfile_test(
         name=name + '.par', src=name, main=main, imports=imports,
         default_python_version=default_python_version, visibility=visibility,
diff --git a/tests/BUILD b/tests/BUILD
index 0d215bc..43a602c 100644
--- a/tests/BUILD
+++ b/tests/BUILD
@@ -83,10 +83,22 @@
 )
 
 par_binary(
-    name = "package_import_roots/import_roots",
+    name = "package_extract/extract",
     srcs = [
-        "package_import_roots/import_roots.py",
+        "package_extract/extract.py",
+        "package_extract/extract_helper.py",
+        "package_extract/extract_helper_package/__init__.py",
     ],
+    data = ["package_extract/extract_helper_package/extract_dat.txt"],
+    imports = ["package_extract"],
+    main = "package_extract/extract.py",
+    srcs_version = "PY2AND3",
+    zip_safe = False,
+)
+
+par_binary(
+    name = "package_import_roots/import_roots",
+    srcs = ["package_import_roots/import_roots.py"],
     imports = ["package_import_roots"],
     main = "package_import_roots/import_roots.py",
     srcs_version = "PY2AND3",
@@ -159,6 +171,7 @@
     ),
     ("direct_dependency", "//tests/package_b:b", "tests/package_b/b"),
     ("external_workspace", "//tests:package_e/e", "tests/package_e/e"),
+    ("extract", "//tests:package_extract/extract", "tests/package_extract/extract"),
     ("import_root", "//tests:package_d/d", "tests/package_d/d"),
     (
         "import_roots",
diff --git a/tests/package_extract/extract.py b/tests/package_extract/extract.py
new file mode 100644
index 0000000..96c8b71
--- /dev/null
+++ b/tests/package_extract/extract.py
@@ -0,0 +1,43 @@
+# Copyright 2018 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Integration test program for Subpar
+
+Tests file extraction functionality (zip_safe=False)
+"""
+
+import os
+import pkgutil
+import sys
+
+
+def main():
+    print('In extract.py main()')
+
+    # Test that imports are from real files on disk.  Slightly tricky
+    # to test, since the 'subpar' package is imported before we
+    # extract and setup sys.path, so we can't "import subpar.test.something"
+    import extract_helper
+    assert os.path.isfile(extract_helper.__file__), (
+        extract_helper.__file__, sys.path)
+    import extract_helper_package
+    assert os.path.isfile(extract_helper_package.__file__), (
+        extract_helper_package.__file__, sys.path)
+
+    # Test resource extraction
+    dat = pkgutil.get_data('extract_helper_package', 'extract_dat.txt')
+    assert (dat == b'Dummy data file for extract.py\n'), dat
+
+if __name__ == '__main__':
+    main()
diff --git a/tests/package_extract/extract_PY2_filelist.txt b/tests/package_extract/extract_PY2_filelist.txt
new file mode 100644
index 0000000..bed1830
--- /dev/null
+++ b/tests/package_extract/extract_PY2_filelist.txt
@@ -0,0 +1,11 @@
+__main__.py
+subpar/__init__.py
+subpar/runtime/__init__.py
+subpar/runtime/support.py
+subpar/tests/__init__.py
+subpar/tests/package_extract/__init__.py
+subpar/tests/package_extract/extract
+subpar/tests/package_extract/extract.py
+subpar/tests/package_extract/extract_helper.py
+subpar/tests/package_extract/extract_helper_package/__init__.py
+subpar/tests/package_extract/extract_helper_package/extract_dat.txt
diff --git a/tests/package_extract/extract_PY3_filelist.txt b/tests/package_extract/extract_PY3_filelist.txt
new file mode 100644
index 0000000..bed1830
--- /dev/null
+++ b/tests/package_extract/extract_PY3_filelist.txt
@@ -0,0 +1,11 @@
+__main__.py
+subpar/__init__.py
+subpar/runtime/__init__.py
+subpar/runtime/support.py
+subpar/tests/__init__.py
+subpar/tests/package_extract/__init__.py
+subpar/tests/package_extract/extract
+subpar/tests/package_extract/extract.py
+subpar/tests/package_extract/extract_helper.py
+subpar/tests/package_extract/extract_helper_package/__init__.py
+subpar/tests/package_extract/extract_helper_package/extract_dat.txt
diff --git a/tests/package_extract/extract_helper.py b/tests/package_extract/extract_helper.py
new file mode 100644
index 0000000..2ae2839
--- /dev/null
+++ b/tests/package_extract/extract_helper.py
@@ -0,0 +1 @@
+pass
diff --git a/tests/package_extract/extract_helper_package/__init__.py b/tests/package_extract/extract_helper_package/__init__.py
new file mode 100644
index 0000000..2ae2839
--- /dev/null
+++ b/tests/package_extract/extract_helper_package/__init__.py
@@ -0,0 +1 @@
+pass
diff --git a/tests/package_extract/extract_helper_package/extract_dat.txt b/tests/package_extract/extract_helper_package/extract_dat.txt
new file mode 100644
index 0000000..153a605
--- /dev/null
+++ b/tests/package_extract/extract_helper_package/extract_dat.txt
@@ -0,0 +1 @@
+Dummy data file for extract.py