evok 0.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.
- evok/__init__.py +19 -0
- evok/__main__.py +6 -0
- evok/ast_scanner.py +217 -0
- evok/builder.py +177 -0
- evok/cache.py +172 -0
- evok/cli.py +279 -0
- evok/environment.py +87 -0
- evok/importer.py +118 -0
- evok/metadata.py +247 -0
- evok/pep440.py +275 -0
- evok/resolver.py +209 -0
- evok-0.1.0.dist-info/METADATA +110 -0
- evok-0.1.0.dist-info/RECORD +16 -0
- evok-0.1.0.dist-info/WHEEL +4 -0
- evok-0.1.0.dist-info/entry_points.txt +4 -0
- evok-0.1.0.dist-info/licenses/LICENSE +21 -0
evok/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Evok: Pure-Python high-performance package manager and virtualization runtime."""
|
|
2
|
+
|
|
3
|
+
from evok.environment import Environment
|
|
4
|
+
from evok.pep440 import BitPackedVersion, SpecifierSet, VersionSpecifier
|
|
5
|
+
from evok.resolver import InMemoryProvider, Requirement, ResolutionGraph, Resolver
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BitPackedVersion",
|
|
11
|
+
"Environment",
|
|
12
|
+
"InMemoryProvider",
|
|
13
|
+
"Requirement",
|
|
14
|
+
"ResolutionGraph",
|
|
15
|
+
"Resolver",
|
|
16
|
+
"SpecifierSet",
|
|
17
|
+
"VersionSpecifier",
|
|
18
|
+
"__version__",
|
|
19
|
+
]
|
evok/__main__.py
ADDED
evok/ast_scanner.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""AST-driven dependency inference and dead-dependency pruning.
|
|
2
|
+
|
|
3
|
+
Inspects Python AST to discover third-party imports, maps module names to PyPI distributions,
|
|
4
|
+
and compares against pyproject.toml to flag missing or unused dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import tomllib
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# Known mapping of top-level import name to canonical PyPI package name
|
|
17
|
+
KNOWN_IMPORT_MAP: dict[str, str] = {
|
|
18
|
+
"yaml": "pyyaml",
|
|
19
|
+
"cv2": "opencv-python",
|
|
20
|
+
"PIL": "pillow",
|
|
21
|
+
"bs4": "beautifulsoup4",
|
|
22
|
+
"sklearn": "scikit-learn",
|
|
23
|
+
"dateutil": "python-dateutil",
|
|
24
|
+
"dotenv": "python-dotenv",
|
|
25
|
+
"fitz": "pymupdf",
|
|
26
|
+
"magic": "python-magic",
|
|
27
|
+
"jwt": "pyjwt",
|
|
28
|
+
"serial": "pyserial",
|
|
29
|
+
"usb": "pyusb",
|
|
30
|
+
"gi": "pygobject",
|
|
31
|
+
"git": "gitpython",
|
|
32
|
+
"jose": "python-jose",
|
|
33
|
+
"multipart": "python-multipart",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_IGNORED_DIRS = {
|
|
37
|
+
".venv",
|
|
38
|
+
"venv",
|
|
39
|
+
".git",
|
|
40
|
+
"__pycache__",
|
|
41
|
+
".pytest_cache",
|
|
42
|
+
".ruff_cache",
|
|
43
|
+
"build",
|
|
44
|
+
"dist",
|
|
45
|
+
"site-packages",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_stdlib_modules() -> set[str]:
|
|
50
|
+
"""Return set of standard library module names."""
|
|
51
|
+
if hasattr(sys, "stdlib_module_names"):
|
|
52
|
+
return set(sys.stdlib_module_names)
|
|
53
|
+
# Fallback list for older or custom runtimes
|
|
54
|
+
return {
|
|
55
|
+
"os",
|
|
56
|
+
"sys",
|
|
57
|
+
"io",
|
|
58
|
+
"math",
|
|
59
|
+
"json",
|
|
60
|
+
"re",
|
|
61
|
+
"time",
|
|
62
|
+
"datetime",
|
|
63
|
+
"pathlib",
|
|
64
|
+
"typing",
|
|
65
|
+
"ast",
|
|
66
|
+
"unittest",
|
|
67
|
+
"logging",
|
|
68
|
+
"collections",
|
|
69
|
+
"urllib",
|
|
70
|
+
"http",
|
|
71
|
+
"subprocess",
|
|
72
|
+
"threading",
|
|
73
|
+
"multiprocessing",
|
|
74
|
+
"asyncio",
|
|
75
|
+
"contextlib",
|
|
76
|
+
"functools",
|
|
77
|
+
"itertools",
|
|
78
|
+
"shutil",
|
|
79
|
+
"tempfile",
|
|
80
|
+
"glob",
|
|
81
|
+
"hashlib",
|
|
82
|
+
"random",
|
|
83
|
+
"socket",
|
|
84
|
+
"ssl",
|
|
85
|
+
"inspect",
|
|
86
|
+
"importlib",
|
|
87
|
+
"ctypes",
|
|
88
|
+
"struct",
|
|
89
|
+
"copy",
|
|
90
|
+
"enum",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def normalize_package_name(name: str) -> str:
|
|
95
|
+
"""Normalize package name according to PEP 503."""
|
|
96
|
+
return name.lower().replace("_", "-").replace(".", "-")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ImportVisitor(ast.NodeVisitor):
|
|
100
|
+
"""AST visitor collecting imported module names."""
|
|
101
|
+
|
|
102
|
+
def __init__(self) -> None:
|
|
103
|
+
self.imports: set[str] = set()
|
|
104
|
+
|
|
105
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
106
|
+
for alias in node.names:
|
|
107
|
+
root = alias.name.split(".")[0]
|
|
108
|
+
if root:
|
|
109
|
+
self.imports.add(root)
|
|
110
|
+
self.generic_visit(node)
|
|
111
|
+
|
|
112
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
113
|
+
if node.level == 0 and node.module:
|
|
114
|
+
root = node.module.split(".")[0]
|
|
115
|
+
if root:
|
|
116
|
+
self.imports.add(root)
|
|
117
|
+
self.generic_visit(node)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def scan_file_imports(file_path: Path | str) -> set[str]:
|
|
121
|
+
"""Parse a single Python file and return all root imported module names."""
|
|
122
|
+
p = Path(file_path)
|
|
123
|
+
try:
|
|
124
|
+
content = p.read_text(encoding="utf-8", errors="ignore")
|
|
125
|
+
tree = ast.parse(content, filename=str(p))
|
|
126
|
+
except (SyntaxError, UnicodeDecodeError):
|
|
127
|
+
return set()
|
|
128
|
+
|
|
129
|
+
visitor = ImportVisitor()
|
|
130
|
+
visitor.visit(tree)
|
|
131
|
+
return visitor.imports
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class ScanResult:
|
|
136
|
+
"""Results of AST dependency scanning across a codebase."""
|
|
137
|
+
|
|
138
|
+
all_imports: set[str] = field(default_factory=set)
|
|
139
|
+
inferred_packages: set[str] = field(default_factory=set)
|
|
140
|
+
declared_dependencies: set[str] = field(default_factory=set)
|
|
141
|
+
missing_dependencies: set[str] = field(default_factory=set)
|
|
142
|
+
unused_dependencies: set[str] = field(default_factory=set)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def map_module_to_package(module_name: str) -> str:
|
|
146
|
+
"""Map an imported module name to its likely PyPI distribution name."""
|
|
147
|
+
if module_name in KNOWN_IMPORT_MAP:
|
|
148
|
+
return KNOWN_IMPORT_MAP[module_name]
|
|
149
|
+
return normalize_package_name(module_name)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def scan_project(root_dir: Path | str) -> ScanResult:
|
|
153
|
+
"""Recursively scan Python files under root_dir and audit against pyproject.toml."""
|
|
154
|
+
root = Path(root_dir).resolve()
|
|
155
|
+
stdlib = get_stdlib_modules()
|
|
156
|
+
|
|
157
|
+
# Discover local project packages/modules to avoid false positives
|
|
158
|
+
local_modules: set[str] = set()
|
|
159
|
+
for item in root.iterdir():
|
|
160
|
+
if item.name.startswith("."):
|
|
161
|
+
continue
|
|
162
|
+
if item.is_file() and item.suffix == ".py":
|
|
163
|
+
local_modules.add(item.stem)
|
|
164
|
+
elif item.is_dir() and (item / "__init__.py").exists():
|
|
165
|
+
local_modules.add(item.name)
|
|
166
|
+
|
|
167
|
+
# Check for src-layout
|
|
168
|
+
src_dir = root / "src"
|
|
169
|
+
if src_dir.is_dir():
|
|
170
|
+
for item in src_dir.iterdir():
|
|
171
|
+
if item.is_file() and item.suffix == ".py":
|
|
172
|
+
local_modules.add(item.stem)
|
|
173
|
+
elif item.is_dir() and (item / "__init__.py").exists():
|
|
174
|
+
local_modules.add(item.name)
|
|
175
|
+
|
|
176
|
+
all_imports: set[str] = set()
|
|
177
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
178
|
+
dirnames[:] = [d for d in dirnames if d not in _IGNORED_DIRS and not d.startswith(".")]
|
|
179
|
+
for f in filenames:
|
|
180
|
+
if f.endswith(".py"):
|
|
181
|
+
file_p = Path(dirpath) / f
|
|
182
|
+
all_imports.update(scan_file_imports(file_p))
|
|
183
|
+
|
|
184
|
+
# Filter out stdlib and local modules
|
|
185
|
+
third_party_modules = {
|
|
186
|
+
m for m in all_imports if m not in stdlib and m not in local_modules and not m.startswith("_")
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
inferred_packages = {map_module_to_package(m) for m in third_party_modules}
|
|
190
|
+
|
|
191
|
+
# Read declared dependencies from pyproject.toml if present
|
|
192
|
+
declared: set[str] = set()
|
|
193
|
+
pyproject_file = root / "pyproject.toml"
|
|
194
|
+
if pyproject_file.exists():
|
|
195
|
+
try:
|
|
196
|
+
with open(pyproject_file, "rb") as f:
|
|
197
|
+
data = tomllib.load(f)
|
|
198
|
+
deps = data.get("project", {}).get("dependencies", [])
|
|
199
|
+
for dep in deps:
|
|
200
|
+
# Strip version specifiers like '>=1.0.0', '==2.0'
|
|
201
|
+
name = dep.split(";")[0].split(">")[0].split("<")[0].split("=")[0].split("~")[0]
|
|
202
|
+
name = name.strip()
|
|
203
|
+
if name:
|
|
204
|
+
declared.add(normalize_package_name(name))
|
|
205
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
missing = inferred_packages - declared
|
|
209
|
+
unused = declared - inferred_packages
|
|
210
|
+
|
|
211
|
+
return ScanResult(
|
|
212
|
+
all_imports=all_imports,
|
|
213
|
+
inferred_packages=inferred_packages,
|
|
214
|
+
declared_dependencies=declared,
|
|
215
|
+
missing_dependencies=missing,
|
|
216
|
+
unused_dependencies=unused,
|
|
217
|
+
)
|
evok/builder.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Pure-Python standard wheel/sdist builder and standalone zipapp bundler.
|
|
2
|
+
|
|
3
|
+
Implements:
|
|
4
|
+
- PEP 427 standard wheel packaging
|
|
5
|
+
- PEP 625 / PEP 517 standard source distribution (sdist .tar.gz)
|
|
6
|
+
- Single-file zero-binary zipapp (.pyz) executable creation
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import os
|
|
14
|
+
import tarfile
|
|
15
|
+
import tomllib
|
|
16
|
+
import zipapp
|
|
17
|
+
import zipfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _sha256_b64(data: bytes) -> str:
|
|
22
|
+
"""Return URL-safe base64 SHA256 digest without trailing '=' for RECORD."""
|
|
23
|
+
digest = hashlib.sha256(data).digest()
|
|
24
|
+
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_project_metadata(project_dir: Path | str) -> dict[str, str | list[str]]:
|
|
28
|
+
"""Parse pyproject.toml and return normalized project metadata."""
|
|
29
|
+
p_dir = Path(project_dir).resolve()
|
|
30
|
+
pyproject_file = p_dir / "pyproject.toml"
|
|
31
|
+
if not pyproject_file.exists():
|
|
32
|
+
raise FileNotFoundError(f"pyproject.toml not found in {project_dir}")
|
|
33
|
+
|
|
34
|
+
with open(pyproject_file, "rb") as f:
|
|
35
|
+
data = tomllib.load(f)
|
|
36
|
+
|
|
37
|
+
project = data.get("project", {})
|
|
38
|
+
name = project.get("name")
|
|
39
|
+
if not name:
|
|
40
|
+
raise ValueError("Missing 'project.name' in pyproject.toml")
|
|
41
|
+
version = project.get("version", "0.1.0")
|
|
42
|
+
description = project.get("description", "")
|
|
43
|
+
requires_python = project.get("requires-python", ">=3.12")
|
|
44
|
+
dependencies = project.get("dependencies", [])
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
"name": name,
|
|
48
|
+
"version": str(version),
|
|
49
|
+
"description": description,
|
|
50
|
+
"requires_python": requires_python,
|
|
51
|
+
"dependencies": dependencies,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_wheel(project_dir: Path | str, dist_dir: Path | str | None = None) -> Path:
|
|
56
|
+
"""Build a PEP 427 pure-Python wheel (.whl) from project_dir."""
|
|
57
|
+
p_dir = Path(project_dir).resolve()
|
|
58
|
+
meta = load_project_metadata(p_dir)
|
|
59
|
+
|
|
60
|
+
name = str(meta["name"])
|
|
61
|
+
norm_name = name.lower().replace("-", "_")
|
|
62
|
+
version = str(meta["version"])
|
|
63
|
+
dist_path = Path(dist_dir).resolve() if dist_dir else p_dir / "dist"
|
|
64
|
+
dist_path.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
wheel_filename = f"{norm_name}-{version}-py3-none-any.whl"
|
|
67
|
+
wheel_path = dist_path / wheel_filename
|
|
68
|
+
|
|
69
|
+
# Locate package files (src layout or flat)
|
|
70
|
+
source_root = p_dir / "src" if (p_dir / "src").is_dir() else p_dir
|
|
71
|
+
dist_info = f"{norm_name}-{version}.dist-info"
|
|
72
|
+
|
|
73
|
+
# Build METADATA content
|
|
74
|
+
metadata_lines = [
|
|
75
|
+
"Metadata-Version: 2.1",
|
|
76
|
+
f"Name: {name}",
|
|
77
|
+
f"Version: {version}",
|
|
78
|
+
f"Summary: {meta['description']}",
|
|
79
|
+
f"Requires-Python: {meta['requires_python']}",
|
|
80
|
+
]
|
|
81
|
+
for dep in meta["dependencies"]: # type: ignore[union-attr]
|
|
82
|
+
metadata_lines.append(f"Requires-Dist: {dep}")
|
|
83
|
+
metadata_bytes = ("\n".join(metadata_lines) + "\n").encode("utf-8")
|
|
84
|
+
|
|
85
|
+
wheel_bytes = (
|
|
86
|
+
b"Wheel-Version: 1.0\nGenerator: evok (0.1.0)\nRoot-Is-Purelib: true\nTag: py3-none-any\n"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
record_rows: list[str] = []
|
|
90
|
+
|
|
91
|
+
with zipfile.ZipFile(wheel_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
92
|
+
# 1. Package source files
|
|
93
|
+
for root, _, files in os.walk(source_root):
|
|
94
|
+
for file in files:
|
|
95
|
+
if file.endswith(".py"):
|
|
96
|
+
full_file = Path(root) / file
|
|
97
|
+
rel_arc = full_file.relative_to(source_root)
|
|
98
|
+
data = full_file.read_bytes()
|
|
99
|
+
zf.writestr(str(rel_arc), data)
|
|
100
|
+
record_rows.append(f"{rel_arc},sha256={_sha256_b64(data)},{len(data)}")
|
|
101
|
+
|
|
102
|
+
# 2. Add dist-info/METADATA
|
|
103
|
+
meta_arc = f"{dist_info}/METADATA"
|
|
104
|
+
zf.writestr(meta_arc, metadata_bytes)
|
|
105
|
+
record_rows.append(f"{meta_arc},sha256={_sha256_b64(metadata_bytes)},{len(metadata_bytes)}")
|
|
106
|
+
|
|
107
|
+
# 3. Add dist-info/WHEEL
|
|
108
|
+
wheel_arc = f"{dist_info}/WHEEL"
|
|
109
|
+
zf.writestr(wheel_arc, wheel_bytes)
|
|
110
|
+
record_rows.append(f"{wheel_arc},sha256={_sha256_b64(wheel_bytes)},{len(wheel_bytes)}")
|
|
111
|
+
|
|
112
|
+
# 4. Add dist-info/RECORD
|
|
113
|
+
record_arc = f"{dist_info}/RECORD"
|
|
114
|
+
record_rows.append(f"{record_arc},,")
|
|
115
|
+
record_bytes = ("\n".join(record_rows) + "\n").encode("utf-8")
|
|
116
|
+
zf.writestr(record_arc, record_bytes)
|
|
117
|
+
|
|
118
|
+
return wheel_path
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_sdist(project_dir: Path | str, dist_dir: Path | str | None = None) -> Path:
|
|
122
|
+
"""Build a PEP 625 / PEP 517 standard source distribution (.tar.gz)."""
|
|
123
|
+
p_dir = Path(project_dir).resolve()
|
|
124
|
+
meta = load_project_metadata(p_dir)
|
|
125
|
+
|
|
126
|
+
name = str(meta["name"])
|
|
127
|
+
norm_name = name.lower().replace("-", "_")
|
|
128
|
+
version = str(meta["version"])
|
|
129
|
+
dist_path = Path(dist_dir).resolve() if dist_dir else p_dir / "dist"
|
|
130
|
+
dist_path.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
|
|
132
|
+
sdist_filename = f"{norm_name}-{version}.tar.gz"
|
|
133
|
+
sdist_path = dist_path / sdist_filename
|
|
134
|
+
|
|
135
|
+
prefix = f"{norm_name}-{version}"
|
|
136
|
+
|
|
137
|
+
with tarfile.open(sdist_path, "w:gz") as tar:
|
|
138
|
+
# Add pyproject.toml
|
|
139
|
+
pyproject_file = p_dir / "pyproject.toml"
|
|
140
|
+
tar.add(pyproject_file, arcname=f"{prefix}/pyproject.toml")
|
|
141
|
+
|
|
142
|
+
# Add README.md if present
|
|
143
|
+
readme = p_dir / "README.md"
|
|
144
|
+
if readme.exists():
|
|
145
|
+
tar.add(readme, arcname=f"{prefix}/README.md")
|
|
146
|
+
|
|
147
|
+
# Add src/ or python files
|
|
148
|
+
src_dir = p_dir / "src"
|
|
149
|
+
if src_dir.is_dir():
|
|
150
|
+
tar.add(src_dir, arcname=f"{prefix}/src")
|
|
151
|
+
|
|
152
|
+
return sdist_path
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def build_zipapp(
|
|
156
|
+
source_dir: Path | str,
|
|
157
|
+
output_path: Path | str,
|
|
158
|
+
main_entrypoint: str = "evok.cli:main",
|
|
159
|
+
) -> Path:
|
|
160
|
+
"""Package a Python package source tree into an executable standalone .pyz zipapp."""
|
|
161
|
+
src = Path(source_dir).resolve()
|
|
162
|
+
out = Path(output_path).resolve()
|
|
163
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
|
|
165
|
+
has_main = (src / "__main__.py").is_file()
|
|
166
|
+
entry = None if has_main else main_entrypoint
|
|
167
|
+
|
|
168
|
+
zipapp.create_archive(
|
|
169
|
+
src,
|
|
170
|
+
target=out,
|
|
171
|
+
interpreter="/usr/bin/env python3",
|
|
172
|
+
main=entry,
|
|
173
|
+
compressed=True,
|
|
174
|
+
)
|
|
175
|
+
# Ensure executable permission
|
|
176
|
+
out.chmod(0o755)
|
|
177
|
+
return out
|
evok/cache.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Unpacked wheel cache with zero-copy filesystem reflinks and hardlinks.
|
|
2
|
+
|
|
3
|
+
Supports:
|
|
4
|
+
- macOS APFS clonefile via ctypes
|
|
5
|
+
- Linux FICLONE ioctl
|
|
6
|
+
- os.link hardlinks
|
|
7
|
+
- Fast fallback to symlink/shutil.copy
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ctypes
|
|
13
|
+
import fcntl
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
import zipfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
# macOS APFS clonefile setup
|
|
21
|
+
_clonefile = None
|
|
22
|
+
if sys.platform == "darwin":
|
|
23
|
+
try:
|
|
24
|
+
_libc = ctypes.cdll.LoadLibrary(None)
|
|
25
|
+
if hasattr(_libc, "clonefile"):
|
|
26
|
+
_clonefile = _libc.clonefile
|
|
27
|
+
_clonefile.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int]
|
|
28
|
+
_clonefile.restype = ctypes.c_int
|
|
29
|
+
except (OSError, AttributeError):
|
|
30
|
+
_clonefile = None
|
|
31
|
+
|
|
32
|
+
# Linux FICLONE ioctl constant
|
|
33
|
+
FICLONE = 0x40049409
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def default_cache_dir() -> Path:
|
|
37
|
+
"""Return default evok cache directory."""
|
|
38
|
+
custom = os.environ.get("EVOK_CACHE_DIR")
|
|
39
|
+
if custom:
|
|
40
|
+
return Path(custom).expanduser().resolve()
|
|
41
|
+
return Path.home() / ".cache" / "evok"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def reflink_file(src: Path | str, dst: Path | str) -> str:
|
|
45
|
+
"""Perform zero-copy file clone (reflink) or hardlink from src to dst.
|
|
46
|
+
|
|
47
|
+
Returns the strategy used: 'clonefile', 'ficlone', 'hardlink', or 'copy'.
|
|
48
|
+
"""
|
|
49
|
+
src_p = Path(src).resolve()
|
|
50
|
+
dst_p = Path(dst).resolve()
|
|
51
|
+
dst_p.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
if dst_p.exists():
|
|
54
|
+
dst_p.unlink()
|
|
55
|
+
|
|
56
|
+
src_bytes = str(src_p).encode("utf-8")
|
|
57
|
+
dst_bytes = str(dst_p).encode("utf-8")
|
|
58
|
+
|
|
59
|
+
# 1. macOS APFS clonefile
|
|
60
|
+
if _clonefile is not None:
|
|
61
|
+
ret = _clonefile(src_bytes, dst_bytes, 0)
|
|
62
|
+
if ret == 0:
|
|
63
|
+
return "clonefile"
|
|
64
|
+
|
|
65
|
+
# 2. Linux FICLONE ioctl
|
|
66
|
+
if sys.platform.startswith("linux"):
|
|
67
|
+
try:
|
|
68
|
+
with open(src_p, "rb") as f_in, open(dst_p, "wb") as f_out:
|
|
69
|
+
fcntl.ioctl(f_out.fileno(), FICLONE, f_in.fileno())
|
|
70
|
+
return "ficlone"
|
|
71
|
+
except OSError:
|
|
72
|
+
if dst_p.exists():
|
|
73
|
+
dst_p.unlink()
|
|
74
|
+
|
|
75
|
+
# 3. os.link (Hardlink)
|
|
76
|
+
try:
|
|
77
|
+
os.link(src_p, dst_p)
|
|
78
|
+
return "hardlink"
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
# 4. Fallback copy
|
|
83
|
+
shutil.copy2(src_p, dst_p)
|
|
84
|
+
return "copy"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def materialize_tree(
|
|
88
|
+
src_dir: Path | str, dst_dir: Path | str, strategy: str = "auto"
|
|
89
|
+
) -> dict[str, int]:
|
|
90
|
+
"""Materialize files from src_dir into dst_dir using zero-copy links.
|
|
91
|
+
|
|
92
|
+
Returns a count of files linked by strategy.
|
|
93
|
+
"""
|
|
94
|
+
src_root = Path(src_dir).resolve()
|
|
95
|
+
dst_root = Path(dst_dir).resolve()
|
|
96
|
+
counts: dict[str, int] = {}
|
|
97
|
+
|
|
98
|
+
for dirpath, _, filenames in os.walk(src_root):
|
|
99
|
+
rel_path = Path(dirpath).relative_to(src_root)
|
|
100
|
+
target_dir = dst_root / rel_path
|
|
101
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
|
|
103
|
+
for filename in filenames:
|
|
104
|
+
src_file = Path(dirpath) / filename
|
|
105
|
+
dst_file = target_dir / filename
|
|
106
|
+
if strategy == "hardlink":
|
|
107
|
+
try:
|
|
108
|
+
if dst_file.exists():
|
|
109
|
+
dst_file.unlink()
|
|
110
|
+
os.link(src_file, dst_file)
|
|
111
|
+
st = "hardlink"
|
|
112
|
+
except OSError:
|
|
113
|
+
shutil.copy2(src_file, dst_file)
|
|
114
|
+
st = "copy"
|
|
115
|
+
elif strategy == "copy":
|
|
116
|
+
shutil.copy2(src_file, dst_file)
|
|
117
|
+
st = "copy"
|
|
118
|
+
else:
|
|
119
|
+
st = reflink_file(src_file, dst_file)
|
|
120
|
+
counts[st] = counts.get(st, 0) + 1
|
|
121
|
+
|
|
122
|
+
return counts
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class WheelCache:
|
|
126
|
+
"""Global unpacked wheel cache."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, cache_dir: Path | str | None = None) -> None:
|
|
129
|
+
self.root = Path(cache_dir).expanduser().resolve() if cache_dir else default_cache_dir()
|
|
130
|
+
self.wheels_dir = self.root / "wheels"
|
|
131
|
+
self.wheels_dir.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
|
|
133
|
+
def get_wheel_dir(self, name: str, version: str) -> Path:
|
|
134
|
+
"""Return the directory path for an unpacked wheel."""
|
|
135
|
+
safe_name = name.lower().replace("-", "_")
|
|
136
|
+
return self.wheels_dir / safe_name / version
|
|
137
|
+
|
|
138
|
+
def is_cached(self, name: str, version: str) -> bool:
|
|
139
|
+
"""Check if an unpacked wheel already exists."""
|
|
140
|
+
w_dir = self.get_wheel_dir(name, version)
|
|
141
|
+
return w_dir.is_dir() and (w_dir / ".evok_complete").exists()
|
|
142
|
+
|
|
143
|
+
def unpack_wheel(self, wheel_path: Path | str) -> Path:
|
|
144
|
+
"""Unpack a .whl file into the global cache."""
|
|
145
|
+
wheel_p = Path(wheel_path).resolve()
|
|
146
|
+
filename = wheel_p.name
|
|
147
|
+
# Wheel filename format: {distribution}-{version}(-{build_tag})?-{python_tag}-{abi_tag}-{platform_tag}.whl
|
|
148
|
+
parts = filename[:-4].split("-")
|
|
149
|
+
if len(parts) < 2:
|
|
150
|
+
raise ValueError(f"Invalid wheel filename format: {filename}")
|
|
151
|
+
|
|
152
|
+
name, version = parts[0], parts[1]
|
|
153
|
+
target_dir = self.get_wheel_dir(name, version)
|
|
154
|
+
if self.is_cached(name, version):
|
|
155
|
+
return target_dir
|
|
156
|
+
|
|
157
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
with zipfile.ZipFile(wheel_p, "r") as zf:
|
|
159
|
+
zf.extractall(target_dir)
|
|
160
|
+
|
|
161
|
+
# Mark complete
|
|
162
|
+
(target_dir / ".evok_complete").write_text("ok\n", encoding="utf-8")
|
|
163
|
+
return target_dir
|
|
164
|
+
|
|
165
|
+
def install_to(
|
|
166
|
+
self, name: str, version: str, target_dir: Path | str, strategy: str = "auto"
|
|
167
|
+
) -> dict[str, int]:
|
|
168
|
+
"""Materialize an unpacked wheel into target_dir (e.g. site-packages)."""
|
|
169
|
+
src = self.get_wheel_dir(name, version)
|
|
170
|
+
if not src.is_dir():
|
|
171
|
+
raise FileNotFoundError(f"Wheel {name}=={version} not found in cache")
|
|
172
|
+
return materialize_tree(src, target_dir, strategy=strategy)
|