Repository: dateutil/dateutil (Python)

Issue:
Switch timezone singletons and caches with weak references
Right now `tzoffset`, `tzstr` and `tz.gettz` each retain strong references to every unique time zone ever constructed by that method. I think in the end this will be a net savings in memory in *most* use cases since it prevents duplication of time zones, but it could also cause a huge proliferation of memory in applications that make short-lived references to a large number of time zones (since those time zones, when they go out of scope, would normally get garbage collected).

In Python 3 there is the [weakref](https://docs.python.org/3/library/weakref.html) module, which allows the creation of weak references, such that an object will be garbage collected if only weak references to it exist.

Rather than try and use `backports.weakref` in Python 2, I think we can just write a "pass through" compatibility shim that has the same external form as `weakref` but simply holds a strong reference to the object in question.


Hints:
I'd be interested in working on this.

Patch:
diff --git a/dateutil/tz/_factories.py b/dateutil/tz/_factories.py
--- a/dateutil/tz/_factories.py
+++ b/dateutil/tz/_factories.py
@@ -1,5 +1,5 @@
 from datetime import timedelta
-
+import weakref
 
 class _TzSingleton(type):
     def __init__(cls, *args, **kwargs):
@@ -19,7 +19,7 @@ def instance(cls, *args, **kwargs):
 
 class _TzOffsetFactory(_TzFactory):
     def __init__(cls, *args, **kwargs):
-        cls.__instances = {}
+        cls.__instances = weakref.WeakValueDictionary()
 
     def __call__(cls, name, offset):
         if isinstance(offset, timedelta):
@@ -36,7 +36,7 @@ def __call__(cls, name, offset):
 
 class _TzStrFactory(_TzFactory):
     def __init__(cls, *args, **kwargs):
-        cls.__instances = {}
+        cls.__instances = weakref.WeakValueDictionary()
 
     def __call__(cls, s, posix_offset=False):
         key = (s, posix_offset)
diff --git a/dateutil/tz/tz.py b/dateutil/tz/tz.py
--- a/dateutil/tz/tz.py
+++ b/dateutil/tz/tz.py
@@ -13,6 +13,7 @@
 import sys
 import os
 import bisect
+import weakref
 
 import six
 from six import string_types
@@ -1406,7 +1407,7 @@ def __get_gettz():
     class GettzFunc(object):
         def __init__(self):
 
-            self.__instances = {}
+            self.__instances = weakref.WeakValueDictionary()
             self._cache_lock = _thread.allocate_lock()
 
         def __call__(self, name=None):
@@ -1425,7 +1426,7 @@ def __call__(self, name=None):
 
         def cache_clear(self):
             with self._cache_lock:
-                self.__instances = {}
+                self.__instances = weakref.WeakValueDictionary()
 
         @staticmethod
         def nocache(name=None):


Test patch:
diff --git a/dateutil/test/test_tz.py b/dateutil/test/test_tz.py
--- a/dateutil/test/test_tz.py
+++ b/dateutil/test/test_tz.py
@@ -14,6 +14,8 @@
 import sys
 import base64
 import copy
+import gc
+import weakref
 
 from functools import partial
 
@@ -731,6 +733,20 @@ def testTzOffsetSingletonDifferent(self):
 
         assert tz1 is tz2
 
+
+@pytest.mark.smoke
+@pytest.mark.tzoffset
+def test_tzoffset_weakref():
+    UTC1 = tz.tzoffset('UTC', 0)
+    UTC_ref = weakref.ref(tz.tzoffset('UTC', 0))
+    UTC1 is UTC_ref()
+    del UTC1
+    gc.collect()
+
+    assert UTC_ref() is None
+    assert UTC_ref() is not tz.tzoffset('UTC', 0)
+
+
 @pytest.mark.tzoffset
 @pytest.mark.parametrize('args', [
     ('UTC', 0),
@@ -1037,6 +1053,7 @@ def testGettzCacheTzLocal(self):
 
         assert local1 is not local2
 
+
 @pytest.mark.gettz
 @pytest.mark.xfail(IS_WIN, reason='zoneinfo separately cached')
 def test_gettz_cache_clear():
@@ -1048,6 +1065,23 @@ def test_gettz_cache_clear():
     assert NYC1 is not NYC2
 
 
+@pytest.mark.xfail(IS_WIN, reason="Windows does not use system zoneinfo")
+@pytest.mark.smoke
+@pytest.mark.gettz
+def test_gettz_weakref():
+    tz.gettz.cache_clear()
+    NYC1 = tz.gettz('America/New_York')
+    NYC_ref = weakref.ref(tz.gettz('America/New_York'))
+
+    assert NYC1 is NYC_ref()
+
+    del NYC1
+    gc.collect()
+
+    assert NYC_ref() is None
+    assert tz.gettz('America/New_York') is not NYC_ref()
+
+
 class ZoneInfoGettzTest(GettzTest, WarningTestMixin):
     def gettz(self, name):
         zoneinfo_file = zoneinfo.get_zonefile_instance()
@@ -1375,6 +1409,21 @@ def testTzStrInstance(self):
         # Ensure that these still are all the same zone
         assert tz1 == tz2 == tz3
 
+
+@pytest.mark.smoke
+@pytest.mark.tzstr
+def test_tzstr_weakref():
+    tz_t1 = tz.tzstr('EST5EDT')
+    tz_t2_ref = weakref.ref(tz.tzstr('EST5EDT'))
+    assert tz_t1 is tz_t2_ref()
+
+    del tz_t1
+    gc.collect()
+
+    assert tz_t2_ref() is None
+    assert tz.tzstr('EST5EDT') is not tz_t2_ref()
+
+
 @pytest.mark.tzstr
 @pytest.mark.parametrize('tz_str,expected', [
     # From https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html