pickle-debug 1.0__tar.gz → 1.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pickle_debug
3
- Version: 1.0
3
+ Version: 1.1
4
4
  Summary: Store expensive objects from debugging
5
5
  Author: Gerard
6
6
  Author-email: gweatherby@uchc.edu
@@ -77,6 +77,19 @@ Decorator. Returns a wrapper (using `functools.wraps`) that:
77
77
  Results are written to a temporary file and then renamed, so an interrupted run does not leave a partial
78
78
  cache entry.
79
79
 
80
+ #### Unpicklable attributes
81
+ If an object (in the result or in the arguments) has attributes that cannot be pickled, such as locks,
82
+ open files, sockets or lambdas, those attributes are left out and the rest of the object is cached. This
83
+ applies to nested objects too, and covers both `__dict__` and `__slots__` attributes. Each dropped
84
+ attribute is logged at `DEBUG` level.
85
+
86
+ An object loaded from the cache does not have the dropped attributes, so accessing them raises
87
+ `AttributeError`. If the code you are debugging needs them, recreate them after the call.
88
+
89
+ Only attributes are filtered. An unpicklable value at the top level, or directly inside a list, tuple,
90
+ dict or set, still raises an exception. So does an object with a custom `__reduce__` or a `__getstate__`
91
+ that does not return a dict.
92
+
80
93
  ### `reset()`
81
94
  Delete the whole cache directory. It refuses (raises `RuntimeError`) if the directory contains anything
82
95
  other than `.pickle` and `.tmp` files, to avoid deleting unrelated data if `enable()` was pointed at the
@@ -84,8 +97,10 @@ wrong place. It does nothing if caching is not enabled or the directory does not
84
97
 
85
98
  ## Caveats
86
99
 
87
- - **Arguments and results must be picklable.** The arguments are pickled to build the key, and the result
88
- is pickled to store it.
100
+ - **Arguments and results must be picklable**, apart from object attributes, which are left out (see
101
+ [Unpicklable attributes](#unpicklable-attributes)). The arguments are pickled to build the key, and the
102
+ result is pickled to store it. Because dropped attributes are not part of the key, two arguments that
103
+ differ only in unpicklable attributes share a cache entry.
89
104
  - **The key is the pickled arguments.** Equal arguments that pickle differently (for example sets with a
90
105
  different iteration order) may miss the cache. Objects whose pickle changes between calls never hit it.
91
106
  - **The key does not include the function's code or any files it reads.** If you change the function or
@@ -99,4 +114,4 @@ wrong place. It does nothing if caching is not enabled or the directory does not
99
114
  ## Logging
100
115
 
101
116
  The module logs to the `pickle_debug` logger at `DEBUG` level when it enables caching, reads from the
102
- cache, or stores a result.
117
+ cache, stores a result, or leaves an unpicklable attribute out.
@@ -65,6 +65,19 @@ Decorator. Returns a wrapper (using `functools.wraps`) that:
65
65
  Results are written to a temporary file and then renamed, so an interrupted run does not leave a partial
66
66
  cache entry.
67
67
 
68
+ #### Unpicklable attributes
69
+ If an object (in the result or in the arguments) has attributes that cannot be pickled, such as locks,
70
+ open files, sockets or lambdas, those attributes are left out and the rest of the object is cached. This
71
+ applies to nested objects too, and covers both `__dict__` and `__slots__` attributes. Each dropped
72
+ attribute is logged at `DEBUG` level.
73
+
74
+ An object loaded from the cache does not have the dropped attributes, so accessing them raises
75
+ `AttributeError`. If the code you are debugging needs them, recreate them after the call.
76
+
77
+ Only attributes are filtered. An unpicklable value at the top level, or directly inside a list, tuple,
78
+ dict or set, still raises an exception. So does an object with a custom `__reduce__` or a `__getstate__`
79
+ that does not return a dict.
80
+
68
81
  ### `reset()`
69
82
  Delete the whole cache directory. It refuses (raises `RuntimeError`) if the directory contains anything
70
83
  other than `.pickle` and `.tmp` files, to avoid deleting unrelated data if `enable()` was pointed at the
@@ -72,8 +85,10 @@ wrong place. It does nothing if caching is not enabled or the directory does not
72
85
 
73
86
  ## Caveats
74
87
 
75
- - **Arguments and results must be picklable.** The arguments are pickled to build the key, and the result
76
- is pickled to store it.
88
+ - **Arguments and results must be picklable**, apart from object attributes, which are left out (see
89
+ [Unpicklable attributes](#unpicklable-attributes)). The arguments are pickled to build the key, and the
90
+ result is pickled to store it. Because dropped attributes are not part of the key, two arguments that
91
+ differ only in unpicklable attributes share a cache entry.
77
92
  - **The key is the pickled arguments.** Equal arguments that pickle differently (for example sets with a
78
93
  different iteration order) may miss the cache. Objects whose pickle changes between calls never hit it.
79
94
  - **The key does not include the function's code or any files it reads.** If you change the function or
@@ -87,4 +102,4 @@ wrong place. It does nothing if caching is not enabled or the directory does not
87
102
  ## Logging
88
103
 
89
104
  The module logs to the `pickle_debug` logger at `DEBUG` level when it enables caching, reads from the
90
- cache, or stores a result.
105
+ cache, stores a result, or leaves an unpicklable attribute out.
@@ -2,7 +2,7 @@
2
2
  name = "pickle_debug"
3
3
  description = "Store expensive objects from debugging"
4
4
  license = {text = "MIT" }
5
- version = "1.0"
5
+ version = "1.1"
6
6
  dependencies = []
7
7
  requires-python= ">= 3.12"
8
8
  readme = "README.md"
@@ -1,6 +1,7 @@
1
1
  import functools
2
2
  import hashlib
3
3
  import importlib.metadata
4
+ import io
4
5
  import logging
5
6
  import os
6
7
  import pickle
@@ -46,12 +47,69 @@ def reset() -> None:
46
47
  raise RuntimeError(f"Unable to remove cache {_CACHE_DIRECTORY}")
47
48
 
48
49
 
50
+ class _FilteringPickler(pickle.Pickler):
51
+ """Pickler that leaves out object attributes which can't be pickled"""
52
+
53
+ def __init__(self, file, memo: dict[int, list] | None = None):
54
+ # C Pickler doesn't expose its protocol, so keep our own copy
55
+ self._protocol = pickle.DEFAULT_PROTOCOL
56
+ super().__init__(file, self._protocol)
57
+ # id -> [value, picklable], shared with nested test picklers. Holding value keeps id from being reused.
58
+ # picklable is True while test in progress to handle cycles
59
+ self._picklable_memo = {} if memo is None else memo
60
+
61
+ def _picklable(self, value: Any) -> bool:
62
+ key = id(value)
63
+ if key not in self._picklable_memo:
64
+ entry = self._picklable_memo[key] = [value, True]
65
+ try:
66
+ _FilteringPickler(io.BytesIO(), self._picklable_memo).dump(value)
67
+ except Exception:
68
+ entry[1] = False
69
+ return self._picklable_memo[key][1]
70
+
71
+ def _filter(self, obj: Any, state: dict) -> dict:
72
+ kept = {}
73
+ for name, value in state.items():
74
+ if self._picklable(value):
75
+ kept[name] = value
76
+ else:
77
+ pickle_debug_logger.debug(f"leaving unpicklable {type(obj).__qualname__}.{name} out of cache")
78
+ return kept
79
+
80
+ def reducer_override(self, obj):
81
+ if isinstance(obj, type) or not hasattr(obj, '__dict__') and not hasattr(type(obj), '__slots__'):
82
+ return NotImplemented
83
+ try:
84
+ reduced = obj.__reduce_ex__(self._protocol)
85
+ except Exception:
86
+ return NotImplemented
87
+ if not isinstance(reduced, tuple) or len(reduced) < 3:
88
+ return NotImplemented
89
+ state = reduced[2]
90
+ if isinstance(state, dict):
91
+ state = self._filter(obj, state)
92
+ elif (isinstance(state, tuple) and len(state) == 2
93
+ and all(s is None or isinstance(s, dict) for s in state)):
94
+ state = tuple(None if s is None else self._filter(obj, s) for s in state)
95
+ else:
96
+ return NotImplemented
97
+ return reduced[:2] + (state,) + reduced[3:]
98
+
99
+
100
+ def _dumps(obj: Any) -> bytes:
101
+ buffer = io.BytesIO()
102
+ _FilteringPickler(buffer).dump(obj)
103
+ return buffer.getvalue()
104
+
105
+
49
106
  def _save(path: Path, result: Any) -> None:
50
107
  """Write via temporary file so an interrupted write doesn't leave partial result"""
108
+ data = _dumps(result)
51
109
  fd, tmp = tempfile.mkstemp(dir=path.parent, suffix='.tmp')
52
110
  try:
53
111
  with os.fdopen(fd, 'wb') as f:
54
- pickle.dump(result, f)
112
+ f.write(data)
55
113
  os.replace(tmp, path)
56
114
  except BaseException:
57
115
  Path(tmp).unlink(missing_ok=True)
@@ -66,7 +124,7 @@ def cached(func: Callable) -> Callable:
66
124
  directory = _CACHE_DIRECTORY
67
125
  if directory is None:
68
126
  return func(*args, **kwargs)
69
- key = pickle.dumps((func.__module__, func.__qualname__, args, sorted(kwargs.items())))
127
+ key = _dumps((func.__module__, func.__qualname__, args, sorted(kwargs.items())))
70
128
  path = directory / (hashlib.sha256(key).hexdigest() + _SUFFIX)
71
129
  try:
72
130
  with open(path, 'rb') as f:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pickle_debug
3
- Version: 1.0
3
+ Version: 1.1
4
4
  Summary: Store expensive objects from debugging
5
5
  Author: Gerard
6
6
  Author-email: gweatherby@uchc.edu
@@ -77,6 +77,19 @@ Decorator. Returns a wrapper (using `functools.wraps`) that:
77
77
  Results are written to a temporary file and then renamed, so an interrupted run does not leave a partial
78
78
  cache entry.
79
79
 
80
+ #### Unpicklable attributes
81
+ If an object (in the result or in the arguments) has attributes that cannot be pickled, such as locks,
82
+ open files, sockets or lambdas, those attributes are left out and the rest of the object is cached. This
83
+ applies to nested objects too, and covers both `__dict__` and `__slots__` attributes. Each dropped
84
+ attribute is logged at `DEBUG` level.
85
+
86
+ An object loaded from the cache does not have the dropped attributes, so accessing them raises
87
+ `AttributeError`. If the code you are debugging needs them, recreate them after the call.
88
+
89
+ Only attributes are filtered. An unpicklable value at the top level, or directly inside a list, tuple,
90
+ dict or set, still raises an exception. So does an object with a custom `__reduce__` or a `__getstate__`
91
+ that does not return a dict.
92
+
80
93
  ### `reset()`
81
94
  Delete the whole cache directory. It refuses (raises `RuntimeError`) if the directory contains anything
82
95
  other than `.pickle` and `.tmp` files, to avoid deleting unrelated data if `enable()` was pointed at the
@@ -84,8 +97,10 @@ wrong place. It does nothing if caching is not enabled or the directory does not
84
97
 
85
98
  ## Caveats
86
99
 
87
- - **Arguments and results must be picklable.** The arguments are pickled to build the key, and the result
88
- is pickled to store it.
100
+ - **Arguments and results must be picklable**, apart from object attributes, which are left out (see
101
+ [Unpicklable attributes](#unpicklable-attributes)). The arguments are pickled to build the key, and the
102
+ result is pickled to store it. Because dropped attributes are not part of the key, two arguments that
103
+ differ only in unpicklable attributes share a cache entry.
89
104
  - **The key is the pickled arguments.** Equal arguments that pickle differently (for example sets with a
90
105
  different iteration order) may miss the cache. Objects whose pickle changes between calls never hit it.
91
106
  - **The key does not include the function's code or any files it reads.** If you change the function or
@@ -99,4 +114,4 @@ wrong place. It does nothing if caching is not enabled or the directory does not
99
114
  ## Logging
100
115
 
101
116
  The module logs to the `pickle_debug` logger at `DEBUG` level when it enables caching, reads from the
102
- cache, or stores a result.
117
+ cache, stores a result, or leaves an unpicklable attribute out.
@@ -8,4 +8,5 @@ src/pickle_debug.egg-info/dependency_links.txt
8
8
  src/pickle_debug.egg-info/top_level.txt
9
9
  src/test/patch_test.py
10
10
  src/test/pd_test.py
11
- src/test/ttest.py
11
+ src/test/ttest.py
12
+ src/test/unpicklable_test.py
@@ -8,9 +8,10 @@ def f(x, y=1):
8
8
  time.sleep(1.5)
9
9
  return x + y
10
10
 
11
+
11
12
  class Slow:
12
13
 
13
- def invoke(self,x):
14
+ def invoke(self, x):
14
15
  time.sleep(1.5)
15
16
  return x * x
16
17
 
@@ -18,8 +19,8 @@ class Slow:
18
19
  pickle_debug.enable("/tmp/mycache")
19
20
 
20
21
  f = pickle_debug.cached(f)
21
- print(f(5,6))
22
- print(f(5,6))
22
+ print(f(5, 6))
23
+ print(f(5, 6))
23
24
 
24
25
  slow = Slow()
25
26
  slow.invoke = pickle_debug.cached(slow.invoke)
@@ -7,14 +7,14 @@ import pickle_debug
7
7
  def f(x, y=1):
8
8
  print(f"call {x} {y}")
9
9
  time.sleep(1.5)
10
- return x + y
10
+ return x + y
11
11
 
12
- print(f(1,2))
13
- print(f(1,2))
14
- pickle_debug.enable("/tmp/mycache")
15
- print(f(1,2))
16
- print(f(1,2))
17
- print(f(3,4))
18
- print(f(1,2))
19
- print(f(3,4))
20
12
 
13
+ print(f(1, 2))
14
+ print(f(1, 2))
15
+ pickle_debug.enable("/tmp/mycache")
16
+ print(f(1, 2))
17
+ print(f(1, 2))
18
+ print(f(3, 4))
19
+ print(f(1, 2))
20
+ print(f(3, 4))
@@ -0,0 +1,46 @@
1
+ import pathlib
2
+ import tempfile
3
+ import threading
4
+
5
+ from pickle_debug import cached, enable
6
+
7
+
8
+ class Holder:
9
+ def __init__(self, value):
10
+ self.value = value
11
+ self.lock = threading.Lock()
12
+ self.inner = Inner()
13
+ self.me = self
14
+
15
+
16
+ class Inner:
17
+ def __init__(self):
18
+ self.ok = 'inner'
19
+ self.callback = lambda: None
20
+
21
+
22
+ class Slotted:
23
+ __slots__ = ('a', 'lock')
24
+
25
+ def __init__(self):
26
+ self.a = 1
27
+ self.lock = threading.Lock()
28
+
29
+
30
+ calls = []
31
+
32
+
33
+ @cached
34
+ def make(x):
35
+ calls.append(x)
36
+ return Holder(x), Slotted()
37
+
38
+
39
+ enable(pathlib.Path(tempfile.mkdtemp()) / 'cache')
40
+ make(5)
41
+ h, s = make(5)
42
+ assert calls == [5], calls
43
+ assert h.value == 5 and not hasattr(h, 'lock') and h.me is h
44
+ assert h.inner.ok == 'inner' and not hasattr(h.inner, 'callback')
45
+ assert s.a == 1 and not hasattr(s, 'lock')
46
+ print('ok')
File without changes
File without changes