evok 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.
- evok-0.1.0/LICENSE +21 -0
- evok-0.1.0/PKG-INFO +110 -0
- evok-0.1.0/README.md +99 -0
- evok-0.1.0/pyproject.toml +36 -0
- evok-0.1.0/pyproject.toml.orig +35 -0
- evok-0.1.0/src/evok/__init__.py +19 -0
- evok-0.1.0/src/evok/__main__.py +6 -0
- evok-0.1.0/src/evok/ast_scanner.py +217 -0
- evok-0.1.0/src/evok/builder.py +177 -0
- evok-0.1.0/src/evok/cache.py +172 -0
- evok-0.1.0/src/evok/cli.py +279 -0
- evok-0.1.0/src/evok/environment.py +87 -0
- evok-0.1.0/src/evok/importer.py +118 -0
- evok-0.1.0/src/evok/metadata.py +247 -0
- evok-0.1.0/src/evok/pep440.py +275 -0
- evok-0.1.0/src/evok/resolver.py +209 -0
evok-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ella Inng
|
|
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.
|
evok-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: evok
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pure-Python high-performance packaging engine and virtualization runtime
|
|
5
|
+
Author: ellainng
|
|
6
|
+
Author-email: ellainng <299958882+ellainng@users.noreply.github.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Evok (`ev`)
|
|
13
|
+
|
|
14
|
+
[](https://github.com/wispsy/evok/actions/workflows/ci.yml)
|
|
15
|
+
[](https://python.org)
|
|
16
|
+
[](LICENSE)
|
|
17
|
+
|
|
18
|
+
> **Pure-Python high-performance package manager and zero-venv virtualization runtime.**
|
|
19
|
+
|
|
20
|
+
Evok demonstrates that a clean-slate Python implementation leveraging modern OS primitives and stdlib capabilities can reach ~80% of `uv`'s speed while delivering native capabilities that compiled binaries cannot provide: first-class in-process APIs, zero-venv `sys.meta_path` virtualization, dynamic AST dependency inference, and single-file zero-binary `.pyz` bootstrapping.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Key Highlights
|
|
25
|
+
|
|
26
|
+
- **Zero-Copy Reflink Cache**: Unpacks wheels once into a global cache and installs in milliseconds using macOS APFS `clonefile`, Linux `FICLONE`, and hardlink (`os.link`) fallbacks.
|
|
27
|
+
- **Bit-Packed PEP 440 Arithmetic**: Encodes release segments and lifecycle stages into single 64-bit scalar integers for microsecond comparisons.
|
|
28
|
+
- **Async Metadata & Range Tail-Reads**: Fetches package metadata from remote ZIP central directories using HTTP Range requests without downloading full wheels.
|
|
29
|
+
- **Zero-Venv Virtualization**: Run code with in-process `sys.meta_path` hooks that load dependencies directly from the cache without creating a `.venv` directory on disk.
|
|
30
|
+
- **AST Dependency Scanner**: Scans project source code to automatically detect third-party imports, flag missing packages, and prune unused/dead dependencies.
|
|
31
|
+
- **Pure-Python Wheel & Sdist Builder**: Standards-compliant PEP 427 wheel generator, PEP 625 sdist packager, and single-file `.pyz` executable bundler.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
### Installation
|
|
38
|
+
|
|
39
|
+
Evok runs on Python 3.12+ with zero external runtime dependencies:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Using uv
|
|
43
|
+
uv pip install evok
|
|
44
|
+
|
|
45
|
+
# Or via pip
|
|
46
|
+
pip install evok
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Command-Line Usage (`ev`)
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# Initialize a new project
|
|
53
|
+
ev init my-app
|
|
54
|
+
|
|
55
|
+
# Inspect AST imports and compare with pyproject.toml
|
|
56
|
+
ev scan
|
|
57
|
+
|
|
58
|
+
# Automatically prune unused dependencies
|
|
59
|
+
ev prune
|
|
60
|
+
|
|
61
|
+
# Build standard wheel, sdist, and standalone zipapp
|
|
62
|
+
ev build --zipapp
|
|
63
|
+
|
|
64
|
+
# Run a script with in-process zero-venv dependency virtualization
|
|
65
|
+
ev run script.py
|
|
66
|
+
|
|
67
|
+
# Check platform reflink support and runtime info
|
|
68
|
+
ev info
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Programmatic Python API
|
|
74
|
+
|
|
75
|
+
Evok is built as a first-class Python library:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from evok import Environment, InMemoryProvider, Resolver
|
|
79
|
+
|
|
80
|
+
# 1. Dependency Resolution
|
|
81
|
+
provider = InMemoryProvider(
|
|
82
|
+
{
|
|
83
|
+
"httpx": {"0.27.0": ["httpcore>=1.0.0"]},
|
|
84
|
+
"httpcore": {"1.0.5": []},
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
resolver = Resolver(provider=provider)
|
|
88
|
+
graph = resolver.resolve(["httpx>=0.24.0"])
|
|
89
|
+
print(graph.get("httpx").version) # 0.27.0
|
|
90
|
+
|
|
91
|
+
# 2. Ephemeral Zero-Venv Execution
|
|
92
|
+
with Environment.ephemeral(graph) as env:
|
|
93
|
+
mod = env.import_module("httpx")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Documentation
|
|
99
|
+
|
|
100
|
+
- [System Architecture](docs/architecture.md)
|
|
101
|
+
- [CLI Reference](docs/cli.md)
|
|
102
|
+
- [Python API Reference](docs/api.md)
|
|
103
|
+
- [Performance & Bottleneck Analysis](docs/benchmarks.md)
|
|
104
|
+
- [Implementation Plan](IMPLEMENTATION_PLAN.md)
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT License.
|
evok-0.1.0/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Evok (`ev`)
|
|
2
|
+
|
|
3
|
+
[](https://github.com/wispsy/evok/actions/workflows/ci.yml)
|
|
4
|
+
[](https://python.org)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
> **Pure-Python high-performance package manager and zero-venv virtualization runtime.**
|
|
8
|
+
|
|
9
|
+
Evok demonstrates that a clean-slate Python implementation leveraging modern OS primitives and stdlib capabilities can reach ~80% of `uv`'s speed while delivering native capabilities that compiled binaries cannot provide: first-class in-process APIs, zero-venv `sys.meta_path` virtualization, dynamic AST dependency inference, and single-file zero-binary `.pyz` bootstrapping.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Key Highlights
|
|
14
|
+
|
|
15
|
+
- **Zero-Copy Reflink Cache**: Unpacks wheels once into a global cache and installs in milliseconds using macOS APFS `clonefile`, Linux `FICLONE`, and hardlink (`os.link`) fallbacks.
|
|
16
|
+
- **Bit-Packed PEP 440 Arithmetic**: Encodes release segments and lifecycle stages into single 64-bit scalar integers for microsecond comparisons.
|
|
17
|
+
- **Async Metadata & Range Tail-Reads**: Fetches package metadata from remote ZIP central directories using HTTP Range requests without downloading full wheels.
|
|
18
|
+
- **Zero-Venv Virtualization**: Run code with in-process `sys.meta_path` hooks that load dependencies directly from the cache without creating a `.venv` directory on disk.
|
|
19
|
+
- **AST Dependency Scanner**: Scans project source code to automatically detect third-party imports, flag missing packages, and prune unused/dead dependencies.
|
|
20
|
+
- **Pure-Python Wheel & Sdist Builder**: Standards-compliant PEP 427 wheel generator, PEP 625 sdist packager, and single-file `.pyz` executable bundler.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
### Installation
|
|
27
|
+
|
|
28
|
+
Evok runs on Python 3.12+ with zero external runtime dependencies:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Using uv
|
|
32
|
+
uv pip install evok
|
|
33
|
+
|
|
34
|
+
# Or via pip
|
|
35
|
+
pip install evok
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Command-Line Usage (`ev`)
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Initialize a new project
|
|
42
|
+
ev init my-app
|
|
43
|
+
|
|
44
|
+
# Inspect AST imports and compare with pyproject.toml
|
|
45
|
+
ev scan
|
|
46
|
+
|
|
47
|
+
# Automatically prune unused dependencies
|
|
48
|
+
ev prune
|
|
49
|
+
|
|
50
|
+
# Build standard wheel, sdist, and standalone zipapp
|
|
51
|
+
ev build --zipapp
|
|
52
|
+
|
|
53
|
+
# Run a script with in-process zero-venv dependency virtualization
|
|
54
|
+
ev run script.py
|
|
55
|
+
|
|
56
|
+
# Check platform reflink support and runtime info
|
|
57
|
+
ev info
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Programmatic Python API
|
|
63
|
+
|
|
64
|
+
Evok is built as a first-class Python library:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from evok import Environment, InMemoryProvider, Resolver
|
|
68
|
+
|
|
69
|
+
# 1. Dependency Resolution
|
|
70
|
+
provider = InMemoryProvider(
|
|
71
|
+
{
|
|
72
|
+
"httpx": {"0.27.0": ["httpcore>=1.0.0"]},
|
|
73
|
+
"httpcore": {"1.0.5": []},
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
resolver = Resolver(provider=provider)
|
|
77
|
+
graph = resolver.resolve(["httpx>=0.24.0"])
|
|
78
|
+
print(graph.get("httpx").version) # 0.27.0
|
|
79
|
+
|
|
80
|
+
# 2. Ephemeral Zero-Venv Execution
|
|
81
|
+
with Environment.ephemeral(graph) as env:
|
|
82
|
+
mod = env.import_module("httpx")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Documentation
|
|
88
|
+
|
|
89
|
+
- [System Architecture](docs/architecture.md)
|
|
90
|
+
- [CLI Reference](docs/cli.md)
|
|
91
|
+
- [Python API Reference](docs/api.md)
|
|
92
|
+
- [Performance & Bottleneck Analysis](docs/benchmarks.md)
|
|
93
|
+
- [Implementation Plan](IMPLEMENTATION_PLAN.md)
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT License.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "evok"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Pure-Python high-performance packaging engine and virtualization runtime"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[[project.authors]]
|
|
12
|
+
name = "ellainng"
|
|
13
|
+
email = "299958882+ellainng@users.noreply.github.com"
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
ev = "evok.cli:main"
|
|
17
|
+
evok = "evok.cli:main"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["uv_build>=0.13.6,<0.14.0"]
|
|
21
|
+
build-backend = "uv_build"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=9.2.0",
|
|
26
|
+
"pytest-cov>=7.1.0",
|
|
27
|
+
"ruff>=0.17.4",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
line-length = 100
|
|
32
|
+
target-version = "py312"
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
|
36
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "evok"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Pure-Python high-performance packaging engine and virtualization runtime"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "ellainng", email = "299958882+ellainng@users.noreply.github.com" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
ev = "evok.cli:main"
|
|
16
|
+
evok = "evok.cli:main"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.13.6,<0.14.0"]
|
|
20
|
+
build-backend = "uv_build"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest>=9.2.0",
|
|
25
|
+
"pytest-cov>=7.1.0",
|
|
26
|
+
"ruff>=0.17.4",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.ruff]
|
|
30
|
+
line-length = 100
|
|
31
|
+
target-version = "py312"
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
pythonpath = ["src"]
|
|
@@ -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
|
+
]
|
|
@@ -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
|
+
)
|