pickle-debug 1.0__py3-none-any.whl

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.
@@ -0,0 +1,84 @@
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
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: pickle_debug
3
+ Version: 1.0
4
+ Summary: Store expensive objects from debugging
5
+ Author: Gerard
6
+ Author-email: gweatherby@uchc.edu
7
+ License: MIT
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Dynamic: license-file
12
+
13
+ # pickle-debug
14
+ Pickle expensive operations for debugging operation
15
+
16
+ When debugging code that runs after a slow step (loading a large file, a long computation, a network call),
17
+ re-running the slow step on every iteration wastes time. `pickle_debug` caches the return value of
18
+ selected functions on disk, so the first call computes and stores the result and later calls with the
19
+ same arguments load it from the cache.
20
+
21
+ Requires Python 3.12 or later. No dependencies.
22
+
23
+ ## Usage
24
+
25
+ ```python
26
+ import pickle_debug
27
+
28
+ @pickle_debug.cached
29
+ def load_data(path, limit=None):
30
+ ... # slow
31
+
32
+ pickle_debug.enable("/tmp/mycache")
33
+
34
+ load_data("big.csv") # computed, then stored
35
+ load_data("big.csv") # read from cache
36
+ ```
37
+
38
+ Caching is off until `enable()` is called. Until then, decorated functions simply call through.
39
+ This makes it safe to leave the `@cached` decorators in place and turn caching on only while debugging.
40
+
41
+ ### Decorating a function from another module
42
+
43
+ A decorator is an ordinary function, so you can apply it after importing:
44
+
45
+ ```python
46
+ import other_module
47
+ other_module.slow = pickle_debug.cached(other_module.slow)
48
+ ```
49
+
50
+ This affects callers that look up `other_module.slow` at call time. Modules that already did
51
+ `from other_module import slow` keep the undecorated function, so patch before they import it.
52
+
53
+ ### Methods
54
+
55
+ ```python
56
+ slow = Slow()
57
+ slow.invoke = pickle_debug.cached(slow.invoke) # one instance
58
+
59
+ Slow.invoke = pickle_debug.cached(Slow.invoke) # all instances
60
+ ```
61
+
62
+ ## API
63
+
64
+ ### `enable(directory)`
65
+ Turn caching on, storing results in `directory` (a `str` or `Path`). The directory is created if
66
+ needed and checked for writability. Raises `OSError` if it cannot be created or written.
67
+
68
+ ### `cached(func)`
69
+ Decorator. Returns a wrapper (using `functools.wraps`) that:
70
+
71
+ 1. calls `func` directly if caching is not enabled;
72
+ 2. otherwise builds a key from `func.__module__`, `func.__qualname__`, the positional arguments and
73
+ the sorted keyword arguments, and hashes it with SHA-256;
74
+ 3. returns the pickled result from `<directory>/<hash>.pickle` if present;
75
+ 4. otherwise calls `func`, writes the result to the cache, and returns it.
76
+
77
+ Results are written to a temporary file and then renamed, so an interrupted run does not leave a partial
78
+ cache entry.
79
+
80
+ ### `reset()`
81
+ Delete the whole cache directory. It refuses (raises `RuntimeError`) if the directory contains anything
82
+ other than `.pickle` and `.tmp` files, to avoid deleting unrelated data if `enable()` was pointed at the
83
+ wrong place. It does nothing if caching is not enabled or the directory does not exist.
84
+
85
+ ## Caveats
86
+
87
+ - **Arguments and results must be picklable.** The arguments are pickled to build the key, and the result
88
+ is pickled to store it.
89
+ - **The key is the pickled arguments.** Equal arguments that pickle differently (for example sets with a
90
+ different iteration order) may miss the cache. Objects whose pickle changes between calls never hit it.
91
+ - **The key does not include the function's code or any files it reads.** If you change the function or
92
+ its input data, call `reset()` or use a new cache directory, or you will get stale results.
93
+ - **Bound methods and `self`.** A wrapped bound method (`slow.invoke`) does not include `self` in the key,
94
+ so all instances share entries. A wrapped class function (`Slow.invoke`) includes `self`, so the
95
+ instance must be picklable and its state is part of the key.
96
+ - This is a debugging aid. Do not use it as a production cache. Cached files are loaded with `pickle`,
97
+ so only use a directory you trust.
98
+
99
+ ## Logging
100
+
101
+ The module logs to the `pickle_debug` logger at `DEBUG` level when it enables caching, reads from the
102
+ cache, or stores a result.
@@ -0,0 +1,9 @@
1
+ pickle_debug/__init__.py,sha256=p4KhG1gXI5l_E4oBN1Bb_I0uj69CL2cz0tu_GqfMRek,2951
2
+ pickle_debug-1.0.dist-info/licenses/LICENSE,sha256=kZWT22beIUMcucFOsCTbUT97IwKS7dzjUlVFm8tRzuY,1063
3
+ test/patch_test.py,sha256=HGZF9ZRufenD5tlEUjHfSOemZpLFt4HG5l6Ff9n1Rrw,397
4
+ test/pd_test.py,sha256=dYNULIBaCm8M-DjcgswRewTxEAJ3_zEChNmrfyZaGOw,990
5
+ test/ttest.py,sha256=3VJ9VE6lYAfteulpsf5KL-st-0yQg24hFSu9Qb13uGk,273
6
+ pickle_debug-1.0.dist-info/METADATA,sha256=wQoKyOVfBmYoYl0LRtJSUy2eiNTDK57vahE04zPR2IM,3958
7
+ pickle_debug-1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ pickle_debug-1.0.dist-info/top_level.txt,sha256=8PVaLGUiMvXr3X6lNQ7vkV7i2y1I4Toa2rosLI2wIqA,18
9
+ pickle_debug-1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NMRhub
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ pickle_debug
2
+ test
test/patch_test.py ADDED
@@ -0,0 +1,27 @@
1
+ import time
2
+
3
+ import pickle_debug
4
+
5
+
6
+ def f(x, y=1):
7
+ print(f"call {x} {y}")
8
+ time.sleep(1.5)
9
+ return x + y
10
+
11
+ class Slow:
12
+
13
+ def invoke(self,x):
14
+ time.sleep(1.5)
15
+ return x * x
16
+
17
+
18
+ pickle_debug.enable("/tmp/mycache")
19
+
20
+ f = pickle_debug.cached(f)
21
+ print(f(5,6))
22
+ print(f(5,6))
23
+
24
+ slow = Slow()
25
+ slow.invoke = pickle_debug.cached(slow.invoke)
26
+ print(slow.invoke(7))
27
+ print(slow.invoke(7))
test/pd_test.py ADDED
@@ -0,0 +1,43 @@
1
+ import pathlib
2
+ import tempfile
3
+
4
+ from pickle_debug import cached, enable, reset
5
+
6
+ calls = []
7
+
8
+
9
+ @cached
10
+ def f(x, y=1):
11
+ calls.append((x, y))
12
+ return [x, y]
13
+
14
+
15
+ assert f(1) == [1, 1] and f(1) == [1, 1] and len(calls) == 2 # disabled: passthrough
16
+ d = pathlib.Path(tempfile.mkdtemp()) / 'cache'
17
+ enable(d)
18
+ assert d.is_dir()
19
+ calls.clear()
20
+ assert f(2, y=3) == [2, 3]
21
+ assert f(2, y=3) == [2, 3]
22
+ assert f(2) == [2, 1]
23
+ assert len(calls) == 2, calls
24
+ assert len(list(d.glob('*.pickle'))) == 2
25
+ (d / 'keep.txt').write_text('x')
26
+ try:
27
+ reset()
28
+ raise SystemExit('reset should refuse')
29
+ except RuntimeError as e:
30
+ print('reset refusal ok:', e)
31
+ assert (d / 'keep.txt').exists() and len(list(d.glob('*.pickle'))) == 2
32
+ (d / 'keep.txt').unlink()
33
+ reset()
34
+ assert not d.exists()
35
+ f(2, y=3)
36
+ assert len(calls) == 3
37
+ assert len(list(d.glob('*.pickle'))) == 1
38
+ try:
39
+ enable('/proc/nope')
40
+ raise SystemExit('should fail')
41
+ except OSError as e:
42
+ print('enable failure ok:', type(e).__name__)
43
+ print('ok')
test/ttest.py ADDED
@@ -0,0 +1,20 @@
1
+ import time
2
+
3
+ import pickle_debug
4
+
5
+
6
+ @pickle_debug.cached
7
+ def f(x, y=1):
8
+ print(f"call {x} {y}")
9
+ time.sleep(1.5)
10
+ return x + y
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
+