Add modules in PYTHONPATH to watched modules
diff --git a/lib/streamlit/file_util.py b/lib/streamlit/file_util.py
index afd39fc5fde4..76cc4c02073b 100644
--- a/lib/streamlit/file_util.py
+++ b/lib/streamlit/file_util.py
@@ -137,3 +137,30 @@ def file_is_in_folder_glob(filepath, folderpath_glob):
 
     file_dir = os.path.dirname(filepath) + "/"
     return fnmatch.fnmatch(file_dir, folderpath_glob)
+
+
+def file_in_pythonpath(filepath):
+    """Test whether a filepath is in the same folder of a path specified in the PYTHONPATH env variable.
+
+
+    Parameters
+    ----------
+    filepath : str
+        An absolute file path.
+
+    Returns
+    -------
+    boolean
+        True if contained in PYTHONPATH, False otherwise. False if PYTHONPATH is not defined or empty.
+
+    """
+
+    pythonpath = os.environ.get("PYTHONPATH", "")
+    if len(pythonpath) == 0:
+        return False
+
+    absolute_paths = [os.path.abspath(path) for path in pythonpath.split(os.pathsep)]
+    return any(
+        file_is_in_folder_glob(os.path.normpath(filepath), path)
+        for path in absolute_paths
+    )
diff --git a/lib/streamlit/hashing.py b/lib/streamlit/hashing.py
index 576249315af8..33ab3da02aea 100644
--- a/lib/streamlit/hashing.py
+++ b/lib/streamlit/hashing.py
@@ -242,6 +242,16 @@ def _update(self, hasher, obj, context=None):
         b = self.to_bytes(obj, context)
         hasher.update(b)
 
+    def _file_should_be_hashed(self, filename):
+        filepath = os.path.abspath(filename)
+        file_is_blacklisted = self._folder_black_list.is_blacklisted(filepath)
+        # Short circuiting for performance.
+        if file_is_blacklisted:
+            return False
+        return file_util.file_is_in_folder_glob(
+            filepath, self._get_main_script_directory()
+        ) or file_util.file_in_pythonpath(filepath)
+
     def _to_bytes(self, obj, context):
         """Hash objects to bytes, including code with dependencies.
         Python's built in `hash` does not produce consistent results across
@@ -332,11 +342,7 @@ def _to_bytes(self, obj, context):
                     return self.to_bytes("%s.%s" % (obj.__module__, obj.__name__))
 
                 h = hashlib.new(self.name)
-                filepath = os.path.abspath(obj.__code__.co_filename)
-
-                if file_util.file_is_in_folder_glob(
-                    filepath, self._get_main_script_directory()
-                ) and not self._folder_black_list.is_blacklisted(filepath):
+                if self._file_should_be_hashed(obj.__code__.co_filename):
                     context = _get_context(obj)
                     if obj.__defaults__:
                         self._update(h, obj.__defaults__, context)
diff --git a/lib/streamlit/watcher/LocalSourcesWatcher.py b/lib/streamlit/watcher/LocalSourcesWatcher.py
index 2ac26cdfc0dc..c05527c8aa4c 100644
--- a/lib/streamlit/watcher/LocalSourcesWatcher.py
+++ b/lib/streamlit/watcher/LocalSourcesWatcher.py
@@ -161,6 +161,16 @@ def _deregister_watcher(self, filepath):
         wm.watcher.close()
         del self._watched_modules[filepath]
 
+    def _file_is_new(self, filepath):
+        return filepath not in self._watched_modules
+
+    def _file_should_be_watched(self, filepath):
+        # Using short circuiting for performance.
+        return self._file_is_new(filepath) and (
+            file_util.file_is_in_folder_glob(filepath, self._report.script_folder)
+            or file_util.file_in_pythonpath(filepath)
+        )
+
     def update_watched_modules(self):
         if self._is_closed:
             return
@@ -200,14 +210,9 @@ def update_watched_modules(self):
                 if self._folder_black_list.is_blacklisted(filepath):
                     continue
 
-                file_is_new = filepath not in self._watched_modules
-                file_is_local = file_util.file_is_in_folder_glob(
-                    filepath, self._report.script_folder
-                )
-
                 local_filepaths.append(filepath)
 
-                if file_is_local and file_is_new:
+                if self._file_should_be_watched(filepath):
                     self._register_watcher(filepath, name)
 
             except Exception:
diff --git a/lib/tests/streamlit/file_util_test.py b/lib/tests/streamlit/file_util_test.py
index 14465b720634..14e1c210566e 100644
--- a/lib/tests/streamlit/file_util_test.py
+++ b/lib/tests/streamlit/file_util_test.py
@@ -152,3 +152,104 @@ def test_file_not_in_folder_glob(self):
     def test_rel_file_not_in_folder_glob(self):
         ret = file_util.file_is_in_folder_glob("foo.py", "**/f")
         self.assertFalse(ret)
+
+    def test_rel_file_not_in_folder_glob(self):
+        ret = file_util.file_is_in_folder_glob("foo.py", "")
+        self.assertTrue(ret)
+
+
+class FileInPythonPathTest(unittest.TestCase):
+    @staticmethod
+    def _make_it_absolute(path):
+        # Use manual join instead of os.abspath to test against non normalized paths
+        return os.path.join(os.getcwd(), path)
+
+    def test_no_pythonpath(self):
+        with patch("os.environ", {}) as d:
+            self.assertFalse(
+                file_util.file_in_pythonpath(
+                    self._make_it_absolute("../something/dir1/dir2/module")
+                )
+            )
+
+    def test_empty_pythonpath(self):
+        with patch("os.environ", {"PYTHONPATH": ""}):
+            self.assertFalse(
+                file_util.file_in_pythonpath(
+                    self._make_it_absolute("something/dir1/dir2/module")
+                )
+            )
+
+    def test_python_path_relative(self):
+        with patch("os.environ", {"PYTHONPATH": "something"}):
+            self.assertTrue(
+                file_util.file_in_pythonpath(
+                    self._make_it_absolute("something/dir1/dir2/module")
+                )
+            )
+            self.assertFalse(
+                file_util.file_in_pythonpath(
+                    self._make_it_absolute("something_else/module")
+                )
+            )
+            self.assertFalse(
+                file_util.file_in_pythonpath(
+                    self._make_it_absolute("../something/dir1/dir2/module")
+                )
+            )
+
+    def test_python_path_absolute(self):
+        with patch("os.environ", {"PYTHONPATH": self._make_it_absolute("something")}):
+            self.assertTrue(
+                file_util.file_in_pythonpath(
+                    self._make_it_absolute("something/dir1/dir2/module")
+                )
+            )
+            self.assertFalse(
+                file_util.file_in_pythonpath(
+                    self._make_it_absolute("something_else/module")
+                )
+            )
+            self.assertFalse(
+                file_util.file_in_pythonpath(
+                    self._make_it_absolute("../something/dir1/dir2/module")
+                )
+            )
+
+    def test_python_path_mixed(self):
+        with patch(
+            "os.environ",
+            {
+                "PYTHONPATH": os.pathsep.join(
+                    [self._make_it_absolute("something"), "something"]
+                )
+            },
+        ):
+            self.assertTrue(
+                file_util.file_in_pythonpath(
+                    self._make_it_absolute("something/dir1/dir2/module")
+                )
+            )
+            self.assertFalse(
+                file_util.file_in_pythonpath(
+                    self._make_it_absolute("something_else/module")
+                )
+            )
+
+    def test_current_directory(self):
+        with patch("os.environ", {"PYTHONPATH": "."}):
+            self.assertTrue(
+                file_util.file_in_pythonpath(
+                    self._make_it_absolute("something/dir1/dir2/module")
+                )
+            )
+            self.assertTrue(
+                file_util.file_in_pythonpath(
+                    self._make_it_absolute("something_else/module")
+                )
+            )
+            self.assertFalse(
+                file_util.file_in_pythonpath(
+                    self._make_it_absolute("../something_else/module")
+                )
+            )
diff --git a/lib/tests/streamlit/watcher/LocalSourcesWatcher_test.py b/lib/tests/streamlit/watcher/LocalSourcesWatcher_test.py
index 15e41253f6a2..ab8e61f610bf 100644
--- a/lib/tests/streamlit/watcher/LocalSourcesWatcher_test.py
+++ b/lib/tests/streamlit/watcher/LocalSourcesWatcher_test.py
@@ -52,6 +52,7 @@ def NOOP_CALLBACK():
     pass
 
 
+@patch("streamlit.file_util.file_in_pythonpath", return_value=False)
 class LocalSourcesWatcherTest(unittest.TestCase):
     def setUp(self):
         modules = [
@@ -76,7 +77,7 @@ def setUp(self):
                 pass
 
     @patch("streamlit.watcher.LocalSourcesWatcher.FileWatcher")
-    def test_just_script(self, fob):
+    def test_just_script(self, fob, _):
         lso = LocalSourcesWatcher.LocalSourcesWatcher(REPORT, NOOP_CALLBACK)
 
         fob.assert_called_once()
@@ -94,7 +95,7 @@ def test_just_script(self, fob):
         self.assertEqual(fob.call_count, 1)  # __init__.py
 
     @patch("streamlit.watcher.LocalSourcesWatcher.FileWatcher")
-    def test_permission_error(self, fob):
+    def test_permission_error(self, fob, _):
         from streamlit import compatibility
 
         if compatibility.is_running_py3():
@@ -106,7 +107,7 @@ def test_permission_error(self, fob):
         lso = LocalSourcesWatcher.LocalSourcesWatcher(REPORT, NOOP_CALLBACK)
 
     @patch("streamlit.watcher.LocalSourcesWatcher.FileWatcher")
-    def test_script_and_2_modules_at_once(self, fob):
+    def test_script_and_2_modules_at_once(self, fob, _):
         lso = LocalSourcesWatcher.LocalSourcesWatcher(REPORT, NOOP_CALLBACK)
 
         fob.assert_called_once()
@@ -138,7 +139,7 @@ def test_script_and_2_modules_at_once(self, fob):
         self.assertEqual(fob.call_count, 0)
 
     @patch("streamlit.watcher.LocalSourcesWatcher.FileWatcher")
-    def test_script_and_2_modules_in_series(self, fob):
+    def test_script_and_2_modules_in_series(self, fob, _):
         lso = LocalSourcesWatcher.LocalSourcesWatcher(REPORT, NOOP_CALLBACK)
 
         fob.assert_called_once()
@@ -172,7 +173,7 @@ def test_script_and_2_modules_in_series(self, fob):
         fob.assert_called_once()
 
     @patch("streamlit.watcher.LocalSourcesWatcher.FileWatcher")
-    def test_misbehaved_module(self, fob):
+    def test_misbehaved_module(self, fob, _):
         lso = LocalSourcesWatcher.LocalSourcesWatcher(REPORT, NOOP_CALLBACK)
 
         fob.assert_called_once()
@@ -184,7 +185,7 @@ def test_misbehaved_module(self, fob):
         fob.assert_called_once()  # Just __init__.py
 
     @patch("streamlit.watcher.LocalSourcesWatcher.FileWatcher")
-    def test_nested_module_parent_unloaded(self, fob):
+    def test_nested_module_parent_unloaded(self, fob, _):
         lso = LocalSourcesWatcher.LocalSourcesWatcher(REPORT, NOOP_CALLBACK)
 
         fob.assert_called_once()
@@ -207,7 +208,7 @@ def test_nested_module_parent_unloaded(self, fob):
             self.assertNotIn("NESTED_MODULE_PARENT", sys.modules)
 
     @patch("streamlit.watcher.LocalSourcesWatcher.FileWatcher")
-    def test_config_blacklist(self, fob):
+    def test_config_blacklist(self, fob, _):
         """Test server.folderWatchBlacklist"""
         prev_blacklist = config.get_option("server.folderWatchBlacklist")
 
@@ -229,7 +230,7 @@ def test_config_blacklist(self, fob):
         # Reset the config object.
         config.set_option("server.folderWatchBlacklist", prev_blacklist)
 
-    def test_config_watcherType(self):
+    def test_config_watcherType(self, _):
         """Test server.fileWatcherType"""
 
         config.set_option("server.fileWatcherType", "none")

We currently limit the modules we watch to modules contained in the current directory. This PR extends the watched modules to modules contained in the `PYTHONPATH` as well. For consistency, we also enable hashing for the same modules in the `PYTHONPATH`.

**Issue:** https://github.com/streamlit/streamlit/issues/805