diskcache-community 5.8.2__tar.gz → 5.8.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {diskcache_community-5.8.2/diskcache_community.egg-info → diskcache_community-5.8.3}/PKG-INFO +1 -1
  2. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community/__init__.py +2 -2
  3. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community/_types.pyi +10 -2
  4. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community/arrays.py +13 -6
  5. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community/core.py +101 -42
  6. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community/core.pyi +2 -1
  7. {diskcache_community-5.8.2 → diskcache_community-5.8.3/diskcache_community.egg-info}/PKG-INFO +1 -1
  8. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/tests/test_core.py +44 -0
  9. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/tests/test_safedisk.py +91 -15
  10. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/tests/test_safedisk_numpy.py +35 -0
  11. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/tests/test_safedisk_types.py +39 -0
  12. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/LICENSE +0 -0
  13. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/NOTICE +0 -0
  14. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/README.md +0 -0
  15. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community/__init__.pyi +0 -0
  16. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community/arrays.pyi +0 -0
  17. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community/cli.py +0 -0
  18. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community/djangocache.py +0 -0
  19. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community/djangocache.pyi +0 -0
  20. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community/fanout.py +0 -0
  21. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community/fanout.pyi +0 -0
  22. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community/persistent.py +0 -0
  23. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community/persistent.pyi +0 -0
  24. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community/py.typed +0 -0
  25. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community/recipes.py +0 -0
  26. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community/recipes.pyi +0 -0
  27. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community.egg-info/SOURCES.txt +0 -0
  28. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community.egg-info/dependency_links.txt +0 -0
  29. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/diskcache_community.egg-info/top_level.txt +0 -0
  30. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/pyproject.toml +0 -0
  31. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/setup.cfg +0 -0
  32. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/tests/test_default_disk.py +0 -0
  33. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/tests/test_deque.py +0 -0
  34. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/tests/test_djangocache.py +0 -0
  35. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/tests/test_doctest.py +0 -0
  36. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/tests/test_entry_errors.py +0 -0
  37. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/tests/test_fanout.py +0 -0
  38. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/tests/test_golden.py +0 -0
  39. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/tests/test_index.py +0 -0
  40. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/tests/test_init.py +0 -0
  41. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/tests/test_readonly.py +0 -0
  42. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/tests/test_recipes.py +0 -0
  43. {diskcache_community-5.8.2 → diskcache_community-5.8.3}/tests/test_safejsondisk.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diskcache-community
3
- Version: 5.8.2
3
+ Version: 5.8.3
4
4
  Summary: Disk Cache -- Disk and file backed persistent cache.
5
5
  Author: Grant Jenks
6
6
  Maintainer: diskcache-community contributors
@@ -72,8 +72,8 @@ except Exception: # pylint: disable=broad-except # pragma: no cover
72
72
 
73
73
 
74
74
  __title__ = 'diskcache-community'
75
- __version__ = '5.8.2'
76
- __build__ = 0x050802
75
+ __version__ = '5.8.3'
76
+ __build__ = 0x050803
77
77
  __author__ = 'Grant Jenks'
78
78
  __license__ = 'Apache 2.0'
79
79
  __copyright__ = 'Copyright 2016-2023 Grant Jenks'
@@ -173,10 +173,18 @@ class BaseCache(Protocol):
173
173
  retry: bool = ...,
174
174
  ) -> bool: ...
175
175
  def incr(
176
- self, key: Any, delta: int = ..., default: int = ..., retry: bool = ...
176
+ self,
177
+ key: Any,
178
+ delta: int = ...,
179
+ default: int | None = ...,
180
+ retry: bool = ...,
177
181
  ) -> int: ...
178
182
  def decr(
179
- self, key: Any, delta: int = ..., default: int = ..., retry: bool = ...
183
+ self,
184
+ key: Any,
185
+ delta: int = ...,
186
+ default: int | None = ...,
187
+ retry: bool = ...,
180
188
  ) -> int: ...
181
189
  def get( # noqa: PLR0913
182
190
  self,
@@ -93,7 +93,9 @@ def _pack_array(numpy, array):
93
93
  The buffer is the array's own memory, so pickle copies it once, into
94
94
  the row, as a bytearray. An array that is not contiguous or not
95
95
  writable is copied first, since pickle stores a read-only buffer as
96
- bytes, which the reader refuses.
96
+ bytes, which the reader refuses. The buffer is a flat view of bytes,
97
+ since numpy exports no buffer for ``datetime64`` and ``timedelta64``
98
+ data; the dtype is in the header.
97
99
 
98
100
  """
99
101
  # pylint: disable=no-member
@@ -114,11 +116,16 @@ def _pack_array(numpy, array):
114
116
  header = buffer.getvalue()
115
117
  _read_header(numpy, header, array.nbytes)
116
118
  except (ValueError, pickle.UnpicklingError) as error:
117
- raise TypeError(
118
- 'SafeDisk cannot store an array of dtype %s' % (array.dtype,)
119
- ) from error
120
-
121
- return header, pickle.PickleBuffer(array)
119
+ message = 'SafeDisk cannot store an array of dtype %s' % (array.dtype,)
120
+ if array.dtype.hasobject:
121
+ message += (
122
+ '; a pandas DataFrame holds its labels and text columns in'
123
+ ' such arrays, so store its records or columns instead'
124
+ )
125
+ raise TypeError(message) from error
126
+
127
+ data = array.reshape(-1, order='A').view(numpy.uint8)
128
+ return header, pickle.PickleBuffer(data)
122
129
 
123
130
 
124
131
  def _unpack_array(header, data):
@@ -557,9 +557,11 @@ SAFE_PICKLE_NAMES = {
557
557
  'uuid': frozenset({'UUID'}),
558
558
  }
559
559
 
560
- # Python types that SafeDisk accepts for writing: exactly the types that the
561
- # names above read back. Subclasses are rejected because pickle records the
562
- # subclass, which the reader would refuse.
560
+ # Python types that SafeDisk accepts for writing without pickling them first:
561
+ # exactly the types that the names above read back. Anything else is pickled
562
+ # and accepted only if the reader resolves every name the pickle looks up, so
563
+ # a subclass is refused, because pickle records the subclass, unless it
564
+ # pickles as one of these types.
563
565
 
564
566
  SAFE_TYPES = frozenset(
565
567
  {
@@ -593,15 +595,35 @@ SAFE_TYPES = frozenset(
593
595
  }
594
596
  )
595
597
 
598
+ # Classes that SafeDisk accepts as objects, such as the classes that
599
+ # memoize(typed=True) puts into keys and the factory of a defaultdict: the
600
+ # classes in SAFE_TYPES that pickle as a name the reader resolves. NoneType
601
+ # pickles as a call to builtins.type, and bool as builtins.bool, which the
602
+ # reader refuses.
603
+
604
+ _SAFE_CLASSES = SAFE_TYPES - {type(None), bool}
605
+
596
606
  _SAFE_MAPPINGS = (dict, co.Counter, co.OrderedDict, co.defaultdict)
597
607
  _SAFE_SEQUENCES = (list, tuple, set, frozenset, co.deque)
598
608
  _SAFE_TZ_TYPES = (type(None), dt.timezone)
599
609
 
600
- # Packages whose classes SafeDisk.with_types refuses to allow. numpy restores
601
- # the internal flags of a dtype from its pickle without checking them, so a
602
- # crafted pickle can make an array read raw bytes as object pointers.
610
+ # Packages whose classes SafeDisk.with_types refuses to allow, because a
611
+ # crafted pickle of one can corrupt memory or run code. numpy restores the
612
+ # internal flags of a dtype from its pickle without checking them, so a
613
+ # crafted pickle can make an array read raw bytes as object pointers. scipy's
614
+ # sparse matrices and pyahocorasick's automatons keep index data that their
615
+ # compiled code trusts, bitstruct's compiled formats crash on state they did
616
+ # not build, and torch builds some objects by compiling or running code.
617
+
618
+ _UNSAFE_MODULES = ('numpy', 'ahocorasick', 'bitstruct', 'scipy', 'torch')
619
+
620
+
621
+ def _unsafe_reason(package):
622
+ """Return why with_types refuses classes of the unsafe `package`."""
623
+ if package == 'numpy':
624
+ return '; use with_numpy() to store arrays'
625
+ return ': a crafted pickle of a %s object is not safe to read' % package
603
626
 
604
- _UNSAFE_MODULES = ('numpy',)
605
627
 
606
628
  _PICKLE_STRING_OPS = frozenset(
607
629
  {'BINUNICODE', 'BINUNICODE8', 'SHORT_BINUNICODE', 'UNICODE'}
@@ -838,10 +860,11 @@ class SafeDisk(Disk):
838
860
  ``FanoutCache(directory, disk=SafeDisk)``, ``Deque(disk=SafeDisk)``,
839
861
  ``Index(disk=SafeDisk)`` or the ``DISK`` option of the Django backend.
840
862
 
841
- Objects outside the allowlist, such as instances of your own classes,
842
- Django model instances and responses, and the ``type`` objects that
843
- ``memoize(typed=True)`` puts into keys, need either the default
844
- :class:`Disk` or a disk from :meth:`with_types` that allows them.
863
+ Objects outside the allowlist, such as instances of your own classes and
864
+ Django model instances and responses, need either the default
865
+ :class:`Disk` or a disk from :meth:`with_types` that allows them. The
866
+ allowed classes themselves can be stored too, so ``memoize(typed=True)``
867
+ works.
845
868
 
846
869
  """
847
870
 
@@ -868,7 +891,9 @@ class SafeDisk(Disk):
868
891
  do nothing beyond storing their fields, such as dataclasses, named
869
892
  tuples, enums and pydantic models. Reading looks classes up only in
870
893
  modules that are already imported, so import them before reading.
871
- numpy classes are refused; use :meth:`with_numpy` to store arrays.
894
+ Classes of numpy, scipy, torch, bitstruct and pyahocorasick are
895
+ refused, because a crafted pickle of one can corrupt memory or run
896
+ code; use :meth:`with_numpy` to store arrays.
872
897
 
873
898
  >>> import collections
874
899
  >>> Point = collections.namedtuple('Point', 'x y')
@@ -882,17 +907,18 @@ class SafeDisk(Disk):
882
907
  :return: SafeDisk subclass to pass as ``disk=``
883
908
  :raises TypeError: if an entry of `types` is not a class
884
909
  :raises ValueError: if an entry of `modules` is not a module name, or
885
- a type or module is numpy (arrays belong to :meth:`with_numpy`)
910
+ a type or module belongs to one of those packages
886
911
 
887
912
  """
888
913
  for entry in types:
889
914
  if not isinstance(entry, type):
890
915
  raise TypeError('with_types takes classes, not %r' % (entry,))
891
- if _in_modules(entry.__module__, _UNSAFE_MODULES):
892
- raise ValueError(
893
- 'with_types refuses the class %r; use with_numpy() to'
894
- ' store arrays' % (entry,)
895
- )
916
+ for unsafe in _UNSAFE_MODULES:
917
+ if _in_modules(entry.__module__, (unsafe,)):
918
+ raise ValueError(
919
+ 'with_types refuses the class %r%s'
920
+ % (entry, _unsafe_reason(unsafe))
921
+ )
896
922
 
897
923
  if isinstance(modules, str):
898
924
  modules = (modules,)
@@ -903,15 +929,14 @@ class SafeDisk(Disk):
903
929
  part.isidentifier() for part in prefix.split('.')
904
930
  ):
905
931
  raise ValueError('%r is not a module name' % (entry,))
906
- if any(
907
- _in_modules(prefix, (unsafe,))
908
- or _in_modules(unsafe, (prefix,))
909
- for unsafe in _UNSAFE_MODULES
910
- ):
911
- raise ValueError(
912
- 'with_types refuses the module %r; use with_numpy() to'
913
- ' store arrays' % (entry,)
914
- )
932
+ for unsafe in _UNSAFE_MODULES:
933
+ if _in_modules(prefix, (unsafe,)) or _in_modules(
934
+ unsafe, (prefix,)
935
+ ):
936
+ raise ValueError(
937
+ 'with_types refuses the module %r%s'
938
+ % (entry, _unsafe_reason(unsafe))
939
+ )
915
940
  prefixes.append(prefix)
916
941
 
917
942
  return _derive_disk(
@@ -933,7 +958,7 @@ class SafeDisk(Disk):
933
958
  value except in keys, and checks each header before it reads an
934
959
  array back. (Plain :class:`SafeDisk` refuses numpy pickles, because
935
960
  unpickling an array is not memory-safe.) numpy scalars keep their
936
- type.
961
+ type, and arrays of ``datetime64`` and ``timedelta64`` are stored too.
937
962
  Object arrays, ``ndarray`` subclasses and other numpy scalars raise
938
963
  :exc:`TypeError` when written. It combines with :meth:`with_types`
939
964
  and :meth:`with_options` in any order.
@@ -966,22 +991,29 @@ class SafeDisk(Disk):
966
991
  return arrays.unpickler(self, file)
967
992
  return self._plain_unpickler(file)
968
993
 
969
- def _extended(self):
970
- return bool(self.safe_types or self.safe_modules)
971
-
972
994
  def _check_object(self, obj, what):
973
- """Raise TypeError unless the reader would accept `obj` back."""
995
+ """Raise TypeError unless the reader would accept `obj` back.
996
+
997
+ Outside SAFE_TYPES, an object is accepted when its pickle looks up
998
+ only names the reader resolves, such as a dict subclass that pickles
999
+ as a plain dict, which is then what the reader builds.
1000
+
1001
+ """
974
1002
  try:
975
1003
  self._check(obj, what)
976
1004
  except TypeError:
977
- if not self._extended():
978
- raise
1005
+ pass
979
1006
  else:
980
1007
  return
981
1008
 
982
- self._check_pickle(
983
- pickle.dumps(obj, protocol=self.pickle_protocol), obj, what
984
- )
1009
+ try:
1010
+ data = pickle.dumps(obj, protocol=self.pickle_protocol)
1011
+ except (pickle.PicklingError, AttributeError) as error:
1012
+ raise TypeError(
1013
+ 'SafeDisk cannot store %s: %s' % (what, error)
1014
+ ) from error
1015
+
1016
+ self._check_pickle(data, obj, what)
985
1017
 
986
1018
  def _check_pickle(self, data, obj, what):
987
1019
  """Raise TypeError unless the reader would accept the pickle `data`."""
@@ -1002,17 +1034,38 @@ class SafeDisk(Disk):
1002
1034
  except pickle.UnpicklingError:
1003
1035
  raise TypeError(
1004
1036
  'SafeDisk cannot store %s of type %s.%s: it pickles'
1005
- ' %s.%s, which the allowlist refuses; allow it with'
1006
- ' SafeDisk.with_types()'
1037
+ ' %s.%s, which the allowlist refuses%s'
1007
1038
  % (
1008
1039
  what,
1009
1040
  type(obj).__module__,
1010
1041
  type(obj).__qualname__,
1011
1042
  module,
1012
1043
  name,
1044
+ self._refusal_hint(module, name),
1013
1045
  )
1014
1046
  ) from None
1015
1047
 
1048
+ def _refusal_hint(self, module, name):
1049
+ """Return how to store what pickles the refused `module`.`name`."""
1050
+ if _in_modules(module, ('numpy',)):
1051
+ if self.safe_numpy:
1052
+ return '; with_numpy() stores only arrays and numpy scalars'
1053
+ return '; store arrays with SafeDisk.with_numpy()'
1054
+
1055
+ for unsafe in _UNSAFE_MODULES:
1056
+ if _in_modules(module, (unsafe,)):
1057
+ return '; with_types() refuses %s classes' % unsafe
1058
+
1059
+ obj = sys.modules.get(module)
1060
+ for part in name.split('.'):
1061
+ obj = getattr(obj, part, None)
1062
+
1063
+ if not isinstance(obj, type):
1064
+ return '; it is not a class, and with_types() allows only classes'
1065
+ if module in ('builtins', '__builtin__'):
1066
+ return ''
1067
+ return '; allow it with SafeDisk.with_types()'
1068
+
1016
1069
  def _check(self, obj, what):
1017
1070
  """Raise TypeError unless every object in `obj` is in SAFE_TYPES."""
1018
1071
  seen = set()
@@ -1023,6 +1076,8 @@ class SafeDisk(Disk):
1023
1076
  type_item = type(item)
1024
1077
 
1025
1078
  if type_item not in SAFE_TYPES:
1079
+ if isinstance(item, type) and item in _SAFE_CLASSES:
1080
+ continue
1026
1081
  raise TypeError(
1027
1082
  'SafeDisk cannot store %s of type %s.%s'
1028
1083
  % (what, type_item.__module__, type_item.__qualname__)
@@ -1034,7 +1089,7 @@ class SafeDisk(Disk):
1034
1089
  seen.add(id(item))
1035
1090
  if type_item is co.defaultdict:
1036
1091
  factory = item.default_factory
1037
- if factory is not None and factory not in SAFE_TYPES:
1092
+ if factory is not None and factory not in _SAFE_CLASSES:
1038
1093
  raise TypeError(
1039
1094
  'SafeDisk cannot store %s with defaultdict'
1040
1095
  ' factory %r' % (what, factory)
@@ -1996,8 +2051,12 @@ class Cache:
1996
2051
  cur = getattr(self._local, 'cur', None)
1997
2052
 
1998
2053
  if cur is None:
1999
- self._connect()
2000
- cur = self._local.cur
2054
+ # Through _con, so a subclass that overrides it, to open another
2055
+ # database file say, gets a cursor on that connection.
2056
+ con = self._con
2057
+ cur = getattr(self._local, 'cur', None)
2058
+ if cur is None or cur.connection is not con:
2059
+ cur = self._local.cur = con.cursor()
2001
2060
 
2002
2061
  return cur
2003
2062
 
@@ -8,6 +8,7 @@ from typing import (
8
8
  Final,
9
9
  Generator,
10
10
  Generic,
11
+ Iterable,
11
12
  Literal,
12
13
  NoReturn,
13
14
  overload,
@@ -192,7 +193,7 @@ class SafeDisk(Disk):
192
193
  safe_numpy: bool
193
194
  @classmethod
194
195
  def with_types(
195
- cls, *types: type, modules: tuple[str, ...] = ...
196
+ cls, *types: type, modules: str | Iterable[str] = ...
196
197
  ) -> type[Self]: ...
197
198
  @classmethod
198
199
  def with_numpy(cls) -> type[Self]: ...
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diskcache-community
3
- Version: 5.8.2
3
+ Version: 5.8.3
4
4
  Summary: Disk Cache -- Disk and file backed persistent cache.
5
5
  Author: Grant Jenks
6
6
  Maintainer: diskcache-community contributors
@@ -2319,6 +2319,50 @@ def test_get_after_close_uses_new_cursor(cache):
2319
2319
  assert cache._local.cur is not cur
2320
2320
 
2321
2321
 
2322
+ class OtherDatabaseCache(dc.Cache):
2323
+ """Cache written against 5.6.3 that keeps its rows in other.db."""
2324
+
2325
+ @property
2326
+ def _con(self):
2327
+ con = getattr(self._local, 'con', None)
2328
+ if con is None:
2329
+ con = self._local.con = sqlite3.connect(
2330
+ op.join(self._directory, 'other.db'),
2331
+ timeout=self._timeout,
2332
+ isolation_level=None,
2333
+ )
2334
+ return con
2335
+
2336
+
2337
+ def test_get_uses_cursor_of_overridden_con():
2338
+ directory = tempfile.mkdtemp()
2339
+ try:
2340
+ with OtherDatabaseCache(directory) as cache:
2341
+ cache.set('a', 1)
2342
+ assert cache.get('a') == 1
2343
+ assert cache._local.cur.connection is cache._con
2344
+
2345
+ other = []
2346
+
2347
+ def get_in_thread():
2348
+ other.append(cache.get('a'))
2349
+ cache.close()
2350
+
2351
+ thread = threading.Thread(target=get_in_thread)
2352
+ thread.start()
2353
+ thread.join()
2354
+ assert other == [1]
2355
+
2356
+ with OtherDatabaseCache(directory) as cache:
2357
+ assert cache.get('a') == 1
2358
+ assert cache.get('b') is None
2359
+
2360
+ assert op.exists(op.join(directory, 'other.db'))
2361
+ assert not op.exists(op.join(directory, 'cache.db'))
2362
+ finally:
2363
+ shutil.rmtree(directory, ignore_errors=True)
2364
+
2365
+
2322
2366
  def test_get_reads_only_value_columns_without_metadata(cache):
2323
2367
  cache.set('a', 1, tag='t')
2324
2368
  statements = []
@@ -18,7 +18,12 @@ import zoneinfo
18
18
  import pytest
19
19
 
20
20
  import diskcache_community as dc
21
- from diskcache_community.core import MODE_BINARY, MODE_PICKLE, SafeUnpickler
21
+ from diskcache_community.core import (
22
+ MODE_BINARY,
23
+ MODE_PICKLE,
24
+ SAFE_TYPES,
25
+ SafeUnpickler,
26
+ )
22
27
 
23
28
  pytestmark = pytest.mark.filterwarnings('ignore', category=dc.EmptyDirWarning)
24
29
 
@@ -104,6 +109,10 @@ SAFE_VALUES = [
104
109
  decimal.Decimal('-1e100'),
105
110
  fractions.Fraction(1, 3),
106
111
  uuid.UUID('12345678-1234-5678-1234-567812345678'),
112
+ co.defaultdict(int, {'a': 1}),
113
+ # The classes themselves, as memoize(typed=True) puts them into keys.
114
+ *sorted(SAFE_TYPES - {type(None), bool}, key=repr),
115
+ ('f', 1, 'a', int, str),
107
116
  ]
108
117
 
109
118
  HASHABLE_VALUES = [
@@ -121,14 +130,18 @@ UNSAFE_VALUES = [
121
130
  (1, (2, (Custom(),))),
122
131
  co.deque([Custom()]),
123
132
  co.defaultdict(Custom),
133
+ co.defaultdict(bool),
124
134
  dt.datetime(2020, 1, 2, tzinfo=CustomTz()),
125
135
  dt.time(1, tzinfo=CustomTz()),
126
136
  dt.datetime(2020, 1, 2, tzinfo=zoneinfo.ZoneInfo('UTC')),
127
137
  type,
128
- int,
138
+ type(None),
139
+ bool,
140
+ (1, type(None)),
141
+ Custom,
129
142
  len,
130
143
  Poison,
131
- object(),
144
+ lambda: None,
132
145
  Exception('boom'),
133
146
  ]
134
147
 
@@ -330,21 +343,84 @@ def test_key_rejected(cache):
330
343
  assert len(cache) == 0
331
344
 
332
345
 
333
- def test_typed_memoize_rejected(cache):
334
- @cache.memoize(typed=True)
335
- def identity(value):
336
- return value
346
+ @pytest.mark.parametrize('disk', [dc.SafeDisk, dc.SafeDisk.with_types()])
347
+ def test_typed_memoize(directory, disk):
348
+ with dc.Cache(directory, disk=disk) as cache:
337
349
 
338
- with pytest.raises(TypeError, match='SafeDisk cannot store key'):
339
- identity(1)
350
+ @cache.memoize(typed=True)
351
+ def identity(value):
352
+ return value
353
+
354
+ assert identity(1) == 1
355
+ assert identity(1.0) == 1.0
356
+ assert identity('a') == 'a'
357
+ assert identity(1) == 1
358
+ assert len(cache) == 3
359
+
360
+ with pytest.raises(TypeError, match='SafeDisk cannot store key'):
361
+ identity(None)
362
+
363
+ with pytest.raises(TypeError, match='SafeDisk cannot store key'):
364
+ identity(True)
365
+
366
+ assert len(cache) == 3
367
+
368
+ with dc.Cache(directory, disk=dc.SafeDisk) as cache:
369
+ assert sorted(key[-1].__name__ for key in cache) == [
370
+ 'float',
371
+ 'int',
372
+ 'str',
373
+ ]
340
374
 
341
- @cache.memoize()
342
- def plain(value):
343
- return value
344
375
 
345
- assert plain(1) == 1
346
- assert plain(1) == 1
347
- assert len(cache) == 1
376
+ class PlainDict(dict):
377
+ """A dict subclass that pickles as a dict, as DRF's ReturnDict does."""
378
+
379
+ def __reduce__(self):
380
+ return dict, (dict(self),)
381
+
382
+
383
+ @pytest.mark.parametrize(
384
+ 'disk',
385
+ [dc.SafeDisk, dc.SafeDisk.with_types(), dc.SafeDisk.with_types(Custom)],
386
+ )
387
+ def test_objects_that_pickle_as_allowed_names_are_stored(directory, disk):
388
+ with dc.Cache(directory, disk=disk) as cache:
389
+ cache['dict'] = PlainDict(a=1)
390
+ cache[PlainDict(b=2).get('b')] = [PlainDict(c=3)]
391
+ cache['object'] = object()
392
+
393
+ assert cache['dict'] == {'a': 1}
394
+ assert type(cache['dict']) is dict
395
+ assert type(cache[2][0]) is dict
396
+ assert type(cache['object']) is object
397
+
398
+
399
+ def test_refusal_messages(cache):
400
+ def refusal(value):
401
+ with pytest.raises(TypeError, match='SafeDisk cannot store') as info:
402
+ cache['key'] = value
403
+ return str(info.value)
404
+
405
+ assert refusal(Custom()).endswith(
406
+ 'it pickles %s.Custom, which the allowlist refuses; allow it with'
407
+ ' SafeDisk.with_types()' % __name__
408
+ )
409
+ assert refusal(_execute).endswith(
410
+ 'it pickles %s._execute, which the allowlist refuses; it is not a'
411
+ ' class, and with_types() allows only classes' % __name__
412
+ )
413
+ assert refusal((1, type(None))).endswith(
414
+ 'it pickles builtins.type, which the allowlist refuses'
415
+ )
416
+ assert refusal([bool]).endswith(
417
+ 'it pickles builtins.bool, which the allowlist refuses'
418
+ )
419
+ assert refusal(co.defaultdict(bool)).endswith(
420
+ 'it pickles builtins.bool, which the allowlist refuses'
421
+ )
422
+ assert refusal(lambda: None).startswith('SafeDisk cannot store value: ')
423
+ assert len(cache) == 0
348
424
 
349
425
 
350
426
  def test_read_values_untouched(cache):
@@ -187,6 +187,13 @@ def raw(header, body=b''):
187
187
  np.zeros((0, 3)),
188
188
  np.arange(4.0).reshape(2, 2).T,
189
189
  np.arange(3, dtype='>i4'),
190
+ # numpy exports no buffer for these, so the data goes as bytes.
191
+ np.array(['2026-01-01T00:00', 'NaT'], dtype='datetime64[ns]'),
192
+ np.arange(6).astype('datetime64[D]').reshape(2, 3).T,
193
+ np.array(np.datetime64('2026-01-01')),
194
+ np.arange(3).astype('>m8[s]'),
195
+ np.array([(1, 2)], dtype=[('a', 'i4'), ('t', 'M8[us]')]),
196
+ np.zeros(0, dtype='m8[ns]'),
190
197
  ],
191
198
  ids=lambda array: '%s-%s' % (array.dtype, array.shape),
192
199
  )
@@ -910,6 +917,34 @@ def test_safedisk_still_refuses_numpy(directory):
910
917
  assert len(cache) == 0
911
918
 
912
919
 
920
+ @pytest.mark.parametrize(
921
+ 'disk', [dc.SafeDisk, dc.SafeDisk.with_types(co.UserDict)]
922
+ )
923
+ def test_numpy_refusals_point_to_with_numpy(directory, disk):
924
+ with dc.Cache(directory, disk=disk) as cache:
925
+ for value in (np.arange(2.0), np.dtype('f8'), [np.float64(1.5)]):
926
+ with pytest.raises(TypeError) as info:
927
+ cache['key'] = value
928
+ assert str(info.value).endswith(
929
+ 'store arrays with SafeDisk.with_numpy()'
930
+ )
931
+
932
+
933
+ def test_with_numpy_refusal_of_other_numpy_objects(directory):
934
+ with dc.Cache(directory, disk=ArrayDisk) as cache:
935
+ with pytest.raises(TypeError) as info:
936
+ cache['key'] = {'dtype': np.dtype('f8')}
937
+ assert str(info.value).endswith(
938
+ 'with_numpy() stores only arrays and numpy scalars'
939
+ )
940
+
941
+
942
+ def test_object_array_refusal_mentions_dataframes(directory):
943
+ with dc.Cache(directory, disk=ArrayDisk) as cache:
944
+ with pytest.raises(TypeError, match='DataFrame .* records'):
945
+ cache['key'] = np.array(['a', None], dtype=object)
946
+
947
+
913
948
  def test_safejsondisk_refuses_with_numpy():
914
949
  with pytest.raises(TypeError, match='SafeJSONDisk'):
915
950
  dc.SafeJSONDisk.with_numpy()
@@ -11,6 +11,7 @@ import struct
11
11
  import sys
12
12
  import tempfile
13
13
  import tracemalloc
14
+ import types
14
15
 
15
16
  import pytest
16
17
 
@@ -443,6 +444,44 @@ def test_numpy_is_refused(directory):
443
444
  cache['value'] = value
444
445
 
445
446
 
447
+ @pytest.mark.parametrize(
448
+ 'package, module',
449
+ [
450
+ ('ahocorasick', 'ahocorasick'),
451
+ ('bitstruct', 'bitstruct.c'),
452
+ ('scipy', 'scipy.sparse._csr'),
453
+ ('torch', 'torch.fx.graph_module'),
454
+ ],
455
+ )
456
+ def test_unsafe_packages_are_refused(package, module):
457
+ entry = type('Thing', (), {'__module__': module})
458
+
459
+ with pytest.raises(ValueError, match='refuses the class .*not safe'):
460
+ dc.SafeDisk.with_types(entry)
461
+
462
+ for prefix in (package, module, package + '.'):
463
+ with pytest.raises(ValueError, match='refuses the module .*not safe'):
464
+ dc.SafeDisk.with_types(modules=prefix)
465
+
466
+ # A package whose name only starts with the same letters is allowed.
467
+ dc.SafeDisk.with_types(modules=package + 'x')
468
+
469
+
470
+ def test_refusal_names_an_unsafe_package(directory, monkeypatch):
471
+ module = types.ModuleType('bitstruct')
472
+ module.Format = type('Format', (), {'__module__': 'bitstruct'})
473
+ monkeypatch.setitem(sys.modules, 'bitstruct', module)
474
+
475
+ with dc.Cache(directory, disk=dc.SafeDisk) as cache:
476
+ with pytest.raises(TypeError) as info:
477
+ cache['value'] = [module.Format()]
478
+
479
+ assert str(info.value).endswith(
480
+ 'it pickles bitstruct.Format, which the allowlist refuses;'
481
+ ' with_types() refuses bitstruct classes'
482
+ )
483
+
484
+
446
485
  def test_modules_are_never_imported_by_a_read():
447
486
  name = 'tests.safedisk_models.side_effect'
448
487
  data = stack_global(name, 'Thing')