]> git.ipfire.org Git - thirdparty/tornado.git/commitdiff
autoreload: Support the ability to run a directory instead of a module
authorBen Darnell <ben@bendarnell.com>
Fri, 14 Jul 2023 00:57:11 +0000 (20:57 -0400)
committerBen Darnell <ben@bendarnell.com>
Sun, 23 Jul 2023 00:53:05 +0000 (20:53 -0400)
Running a directory has some but not all of the behavior of
running a module, including setting __spec__, so we must be careful
not to break things by assuming that __spec__ means module mode.

Fixes #2855

tornado/autoreload.py
tornado/test/autoreload_test.py

index 0ac449665122ac653fc79d8a7ba35f013d595182..07b9112a66162a10540ac4e61b2797d056eeb7e5 100644 (file)
@@ -225,7 +225,15 @@ def _reload() -> None:
     else:
         spec = getattr(sys.modules["__main__"], "__spec__", None)
         argv = sys.argv
-    if spec:
+    if spec and spec.name != "__main__":
+        # __spec__ is set in two cases: when running a module, and when running a directory. (when
+        # running a file, there is no spec). In the former case, we must pass -m to maintain the
+        # module-style behavior (setting sys.path), even though python stripped -m from its argv at
+        # startup. If sys.path is exactly __main__, we're running a directory and should fall
+        # through to the non-module behavior.
+        #
+        # Some of this, including the use of exactly __main__ as a spec for directory mode,
+        # is documented at https://docs.python.org/3/library/runpy.html#runpy.run_path
         argv = ["-m", spec.name] + argv[1:]
     else:
         path_prefix = "." + os.pathsep
@@ -331,7 +339,7 @@ def main() -> None:
         # never made it into sys.modules and so we won't know to watch it.
         # Just to make sure we've covered everything, walk the stack trace
         # from the exception and watch every file.
-        for (filename, lineno, name, line) in traceback.extract_tb(sys.exc_info()[2]):
+        for filename, lineno, name, line in traceback.extract_tb(sys.exc_info()[2]):
             watch(filename)
         if isinstance(e, SyntaxError):
             # SyntaxErrors are special:  their innermost stack frame is fake
index fff602811b32111ca0794eb13c92e3e20bcbd08d..feee94f3bec900457b27070cbd0d317cb79cd10f 100644 (file)
@@ -24,17 +24,23 @@ class AutoreloadTest(unittest.TestCase):
             time.sleep(1)
             shutil.rmtree(self.path)
 
-    def test_reload_module(self):
+    def test_reload(self):
         main = """\
 import os
 import sys
 
 from tornado import autoreload
 
-# This import will fail if path is not set up correctly
-import testapp
+# In module mode, the path is set to the parent directory and we can import testapp.
+try:
+    import testapp
+except ImportError:
+    print("import testapp failed")
+else:
+    print("import testapp succeeded")
 
-print('Starting')
+spec = getattr(sys.modules[__name__], '__spec__', None)
+print(f"Starting {__name__=}, __spec__.name={getattr(spec, 'name', None)}")
 if 'TESTAPP_STARTED' not in os.environ:
     os.environ['TESTAPP_STARTED'] = '1'
     sys.stdout.flush()
@@ -57,16 +63,62 @@ if 'TESTAPP_STARTED' not in os.environ:
         if "PYTHONPATH" in os.environ:
             pythonpath += os.pathsep + os.environ["PYTHONPATH"]
 
-        p = Popen(
-            [sys.executable, "-m", "testapp"],
-            stdout=subprocess.PIPE,
-            cwd=self.path,
-            env=dict(os.environ, PYTHONPATH=pythonpath),
-            universal_newlines=True,
-            encoding="utf-8",
-        )
-        out = p.communicate()[0]
-        self.assertEqual(out, "Starting\nStarting\n")
+        with self.subTest(mode="module"):
+            # In module mode, the path is set to the parent directory and we can import testapp.
+            # Also, the __spec__.name is set to the fully qualified module name.
+            p = Popen(
+                [sys.executable, "-m", "testapp"],
+                stdout=subprocess.PIPE,
+                cwd=self.path,
+                env=dict(os.environ, PYTHONPATH=pythonpath),
+                universal_newlines=True,
+                encoding="utf-8",
+            )
+            out = p.communicate()[0]
+            self.assertEqual(
+                out,
+                (
+                    "import testapp succeeded\n"
+                    + "Starting __name__='__main__', __spec__.name=testapp.__main__\n"
+                )
+                * 2,
+            )
+
+        with self.subTest(mode="file"):
+            # When the __main__.py file is run directly, there is no qualified module spec and we
+            # cannot import testapp.
+            p = Popen(
+                [sys.executable, "testapp/__main__.py"],
+                stdout=subprocess.PIPE,
+                cwd=self.path,
+                env=dict(os.environ, PYTHONPATH=pythonpath),
+                universal_newlines=True,
+                encoding="utf-8",
+            )
+            out = p.communicate()[0]
+            self.assertEqual(
+                out,
+                "import testapp failed\nStarting __name__='__main__', __spec__.name=None\n"
+                * 2,
+            )
+
+        with self.subTest(mode="directory"):
+            # Running as a directory finds __main__.py like a module. It does not manipulate
+            # sys.path but it does set a spec with a name of exactly __main__.
+            p = Popen(
+                [sys.executable, "testapp"],
+                stdout=subprocess.PIPE,
+                cwd=self.path,
+                env=dict(os.environ, PYTHONPATH=pythonpath),
+                universal_newlines=True,
+                encoding="utf-8",
+            )
+            out = p.communicate()[0]
+            self.assertEqual(
+                out,
+                "import testapp failed\nStarting __name__='__main__', __spec__.name=__main__\n"
+                * 2,
+            )
 
     def test_reload_wrapper_preservation(self):
         # This test verifies that when `python -m tornado.autoreload`