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.
@@ -0,0 +1,2 @@
1
+ .playwright-mcp
2
+
@@ -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,4 @@
1
+ from cache_skip.decorator import cache_skip
2
+ from cache_skip.dirmaker import Dirmaker
3
+
4
+ __all__ = ["cache_skip", "Dirmaker"]
@@ -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