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.
- {pickle_debug-1.0/src/pickle_debug.egg-info → pickle_debug-1.2}/PKG-INFO +19 -4
- {pickle_debug-1.0 → pickle_debug-1.2}/README.md +18 -3
- {pickle_debug-1.0 → pickle_debug-1.2}/pyproject.toml +1 -1
- pickle_debug-1.2/src/pickle_debug/__init__.py +174 -0
- {pickle_debug-1.0 → pickle_debug-1.2/src/pickle_debug.egg-info}/PKG-INFO +19 -4
- {pickle_debug-1.0 → pickle_debug-1.2}/src/pickle_debug.egg-info/SOURCES.txt +3 -1
- {pickle_debug-1.0 → pickle_debug-1.2}/src/test/patch_test.py +4 -3
- pickle_debug-1.2/src/test/record.py +11 -0
- {pickle_debug-1.0 → pickle_debug-1.2}/src/test/ttest.py +9 -9
- pickle_debug-1.2/src/test/unpicklable_test.py +46 -0
- pickle_debug-1.0/src/pickle_debug/__init__.py +0 -84
- {pickle_debug-1.0 → pickle_debug-1.2}/LICENSE +0 -0
- {pickle_debug-1.0 → pickle_debug-1.2}/setup.cfg +0 -0
- {pickle_debug-1.0 → pickle_debug-1.2}/src/pickle_debug.egg-info/dependency_links.txt +0 -0
- {pickle_debug-1.0 → pickle_debug-1.2}/src/pickle_debug.egg-info/top_level.txt +0 -0
- {pickle_debug-1.0 → pickle_debug-1.2}/src/test/pd_test.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pickle_debug
|
|
3
|
-
Version: 1.
|
|
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
|
|
88
|
-
|
|
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,
|
|
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
|
|
76
|
-
|
|
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,
|
|
105
|
+
cache, stores a result, or leaves an unpicklable attribute out.
|
|
@@ -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.
|
|
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
|
|
88
|
-
|
|
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,
|
|
117
|
+
cache, stores a result, or leaves an unpicklable attribute out.
|
|
@@ -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')
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|