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 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
@@ -0,0 +1,6 @@
1
+ from evok.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ import sys
5
+
6
+ sys.exit(main())
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)