Fix st.cache
diff --git a/lib/streamlit/caching.py b/lib/streamlit/caching.py
index 6e842516ba6f..833602e03a92 100644
--- a/lib/streamlit/caching.py
+++ b/lib/streamlit/caching.py
@@ -28,9 +28,9 @@
 import threading
 import time
 from collections import namedtuple
-from typing import Any, Dict
+from typing import Any, Dict, Optional
 
-from cachetools import LRUCache, TTLCache
+from cachetools import TTLCache
 
 import streamlit as st
 from streamlit.util import functools_wraps
@@ -56,12 +56,10 @@
 
 LOGGER = get_logger(__name__)
 
-# The timer function we use with TTLCache.
+# The timer function we use with TTLCache. This is the default timer func, but
+# is exposed here as a constant so that it can be patched in unit tests.
 TTLCACHE_TIMER = time.monotonic
 
-# A list of all mem-caches we've created.
-_all_mem_caches = []
-
 
 class CacheError(Exception):
     pass
@@ -80,6 +78,64 @@ def __init__(self, cached_value):
 DiskCacheEntry = namedtuple("DiskCacheEntry", ["value"])
 
 
+class _MemCaches(object):
+    """Manages all in-memory st.cache caches"""
+
+    def __init__(self):
+        # Contains a cache object for each st.cache'd function
+        self._lock = threading.RLock()
+        self._function_caches = {}  # type: Dict[str, TTLCache]
+
+    def get_cache(
+        self, key: str, max_entries: Optional[float], ttl: Optional[float]
+    ) -> TTLCache:
+        """Return the mem cache for the given key.
+
+        If it doesn't exist, create a new one with the given params.
+        """
+
+        if max_entries is None:
+            max_entries = math.inf
+        if ttl is None:
+            ttl = math.inf
+
+        if not isinstance(max_entries, (int, float)):
+            raise RuntimeError("max_entries must be an int")
+        if not isinstance(ttl, (int, float)):
+            raise RuntimeError("ttl must be a float")
+
+        # Get the existing cache, if it exists, and validate that its params
+        # haven't changed.
+        with self._lock:
+            mem_cache = self._function_caches.get(key)
+            if (
+                mem_cache is not None
+                and mem_cache.ttl == ttl
+                and mem_cache.maxsize == max_entries
+            ):
+                return mem_cache
+
+            # Create a new cache object and put it in our dict
+            LOGGER.debug(
+                "Creating new mem_cache (key=%s, max_entries=%s, ttl=%s)",
+                key,
+                max_entries,
+                ttl,
+            )
+            mem_cache = TTLCache(maxsize=max_entries, ttl=ttl, timer=TTLCACHE_TIMER)
+            self._function_caches[key] = mem_cache
+            return mem_cache
+
+    def clear(self) -> None:
+        """Clear all caches"""
+        with self._lock:
+            self._function_caches = {}
+
+
+# Our singleton _MemCaches instance
+_mem_caches = _MemCaches()
+
+
 # A thread-local counter that's incremented when we enter @st.cache
 # and decremented when we exit.
 class ThreadLocalCacheInfo(threading.local):
@@ -206,34 +262,6 @@ def _get_mutated_output_error_message():
     return message
 
 
-def _create_mem_cache(max_entries, ttl):
-    """Create an in-memory cache object with the given parameters."""
-    if max_entries is None and ttl is None:
-        # If we have no max_entries or TTL, we can just use a regular dict.
-        mem_cache = {}
-    else:
-        if max_entries is None:
-            max_entries = math.inf
-        elif not isinstance(max_entries, int):
-            raise Exception("`max_entries` must be an int or None")
-
-        if not isinstance(ttl, (float, int)) and ttl is not None:
-            raise Exception("`ttl` must be a float or None")
-
-        # If ttl is none, just create an LRUCache. (TTLCache is simply an
-        # LRUCache that adds a ttl option.)
-        if ttl is None:
-            mem_cache = LRUCache(maxsize=max_entries)
-        else:
-            mem_cache = TTLCache(maxsize=max_entries, ttl=ttl, timer=TTLCACHE_TIMER)
-
-    # Stick the new cache in our global list
-    global _all_mem_caches
-    _all_mem_caches.append(mem_cache)
-
-    return mem_cache
-
-
 def _read_from_mem_cache(mem_cache, key, allow_output_mutation, hash_funcs):
     if key in mem_cache:
         entry = mem_cache[key]
@@ -445,8 +473,36 @@ def cache(
             ttl=ttl,
         )
 
-    # Create the function's in-memory cache.
-    mem_cache = _create_mem_cache(max_entries, ttl)
+    # Create the unique key for this function's cache. The cache will be
+    # retrieved from inside the wrapped function.
+    #
+    # A naive implementation would involve simply creating the cache object
+    # right here in the wrapper, which in a normal Python script would be
+    # executed only once. But in Streamlit, we reload all modules related to a
+    # user's app when the app is re-run, which means that - among other
+    # things - all function decorators in the app will be re-run, and so any
+    # decorator-local objects will be recreated.
+    #
+    # Furthermore, our caches can be destroyed and recreated (in response
+    # to cache clearing, for example), which means that retrieving the
+    # function's cache here (so that the wrapped function can save a lookup)
+    # is incorrect: the cache itself may be recreated between
+    # decorator-evaluation time and decorated-function-execution time. So
+    # we must retrieve the cache object *and* perform the cached-value lookup
+    # inside the decorated function.
+
+    func_hasher = CodeHasher("md5", None, hash_funcs)
+    # Include the function's module and qualified name in the hash.
+    # This means that two identical functions in different modules
+    # will not share a hash; it also means that two identical *nested*
+    # functions in the same module will not share a hash.
+    func_hasher.update(func.__module__)
+    func_hasher.update(func.__qualname__)
+    func_hasher.update(func)
+    cache_key = func_hasher.hexdigest()
+    LOGGER.debug(
+        "mem_cache key for %s.%s: %s", func.__module__, func.__qualname__, cache_key
+    )
 
     @functools_wraps(func)
     def wrapped_func(*args, **kwargs):
@@ -458,7 +514,7 @@ def wrapped_func(*args, **kwargs):
             LOGGER.debug("Purposefully skipping cache")
             return func(*args, **kwargs)
 
-        name = func.__name__
+        name = func.__qualname__
 
         if len(args) == 0 and len(kwargs) == 0:
             message = "Running %s()." % name
@@ -466,23 +522,34 @@ def wrapped_func(*args, **kwargs):
             message = "Running %s(...)." % name
 
         def get_or_create_cached_value():
-            hasher = hashlib.new("md5")
-
-            args_hasher = CodeHasher("md5", hasher, hash_funcs)
+            # First, get the cache that's attached to this function.
+            # This cache's key is generated (above) from the function's code.
+            global _mem_caches
+            mem_cache = _mem_caches.get_cache(cache_key, max_entries, ttl)
+
+            # Next, calculate the key for the value we'll be searching for
+            # within that cache. This key is generated from both the function's
+            # code and the arguments that are passed into it. (Even though this
+            # key is used to index into a per-function cache, it must be
+            # globally unique, because it is *also* used for a global on-disk
+            # cache that is *not* per-function.)
+            value_hasher = hashlib.new("md5")
+
+            args_hasher = CodeHasher("md5", value_hasher, hash_funcs)
             args_hasher.update([args, kwargs])
             LOGGER.debug("Hashing arguments to %s of %i bytes.", name, args_hasher.size)
 
-            code_hasher = CodeHasher("md5", hasher, hash_funcs)
+            code_hasher = CodeHasher("md5", value_hasher, hash_funcs)
             code_hasher.update(func)
             LOGGER.debug("Hashing function %s in %i bytes.", name, code_hasher.size)
 
-            key = hasher.hexdigest()
-            LOGGER.debug("Cache key: %s", key)
+            value_key = value_hasher.hexdigest()
+            LOGGER.debug("Cache key: %s", value_key)
 
             try:
                 return_value = _read_from_cache(
                     mem_cache=mem_cache,
-                    key=key,
+                    key=value_key,
                     persisted=persist,
                     allow_output_mutation=allow_output_mutation,
                     hash_funcs=hash_funcs,
@@ -500,7 +567,7 @@ def get_or_create_cached_value():
 
                 _write_to_cache(
                     mem_cache=mem_cache,
-                    key=key,
+                    key=value_key,
                     value=return_value,
                     persist=persist,
                     allow_output_mutation=allow_output_mutation,
@@ -556,7 +623,7 @@ class Cache(Dict[Any, Any]):
     def __init__(self, persist=False, allow_output_mutation=False):
         self._persist = persist
         self._allow_output_mutation = allow_output_mutation
-        self._mem_cache = _create_mem_cache(None, None)
+        self._mem_cache = {}
 
         dict.__init__(self)
 
@@ -686,7 +753,5 @@ def _clear_disk_cache():
 
 
 def _clear_mem_cache():
-    global _all_mem_caches
-    # Copy _all_mem_caches to guard against threading errors
-    for mem_cache in list(_all_mem_caches):
-        mem_cache.clear()
+    global _mem_caches
+    _mem_caches.clear()
diff --git a/lib/tests/streamlit/scriptrunner/ScriptRunner_test.py b/lib/tests/streamlit/scriptrunner/ScriptRunner_test.py
index c88e57b662b1..8a9a7f08313f 100644
--- a/lib/tests/streamlit/scriptrunner/ScriptRunner_test.py
+++ b/lib/tests/streamlit/scriptrunner/ScriptRunner_test.py
@@ -314,6 +314,36 @@ def test_multiple_scriptrunners(self):
                 ],
             )
 
+    def test_rerun_caching(self):
+        """Test that st.caches are maintained across script runs."""
+
+        # Run st_cache_script.
+        runner = TestScriptRunner("st_cache_script.py")
+        runner.enqueue_rerun()
+        runner.start()
+        runner.join()
+
+        # The script has 4 cached functions, each of which writes out
+        # the same text.
+        self._assert_text_deltas(
+            runner,
+            [
+                "cached function called",
+                "cached function called",
+                "cached function called",
+                "cached function called",
+            ],
+        )
+
+        # Re-run the script on a second runner.
+        runner = TestScriptRunner("st_cache_script.py")
+        runner.enqueue_rerun()
+        runner.start()
+        runner.join()
+
+        # The cached functions should not have been called on this second run
+        self._assert_text_deltas(runner, [])
+
     def _assert_no_exceptions(self, scriptrunner):
         """Asserts that no uncaught exceptions were thrown in the
         scriptrunner's run thread.
diff --git a/lib/tests/streamlit/scriptrunner/test_data/st_cache_script.py b/lib/tests/streamlit/scriptrunner/test_data/st_cache_script.py
new file mode 100644
index 000000000000..7dce0be66b36
--- /dev/null
+++ b/lib/tests/streamlit/scriptrunner/test_data/st_cache_script.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+# Copyright 2018-2020 Streamlit Inc.
+#
+# 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.
+
+"""A script for ScriptRunnerTest that uses st.cache"""
+
+import streamlit as st
+
+# Except for their names, these functions all intentionally have the same
+# bodies - so their ASTs should hash to the same values. However, they should
+# *not* share the same caches, because we include a function's qualified
+# name in its cache key. To test that this is true, the associated ScriptRunner
+# test should run the script twice:
+# - On the first run, "cached function called" should be produced 4 times
+# - On the second run, "cached function called" should not be produced
+
+
+@st.cache(suppress_st_warning=True)
+def cached1():
+    st.text("cached function called")
+    return "cached value"
+
+
+@st.cache(suppress_st_warning=True)
+def cached2():
+    st.text("cached function called")
+    return "cached value"
+
+
+def outer_func():
+    # These closures share the names and bodies of the functions in the outer
+    # scope, but they should have their own independent caches.
+    @st.cache(suppress_st_warning=True)
+    def cached1():
+        st.text("cached function called")
+        return "cached value"
+
+    @st.cache(suppress_st_warning=True)
+    def cached2():
+        st.text("cached function called")
+        return "cached value"
+
+    cached1()
+    cached2()
+
+
+cached1()
+cached2()
+outer_func()

This fixes breakage that I introduced in #1152 

Rather than creating the cache as a local variable in the st.cache wrapper, we instead manage all caches in a global `_MemCaches` class. Each cache is keyed off its wrapped function's fully qualified name, and its contents.

There's a new `ScriptRunner` test that makes sure that caches are reused across multiple runs of the same script.