pycache-skip 0.1.0__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.
- pycache_skip-0.1.0/.gitignore +2 -0
- pycache_skip-0.1.0/LICENSE +21 -0
- pycache_skip-0.1.0/PKG-INFO +130 -0
- pycache_skip-0.1.0/README.md +115 -0
- pycache_skip-0.1.0/idea.md +38 -0
- pycache_skip-0.1.0/pyproject.toml +32 -0
- pycache_skip-0.1.0/src/cache_skip/__init__.py +4 -0
- pycache_skip-0.1.0/src/cache_skip/decorator.py +186 -0
- pycache_skip-0.1.0/src/cache_skip/deps.py +156 -0
- pycache_skip-0.1.0/src/cache_skip/dirmaker.py +32 -0
- pycache_skip-0.1.0/src/cache_skip/scanner.py +38 -0
- pycache_skip-0.1.0/src/cache_skip/state.py +36 -0
- pycache_skip-0.1.0/src/cache_skip/tests/__init__.py +0 -0
- pycache_skip-0.1.0/src/cache_skip/tests/test_decorator.py +228 -0
- pycache_skip-0.1.0/src/cache_skip/tests/test_deps.py +75 -0
- pycache_skip-0.1.0/src/cache_skip/tests/test_dirmaker.py +34 -0
- pycache_skip-0.1.0/src/cache_skip/tests/test_scanner.py +57 -0
- pycache_skip-0.1.0/src/cache_skip/tests/test_state.py +39 -0
- pycache_skip-0.1.0/upload_pypi.sh +18 -0
- pycache_skip-0.1.0/uv.lock +704 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cache_skip contributors
|
|
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,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pycache_skip
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Skip pipeline steps when inputs are unchanged — content-aware, with module dependency tracking
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Requires-Dist: loguru
|
|
13
|
+
Requires-Dist: xxhash
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# pycache_skip
|
|
17
|
+
|
|
18
|
+
Skip pipeline steps when their inputs have not changed.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uv add pycache_skip
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## What it does
|
|
25
|
+
|
|
26
|
+
`cache_skip` wraps a pipeline step function and skips re-execution when all
|
|
27
|
+
inputs are unchanged. It stores a compact state file (`.input_state.json`)
|
|
28
|
+
alongside each output directory. On subsequent calls it compares the current
|
|
29
|
+
inputs against the stored state and only reruns the function when something
|
|
30
|
+
actually changed.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
### Basic example (single input directory)
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from cache_skip import cache_skip, Dirmaker
|
|
39
|
+
|
|
40
|
+
dm = Dirmaker(Path("/data/pipeline/run-001"))
|
|
41
|
+
|
|
42
|
+
@cache_skip
|
|
43
|
+
def step_transform(raw: Path, *, _output: Path) -> Path:
|
|
44
|
+
# heavy transformation ...
|
|
45
|
+
return _output
|
|
46
|
+
|
|
47
|
+
# First call — runs the function and records input state.
|
|
48
|
+
step_transform(Path("/data/raw"), _output=dm.path_for("transform"))
|
|
49
|
+
|
|
50
|
+
# Second call — skips the function, returns the output path immediately.
|
|
51
|
+
step_transform(Path("/data/raw"), _output=dm.path_for("transform"))
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Example with non-Path args
|
|
55
|
+
|
|
56
|
+
Non-`Path` arguments (dates, strings, ints, etc.) are also part of the cache
|
|
57
|
+
key. Changing them triggers a rerun.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import datetime as dt
|
|
61
|
+
|
|
62
|
+
@cache_skip(track_dependencies=False)
|
|
63
|
+
def step_build_config(
|
|
64
|
+
schedule_date: dt.date,
|
|
65
|
+
template: Path,
|
|
66
|
+
*,
|
|
67
|
+
_output: Path,
|
|
68
|
+
) -> Path:
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
# Changing schedule_date from 2025-01-01 to 2025-01-02 invalidates the cache.
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Dirmaker companion
|
|
75
|
+
|
|
76
|
+
`Dirmaker` allocates named output directories under a staging root. Use
|
|
77
|
+
`path_for(name)` to resolve the path without side effects (for `@cache_skip`),
|
|
78
|
+
or `new_output_dir(name)` to delete and recreate explicitly.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
dm = Dirmaker(Path("/data/pipeline/run-001"))
|
|
82
|
+
|
|
83
|
+
# Pass path to decorator — decorator manages deletion on rerun.
|
|
84
|
+
step_transform(raw, _output=dm.path_for("transform"))
|
|
85
|
+
|
|
86
|
+
# Or manage the directory yourself:
|
|
87
|
+
out = dm.new_output_dir("transform") # deletes existing, creates fresh
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## How invalidation works
|
|
91
|
+
|
|
92
|
+
Three-tier change detection on every call after the first:
|
|
93
|
+
|
|
94
|
+
1. **Args hash** — all non-`Path`, non-`_output` arguments are hashed via
|
|
95
|
+
`repr()`. A change in any scalar argument (date, string, int, …) triggers
|
|
96
|
+
a rerun immediately.
|
|
97
|
+
|
|
98
|
+
2. **Dependency hash** — the source files of the decorated function and all
|
|
99
|
+
modules it imports (static AST analysis) are hashed. Editing the function's
|
|
100
|
+
source code triggers a rerun. Disable with `track_dependencies=False`.
|
|
101
|
+
|
|
102
|
+
3. **File content hash** — every file under each input `Path` is compared.
|
|
103
|
+
Metadata (mtime, inode, size) is checked first as a fast path. If metadata
|
|
104
|
+
is identical the stored hash is trusted. If metadata drifted but content
|
|
105
|
+
hash matches, the state file is updated silently without a rerun (handles
|
|
106
|
+
`rsync` / `cp -p` copies with timestamp noise).
|
|
107
|
+
|
|
108
|
+
## track_dependencies
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
@cache_skip(track_dependencies=False)
|
|
112
|
+
def step(...):
|
|
113
|
+
...
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Set `track_dependencies=False` to skip module source hashing. Useful when the
|
|
117
|
+
function imports large, rarely-changing libraries and startup cost matters, or
|
|
118
|
+
in tests.
|
|
119
|
+
|
|
120
|
+
## Comparison with auto_skip
|
|
121
|
+
|
|
122
|
+
`cache_skip` is a simpler, self-contained alternative to `auto_skip`:
|
|
123
|
+
|
|
124
|
+
| Feature | `cache_skip` | `auto_skip` |
|
|
125
|
+
| ------------------- | ---------------------------- | -------------------- |
|
|
126
|
+
| Input detection | explicit `Path` args | strace / audit hooks |
|
|
127
|
+
| Non-Path args | hashed | ignored |
|
|
128
|
+
| Module dep tracking | static AST | runtime import list |
|
|
129
|
+
| External deps | `xxhash`, `loguru` | heavier stack |
|
|
130
|
+
| Output format | dir with `.input_state.json` | opaque cache store |
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# pycache_skip
|
|
2
|
+
|
|
3
|
+
Skip pipeline steps when their inputs have not changed.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
uv add pycache_skip
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
`cache_skip` wraps a pipeline step function and skips re-execution when all
|
|
12
|
+
inputs are unchanged. It stores a compact state file (`.input_state.json`)
|
|
13
|
+
alongside each output directory. On subsequent calls it compares the current
|
|
14
|
+
inputs against the stored state and only reruns the function when something
|
|
15
|
+
actually changed.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
### Basic example (single input directory)
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from cache_skip import cache_skip, Dirmaker
|
|
24
|
+
|
|
25
|
+
dm = Dirmaker(Path("/data/pipeline/run-001"))
|
|
26
|
+
|
|
27
|
+
@cache_skip
|
|
28
|
+
def step_transform(raw: Path, *, _output: Path) -> Path:
|
|
29
|
+
# heavy transformation ...
|
|
30
|
+
return _output
|
|
31
|
+
|
|
32
|
+
# First call — runs the function and records input state.
|
|
33
|
+
step_transform(Path("/data/raw"), _output=dm.path_for("transform"))
|
|
34
|
+
|
|
35
|
+
# Second call — skips the function, returns the output path immediately.
|
|
36
|
+
step_transform(Path("/data/raw"), _output=dm.path_for("transform"))
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Example with non-Path args
|
|
40
|
+
|
|
41
|
+
Non-`Path` arguments (dates, strings, ints, etc.) are also part of the cache
|
|
42
|
+
key. Changing them triggers a rerun.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import datetime as dt
|
|
46
|
+
|
|
47
|
+
@cache_skip(track_dependencies=False)
|
|
48
|
+
def step_build_config(
|
|
49
|
+
schedule_date: dt.date,
|
|
50
|
+
template: Path,
|
|
51
|
+
*,
|
|
52
|
+
_output: Path,
|
|
53
|
+
) -> Path:
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
# Changing schedule_date from 2025-01-01 to 2025-01-02 invalidates the cache.
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Dirmaker companion
|
|
60
|
+
|
|
61
|
+
`Dirmaker` allocates named output directories under a staging root. Use
|
|
62
|
+
`path_for(name)` to resolve the path without side effects (for `@cache_skip`),
|
|
63
|
+
or `new_output_dir(name)` to delete and recreate explicitly.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
dm = Dirmaker(Path("/data/pipeline/run-001"))
|
|
67
|
+
|
|
68
|
+
# Pass path to decorator — decorator manages deletion on rerun.
|
|
69
|
+
step_transform(raw, _output=dm.path_for("transform"))
|
|
70
|
+
|
|
71
|
+
# Or manage the directory yourself:
|
|
72
|
+
out = dm.new_output_dir("transform") # deletes existing, creates fresh
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## How invalidation works
|
|
76
|
+
|
|
77
|
+
Three-tier change detection on every call after the first:
|
|
78
|
+
|
|
79
|
+
1. **Args hash** — all non-`Path`, non-`_output` arguments are hashed via
|
|
80
|
+
`repr()`. A change in any scalar argument (date, string, int, …) triggers
|
|
81
|
+
a rerun immediately.
|
|
82
|
+
|
|
83
|
+
2. **Dependency hash** — the source files of the decorated function and all
|
|
84
|
+
modules it imports (static AST analysis) are hashed. Editing the function's
|
|
85
|
+
source code triggers a rerun. Disable with `track_dependencies=False`.
|
|
86
|
+
|
|
87
|
+
3. **File content hash** — every file under each input `Path` is compared.
|
|
88
|
+
Metadata (mtime, inode, size) is checked first as a fast path. If metadata
|
|
89
|
+
is identical the stored hash is trusted. If metadata drifted but content
|
|
90
|
+
hash matches, the state file is updated silently without a rerun (handles
|
|
91
|
+
`rsync` / `cp -p` copies with timestamp noise).
|
|
92
|
+
|
|
93
|
+
## track_dependencies
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
@cache_skip(track_dependencies=False)
|
|
97
|
+
def step(...):
|
|
98
|
+
...
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Set `track_dependencies=False` to skip module source hashing. Useful when the
|
|
102
|
+
function imports large, rarely-changing libraries and startup cost matters, or
|
|
103
|
+
in tests.
|
|
104
|
+
|
|
105
|
+
## Comparison with auto_skip
|
|
106
|
+
|
|
107
|
+
`cache_skip` is a simpler, self-contained alternative to `auto_skip`:
|
|
108
|
+
|
|
109
|
+
| Feature | `cache_skip` | `auto_skip` |
|
|
110
|
+
| ------------------- | ---------------------------- | -------------------- |
|
|
111
|
+
| Input detection | explicit `Path` args | strace / audit hooks |
|
|
112
|
+
| Non-Path args | hashed | ignored |
|
|
113
|
+
| Module dep tracking | static AST | runtime import list |
|
|
114
|
+
| External deps | `xxhash`, `loguru` | heavier stack |
|
|
115
|
+
| Output format | dir with `.input_state.json` | opaque cache store |
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from casher use concept of:
|
|
2
|
+
|
|
3
|
+
in-process module dependency tracking to compute implicit dependent files
|
|
4
|
+
|
|
5
|
+
for all input dirs track each file and dir recursively:
|
|
6
|
+
record in a file:
|
|
7
|
+
|
|
8
|
+
- path
|
|
9
|
+
- last-modified
|
|
10
|
+
- node-id?
|
|
11
|
+
- size
|
|
12
|
+
- hash
|
|
13
|
+
|
|
14
|
+
if last-modified size or node-id changed: compute hash and compare this:
|
|
15
|
+
|
|
16
|
+
if identical last-modified and size. assume not changed
|
|
17
|
+
if modifed compute hash. if has same, update entry, not chnages
|
|
18
|
+
|
|
19
|
+
the file/dirlist lives in otput dir under .input.txt
|
|
20
|
+
|
|
21
|
+
if output exists:
|
|
22
|
+
read .input.txt and compare with current state of input dirs. if any changes, mrun function and created update .input.txt with new state. if no changes, skip run and reuse output
|
|
23
|
+
update .input.txt if last-modified or size changed, but hash changed to reflect current state, in this case no recompution required as same input
|
|
24
|
+
|
|
25
|
+
important the files and hashes include all module dependencies catured in dependency tracker! any change there, even if not in input dirs, should trigger recomputation as it may change the behavior of the function
|
|
26
|
+
|
|
27
|
+
decorator: @cache_skip
|
|
28
|
+
|
|
29
|
+
@cache_skip(track_dependencies=True) # default is true
|
|
30
|
+
def pipeline_function(input_dir1: Path, input_dir2: Path, \_output: Path): # function body
|
|
31
|
+
do lots of computation and write to \_output to \_output dir
|
|
32
|
+
return \_output
|
|
33
|
+
|
|
34
|
+
see file in /home/ralf/sync/synced_develop/lsy/hd-demo-designer/src/pipeline
|
|
35
|
+
it will replace the auto_skip decorator there
|
|
36
|
+
|
|
37
|
+
- module like casher will be uploaded to pypi, too same license etc.
|
|
38
|
+
- proper README.md with usage instructions and examples.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pycache_skip"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Skip pipeline steps when inputs are unchanged — content-aware, with module dependency tracking"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Operating System :: POSIX :: Linux",
|
|
17
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
18
|
+
]
|
|
19
|
+
dependencies = ["loguru", "xxhash"]
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/cache_skip"]
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build]
|
|
25
|
+
exclude = ["test.sh", "coverage.sh"]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
testpaths = ["src/cache_skip/tests"]
|
|
29
|
+
timeout = 10
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = ["pytest>=9.0.3", "pytest-timeout>=2.4.0", "build>=1.5.0", "twine>=6.2.0"]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import shutil
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import xxhash
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from . import deps as _deps
|
|
11
|
+
from .scanner import scan_inputs
|
|
12
|
+
from .state import FileRecord, InputState, read_state, write_state
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _collect_input_paths(bound: inspect.BoundArguments) -> list[Path]:
|
|
16
|
+
paths = []
|
|
17
|
+
for name, value in bound.arguments.items():
|
|
18
|
+
if name == "_output":
|
|
19
|
+
continue
|
|
20
|
+
if isinstance(value, Path):
|
|
21
|
+
paths.append(value)
|
|
22
|
+
return paths
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _compute_args_hash(bound: inspect.BoundArguments) -> str:
|
|
26
|
+
non_path_args: dict[str, str] = {}
|
|
27
|
+
for name, value in bound.arguments.items():
|
|
28
|
+
if name == "_output":
|
|
29
|
+
continue
|
|
30
|
+
if isinstance(value, Path):
|
|
31
|
+
continue
|
|
32
|
+
non_path_args[name] = repr(value)
|
|
33
|
+
combined = "|".join(f"{k}={v}" for k, v in sorted(non_path_args.items()))
|
|
34
|
+
return xxhash.xxh128(combined.encode()).hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _compute_file_hash(path: Path) -> str:
|
|
38
|
+
return xxhash.xxh128(path.read_bytes()).hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _do_rerun(
|
|
42
|
+
fn: Callable,
|
|
43
|
+
args: tuple,
|
|
44
|
+
kwargs: dict,
|
|
45
|
+
input_paths: list[Path],
|
|
46
|
+
args_hash: str,
|
|
47
|
+
dep_hash: str,
|
|
48
|
+
output: Path,
|
|
49
|
+
) -> object:
|
|
50
|
+
shutil.rmtree(output, ignore_errors=True)
|
|
51
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
result = fn(*args, **kwargs)
|
|
53
|
+
state = scan_inputs(input_paths)
|
|
54
|
+
state.args_hash = args_hash
|
|
55
|
+
state.dep_hash = dep_hash
|
|
56
|
+
write_state(state, output / ".input_state.json")
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cache_skip(
|
|
61
|
+
_fn: Callable | None = None,
|
|
62
|
+
*,
|
|
63
|
+
track_dependencies: bool = True,
|
|
64
|
+
) -> Callable:
|
|
65
|
+
def decorator(fn: Callable) -> Callable:
|
|
66
|
+
@wraps(fn)
|
|
67
|
+
def wrapper(*args, **kwargs):
|
|
68
|
+
# Step 0 — extract _output
|
|
69
|
+
output = kwargs.get("_output")
|
|
70
|
+
assert (
|
|
71
|
+
output is not None
|
|
72
|
+
), f"{fn.__qualname__}() missing required keyword argument: '_output'"
|
|
73
|
+
output = Path(output)
|
|
74
|
+
|
|
75
|
+
# Step 1 — collect input Paths
|
|
76
|
+
sig = inspect.signature(fn)
|
|
77
|
+
bound = sig.bind(*args, **kwargs)
|
|
78
|
+
bound.apply_defaults()
|
|
79
|
+
input_paths = _collect_input_paths(bound)
|
|
80
|
+
|
|
81
|
+
# Step 2 — compute args_hash
|
|
82
|
+
args_hash = _compute_args_hash(bound)
|
|
83
|
+
|
|
84
|
+
# Step 3 — compute dep_hash
|
|
85
|
+
if track_dependencies:
|
|
86
|
+
dep_hash = _deps.compute_dep_hash(fn)
|
|
87
|
+
else:
|
|
88
|
+
dep_hash = ""
|
|
89
|
+
|
|
90
|
+
# Step 4 — output absent → fresh run
|
|
91
|
+
if not output.exists():
|
|
92
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
result = fn(*args, **kwargs)
|
|
94
|
+
state = scan_inputs(input_paths)
|
|
95
|
+
state.args_hash = args_hash
|
|
96
|
+
state.dep_hash = dep_hash
|
|
97
|
+
write_state(state, output / ".input_state.json")
|
|
98
|
+
logger.info(
|
|
99
|
+
"cache_skip: ran {} — output did not exist", fn.__qualname__
|
|
100
|
+
)
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
# Step 5 — output present → compare state
|
|
104
|
+
state_path = output / ".input_state.json"
|
|
105
|
+
if not state_path.exists():
|
|
106
|
+
logger.info("cache_skip: rerunning {} — no state file", fn.__qualname__)
|
|
107
|
+
return _do_rerun(
|
|
108
|
+
fn, args, kwargs, input_paths, args_hash, dep_hash, output
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
stored = read_state(state_path)
|
|
112
|
+
|
|
113
|
+
if stored.args_hash != args_hash:
|
|
114
|
+
logger.info("cache_skip: rerunning {} — args changed", fn.__qualname__)
|
|
115
|
+
return _do_rerun(
|
|
116
|
+
fn, args, kwargs, input_paths, args_hash, dep_hash, output
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if stored.dep_hash != dep_hash:
|
|
120
|
+
logger.info(
|
|
121
|
+
"cache_skip: rerunning {} — dep_hash changed", fn.__qualname__
|
|
122
|
+
)
|
|
123
|
+
return _do_rerun(
|
|
124
|
+
fn, args, kwargs, input_paths, args_hash, dep_hash, output
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
current = scan_inputs(input_paths)
|
|
128
|
+
|
|
129
|
+
if set(current.files) != set(stored.files):
|
|
130
|
+
logger.info(
|
|
131
|
+
"cache_skip: rerunning {} — file set changed", fn.__qualname__
|
|
132
|
+
)
|
|
133
|
+
return _do_rerun(
|
|
134
|
+
fn, args, kwargs, input_paths, args_hash, dep_hash, output
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
changed = False
|
|
138
|
+
updated_files: dict[str, FileRecord] = {}
|
|
139
|
+
|
|
140
|
+
for path_str, current_rec in current.files.items():
|
|
141
|
+
stored_rec = stored.files.get(path_str)
|
|
142
|
+
if stored_rec is None:
|
|
143
|
+
changed = True
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
if (
|
|
147
|
+
current_rec.mtime == stored_rec.mtime
|
|
148
|
+
and current_rec.size == stored_rec.size
|
|
149
|
+
and current_rec.inode == stored_rec.inode
|
|
150
|
+
):
|
|
151
|
+
updated_files[path_str] = stored_rec
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
actual_hash = _compute_file_hash(Path(path_str))
|
|
155
|
+
if actual_hash == stored_rec.hash:
|
|
156
|
+
updated_files[path_str] = FileRecord(
|
|
157
|
+
path=path_str,
|
|
158
|
+
mtime=current_rec.mtime,
|
|
159
|
+
inode=current_rec.inode,
|
|
160
|
+
size=current_rec.size,
|
|
161
|
+
hash=actual_hash,
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
changed = True
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
if changed:
|
|
168
|
+
logger.info(
|
|
169
|
+
"cache_skip: rerunning {} — file content changed", fn.__qualname__
|
|
170
|
+
)
|
|
171
|
+
return _do_rerun(
|
|
172
|
+
fn, args, kwargs, input_paths, args_hash, dep_hash, output
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if updated_files != stored.files:
|
|
176
|
+
stored.files = updated_files
|
|
177
|
+
write_state(stored, state_path)
|
|
178
|
+
|
|
179
|
+
logger.info("cache_skip: skipping {} — inputs unchanged", fn.__qualname__)
|
|
180
|
+
return output
|
|
181
|
+
|
|
182
|
+
return wrapper
|
|
183
|
+
|
|
184
|
+
if _fn is not None:
|
|
185
|
+
return decorator(_fn)
|
|
186
|
+
return decorator
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import inspect
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import xxhash
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compute_dep_hash(
|
|
10
|
+
func: Callable,
|
|
11
|
+
dep_roots: list[Path] | None = None,
|
|
12
|
+
dep_files: list[Path] | None = None,
|
|
13
|
+
) -> str:
|
|
14
|
+
if dep_files is not None:
|
|
15
|
+
return _hash_files(dep_files)
|
|
16
|
+
|
|
17
|
+
mod = inspect.getmodule(func)
|
|
18
|
+
assert mod is not None, f"Cannot determine module for {func}"
|
|
19
|
+
assert (
|
|
20
|
+
hasattr(mod, "__file__") and mod.__file__ is not None
|
|
21
|
+
), f"Module {mod.__name__} has no __file__"
|
|
22
|
+
|
|
23
|
+
if dep_roots is None:
|
|
24
|
+
dep_roots = _auto_detect_roots(mod)
|
|
25
|
+
|
|
26
|
+
dep_roots_resolved = [Path(r).resolve() for r in dep_roots]
|
|
27
|
+
start_file = Path(mod.__file__).resolve()
|
|
28
|
+
|
|
29
|
+
visited: set[Path] = set()
|
|
30
|
+
_walk_imports(start_file, dep_roots_resolved, visited, is_entry=True)
|
|
31
|
+
|
|
32
|
+
return _hash_files(list(visited))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _auto_detect_roots(mod) -> list[Path]:
|
|
36
|
+
mod_file = Path(mod.__file__).resolve()
|
|
37
|
+
parts = mod.__name__.split(".")
|
|
38
|
+
pkg_dir = mod_file.parent
|
|
39
|
+
for _ in range(len(parts) - 1):
|
|
40
|
+
pkg_dir = pkg_dir.parent
|
|
41
|
+
return [pkg_dir]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _hash_files(paths: list[Path]) -> str:
|
|
45
|
+
file_hashes: list[tuple[str, str]] = []
|
|
46
|
+
for src_path in sorted(paths, key=lambda p: str(p.resolve())):
|
|
47
|
+
resolved = src_path.resolve()
|
|
48
|
+
content_hash = xxhash.xxh128(resolved.read_bytes()).hexdigest()
|
|
49
|
+
file_hashes.append((str(resolved), content_hash))
|
|
50
|
+
combined = "\n".join(f"{p}:{h}" for p, h in file_hashes)
|
|
51
|
+
return xxhash.xxh128(combined.encode()).hexdigest()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _walk_imports(
|
|
55
|
+
source_file: Path,
|
|
56
|
+
dep_roots: list[Path],
|
|
57
|
+
visited: set[Path],
|
|
58
|
+
*,
|
|
59
|
+
is_entry: bool = False,
|
|
60
|
+
) -> None:
|
|
61
|
+
source_file = source_file.resolve()
|
|
62
|
+
if source_file in visited:
|
|
63
|
+
return
|
|
64
|
+
if not source_file.is_file() or source_file.suffix != ".py":
|
|
65
|
+
return
|
|
66
|
+
if not is_entry and not any(_is_under(source_file, root) for root in dep_roots):
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
visited.add(source_file)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
tree = ast.parse(source_file.read_bytes(), filename=str(source_file))
|
|
73
|
+
except SyntaxError:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
for node in ast.walk(tree):
|
|
77
|
+
if isinstance(node, ast.Import):
|
|
78
|
+
for alias in node.names:
|
|
79
|
+
resolved = _resolve_module(alias.name, source_file, dep_roots)
|
|
80
|
+
if resolved:
|
|
81
|
+
_walk_imports(resolved, dep_roots, visited)
|
|
82
|
+
elif isinstance(node, ast.ImportFrom):
|
|
83
|
+
if node.module is None:
|
|
84
|
+
continue
|
|
85
|
+
module_name = node.module
|
|
86
|
+
if node.level > 0:
|
|
87
|
+
module_name = _resolve_relative(
|
|
88
|
+
module_name, node.level, source_file, dep_roots
|
|
89
|
+
)
|
|
90
|
+
if module_name is None:
|
|
91
|
+
continue
|
|
92
|
+
resolved = _resolve_module(module_name, source_file, dep_roots)
|
|
93
|
+
if resolved:
|
|
94
|
+
_walk_imports(resolved, dep_roots, visited)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _resolve_module(
|
|
98
|
+
module_name: str,
|
|
99
|
+
from_file: Path,
|
|
100
|
+
dep_roots: list[Path],
|
|
101
|
+
) -> Path | None:
|
|
102
|
+
parts = module_name.split(".")
|
|
103
|
+
for root in dep_roots:
|
|
104
|
+
pkg_path = root / "/".join(parts) / "__init__.py"
|
|
105
|
+
if pkg_path.is_file():
|
|
106
|
+
return pkg_path.resolve()
|
|
107
|
+
mod_path = (
|
|
108
|
+
root / "/".join(parts[:-1]) / (parts[-1] + ".py")
|
|
109
|
+
if len(parts) > 1
|
|
110
|
+
else root / (parts[0] + ".py")
|
|
111
|
+
)
|
|
112
|
+
if mod_path.is_file():
|
|
113
|
+
return mod_path.resolve()
|
|
114
|
+
for i in range(len(parts), 0, -1):
|
|
115
|
+
sub = parts[:i]
|
|
116
|
+
pkg_init = root / "/".join(sub) / "__init__.py"
|
|
117
|
+
if pkg_init.is_file():
|
|
118
|
+
return pkg_init.resolve()
|
|
119
|
+
mod_file = (
|
|
120
|
+
root / "/".join(sub[:-1]) / (sub[-1] + ".py")
|
|
121
|
+
if len(sub) > 1
|
|
122
|
+
else root / (sub[0] + ".py")
|
|
123
|
+
)
|
|
124
|
+
if mod_file.is_file():
|
|
125
|
+
return mod_file.resolve()
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _resolve_relative(
|
|
130
|
+
module_name: str,
|
|
131
|
+
level: int,
|
|
132
|
+
from_file: Path,
|
|
133
|
+
dep_roots: list[Path],
|
|
134
|
+
) -> str | None:
|
|
135
|
+
pkg_dir = from_file.parent
|
|
136
|
+
for _ in range(level - 1):
|
|
137
|
+
pkg_dir = pkg_dir.parent
|
|
138
|
+
for root in dep_roots:
|
|
139
|
+
if _is_under(pkg_dir, root):
|
|
140
|
+
try:
|
|
141
|
+
rel = pkg_dir.relative_to(root)
|
|
142
|
+
prefix = ".".join(rel.parts)
|
|
143
|
+
if prefix and module_name:
|
|
144
|
+
return f"{prefix}.{module_name}"
|
|
145
|
+
return prefix or module_name
|
|
146
|
+
except ValueError:
|
|
147
|
+
continue
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _is_under(path: Path, root: Path) -> bool:
|
|
152
|
+
try:
|
|
153
|
+
path.relative_to(root)
|
|
154
|
+
return True
|
|
155
|
+
except ValueError:
|
|
156
|
+
return False
|