pickle-debug 1.0__tar.gz → 1.2__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.2
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.2"
6
6
  dependencies = []
7
7
  requires-python= ">= 3.12"
8
8
  readme = "README.md"
@@ -0,0 +1,174 @@
1
+ import functools
2
+ import hashlib
3
+ import importlib.metadata
4
+ import io
5
+ import logging
6
+ import os
7
+ import pickle
8
+ import shutil
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Any, Callable
12
+
13
+ pickle_debug_logger = logging.getLogger(__name__)
14
+
15
+ __version__ = importlib.metadata.version('pickle-debug')
16
+
17
+ _CACHE_DIRECTORY: Path | None = None
18
+ _SUFFIX = '.pickle'
19
+
20
+
21
+ def enable(directory: Path | str) -> None:
22
+ """Enable caching in specified directory. Raises OSError if directory can't be created or written"""
23
+ global _CACHE_DIRECTORY
24
+ directory = Path(directory)
25
+ directory.mkdir(parents=True, exist_ok=True)
26
+ # verify we can create and write files
27
+ with tempfile.TemporaryFile(dir=directory) as f:
28
+ f.write(b'test')
29
+ _CACHE_DIRECTORY = directory
30
+ pickle_debug_logger.debug(f"caching to {directory}")
31
+
32
+
33
+ def reset() -> None:
34
+ """Remove entire cache directory. Raises exception if it exists and can't be removed"""
35
+ if _CACHE_DIRECTORY is None or not _CACHE_DIRECTORY.exists():
36
+ return
37
+ unexpected = [p for p in _CACHE_DIRECTORY.iterdir()
38
+ if not (p.is_file() and not p.is_symlink() and p.suffix in (_SUFFIX, '.tmp'))]
39
+ if unexpected:
40
+ raise RuntimeError(f"Not removing {_CACHE_DIRECTORY}, unexpected contents: "
41
+ + ', '.join(p.name for p in unexpected))
42
+ try:
43
+ shutil.rmtree(_CACHE_DIRECTORY)
44
+ except OSError as e:
45
+ raise RuntimeError(f"Unable to remove cache {_CACHE_DIRECTORY}") from e
46
+ if _CACHE_DIRECTORY.exists():
47
+ raise RuntimeError(f"Unable to remove cache {_CACHE_DIRECTORY}")
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
+
106
+ def _save(path: Path, result: Any) -> None:
107
+ """Write via temporary file so an interrupted write doesn't leave partial result"""
108
+ data = _dumps(result)
109
+ fd, tmp = tempfile.mkstemp(dir=path.parent, suffix='.tmp')
110
+ try:
111
+ with os.fdopen(fd, 'wb') as f:
112
+ f.write(data)
113
+ os.replace(tmp, path)
114
+ except BaseException:
115
+ Path(tmp).unlink(missing_ok=True)
116
+ raise
117
+
118
+
119
+ def _is_method_receiver(obj: Any, name: str, wrapper: Callable) -> bool:
120
+ """True if obj is the self or cls that wrapper was called through as a method"""
121
+ cls = obj if isinstance(obj, type) else type(obj)
122
+ for klass in cls.__mro__:
123
+ attr = vars(klass).get(name)
124
+ if isinstance(attr, classmethod):
125
+ attr = attr.__func__
126
+ if attr is wrapper:
127
+ return True
128
+ return False
129
+
130
+
131
+ def cached(func: Callable) -> Callable:
132
+ """Decorator: cache result of func in directory set by enable(), keyed by arguments.
133
+ When used on a method, self (or cls) is keyed by its class name rather than its current value"""
134
+
135
+ @functools.wraps(func)
136
+ def wrapper(*args, **kwargs):
137
+ directory = _CACHE_DIRECTORY
138
+ if directory is None:
139
+ return func(*args, **kwargs)
140
+ key_args = args
141
+ if args and _is_method_receiver(args[0], func.__name__, wrapper):
142
+ cls = args[0] if isinstance(args[0], type) else type(args[0])
143
+ key_args = (f"{cls.__module__}.{cls.__qualname__}",) + args[1:]
144
+ key = _dumps((func.__module__, func.__qualname__, key_args, sorted(kwargs.items())))
145
+ path = directory / (hashlib.sha256(key).hexdigest() + _SUFFIX)
146
+ try:
147
+ with open(path, 'rb') as f:
148
+ result = pickle.load(f)
149
+ pickle_debug_logger.debug(f"{func.__qualname__} read from {path}")
150
+ return result
151
+ except FileNotFoundError:
152
+ pass
153
+ result = func(*args, **kwargs)
154
+ directory.mkdir(parents=True, exist_ok=True)
155
+ _save(path, result)
156
+ pickle_debug_logger.debug(f"{func.__qualname__} stored in {path}")
157
+ return result
158
+
159
+ return wrapper
160
+
161
+ def record(name:str,fn,*args,**kwargs):
162
+ p = None
163
+ if _CACHE_DIRECTORY is not None:
164
+ p = _CACHE_DIRECTORY / f"{name}.pickle"
165
+ if p.is_file():
166
+ with open(p,'rb') as f:
167
+ result = pickle.load(f)
168
+ return result
169
+ result = fn(*args,**kwargs)
170
+ if p is not None:
171
+ with open(p,'wb') as f:
172
+ pickle.dump(result,f)
173
+ return result
174
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pickle_debug
3
- Version: 1.0
3
+ Version: 1.2
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,6 @@ 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/record.py
12
+ src/test/ttest.py
13
+ 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)
@@ -0,0 +1,11 @@
1
+ import pickle_debug
2
+
3
+ def sum(x,y):
4
+ print(f"{x} + {y}")
5
+ return x + y
6
+
7
+ pickle_debug.enable("/tmp/sum")
8
+ ans = pickle_debug.record("sum",sum,3,4)
9
+ print(ans)
10
+ ans = pickle_debug.record("sum",sum,3,4)
11
+ print(ans)
@@ -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')
@@ -1,84 +0,0 @@
1
- import functools
2
- import hashlib
3
- import importlib.metadata
4
- import logging
5
- import os
6
- import pickle
7
- import shutil
8
- import tempfile
9
- from pathlib import Path
10
- from typing import Any, Callable
11
-
12
- pickle_debug_logger = logging.getLogger(__name__)
13
-
14
- __version__ = importlib.metadata.version('pickle-debug')
15
-
16
- _CACHE_DIRECTORY: Path | None = None
17
- _SUFFIX = '.pickle'
18
-
19
-
20
- def enable(directory: Path | str) -> None:
21
- """Enable caching in specified directory. Raises OSError if directory can't be created or written"""
22
- global _CACHE_DIRECTORY
23
- directory = Path(directory)
24
- directory.mkdir(parents=True, exist_ok=True)
25
- # verify we can create and write files
26
- with tempfile.TemporaryFile(dir=directory) as f:
27
- f.write(b'test')
28
- _CACHE_DIRECTORY = directory
29
- pickle_debug_logger.debug(f"caching to {directory}")
30
-
31
-
32
- def reset() -> None:
33
- """Remove entire cache directory. Raises exception if it exists and can't be removed"""
34
- if _CACHE_DIRECTORY is None or not _CACHE_DIRECTORY.exists():
35
- return
36
- unexpected = [p for p in _CACHE_DIRECTORY.iterdir()
37
- if not (p.is_file() and not p.is_symlink() and p.suffix in (_SUFFIX, '.tmp'))]
38
- if unexpected:
39
- raise RuntimeError(f"Not removing {_CACHE_DIRECTORY}, unexpected contents: "
40
- + ', '.join(p.name for p in unexpected))
41
- try:
42
- shutil.rmtree(_CACHE_DIRECTORY)
43
- except OSError as e:
44
- raise RuntimeError(f"Unable to remove cache {_CACHE_DIRECTORY}") from e
45
- if _CACHE_DIRECTORY.exists():
46
- raise RuntimeError(f"Unable to remove cache {_CACHE_DIRECTORY}")
47
-
48
-
49
- def _save(path: Path, result: Any) -> None:
50
- """Write via temporary file so an interrupted write doesn't leave partial result"""
51
- fd, tmp = tempfile.mkstemp(dir=path.parent, suffix='.tmp')
52
- try:
53
- with os.fdopen(fd, 'wb') as f:
54
- pickle.dump(result, f)
55
- os.replace(tmp, path)
56
- except BaseException:
57
- Path(tmp).unlink(missing_ok=True)
58
- raise
59
-
60
-
61
- def cached(func: Callable) -> Callable:
62
- """Decorator: cache result of func in directory set by enable(), keyed by arguments"""
63
-
64
- @functools.wraps(func)
65
- def wrapper(*args, **kwargs):
66
- directory = _CACHE_DIRECTORY
67
- if directory is None:
68
- return func(*args, **kwargs)
69
- key = pickle.dumps((func.__module__, func.__qualname__, args, sorted(kwargs.items())))
70
- path = directory / (hashlib.sha256(key).hexdigest() + _SUFFIX)
71
- try:
72
- with open(path, 'rb') as f:
73
- result = pickle.load(f)
74
- pickle_debug_logger.debug(f"{func.__qualname__} read from {path}")
75
- return result
76
- except FileNotFoundError:
77
- pass
78
- result = func(*args, **kwargs)
79
- directory.mkdir(parents=True, exist_ok=True)
80
- _save(path, result)
81
- pickle_debug_logger.debug(f"{func.__qualname__} stored in {path}")
82
- return result
83
-
84
- return wrapper
File without changes
File without changes