vnma 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.
- vnma/__init__.py +3 -0
- vnma/__main__.py +7 -0
- vnma/cli.py +455 -0
- vnma/core/__init__.py +24 -0
- vnma/core/config.py +164 -0
- vnma/core/context.py +106 -0
- vnma/core/lockfile.py +84 -0
- vnma/core/models.py +188 -0
- vnma/core/package_config.py +55 -0
- vnma/core/platform_tags.py +136 -0
- vnma/pypi/__init__.py +7 -0
- vnma/pypi/cache.py +94 -0
- vnma/pypi/client.py +96 -0
- vnma/pypi/git.py +58 -0
- vnma/pypi/local_index.py +120 -0
- vnma/pypi/resolver.py +191 -0
- vnma/runtime/__init__.py +6 -0
- vnma/runtime/bootstrap.py +129 -0
- vnma/runtime/runner.py +67 -0
- vnma/visitors/__init__.py +30 -0
- vnma/visitors/artifact_visitors.py +159 -0
- vnma/visitors/base.py +58 -0
- vnma/visitors/git_visitors.py +55 -0
- vnma/visitors/ide_visitors.py +114 -0
- vnma/wheel/__init__.py +7 -0
- vnma/wheel/archive.py +102 -0
- vnma/wheel/builder.py +68 -0
- vnma/wheel/installer.py +136 -0
- vnma/wheel/pruner.py +118 -0
- vnma/wheel/uninstaller.py +93 -0
- vnma-0.1.0.dist-info/METADATA +169 -0
- vnma-0.1.0.dist-info/RECORD +36 -0
- vnma-0.1.0.dist-info/WHEEL +5 -0
- vnma-0.1.0.dist-info/entry_points.txt +2 -0
- vnma-0.1.0.dist-info/licenses/LICENSE +38 -0
- vnma-0.1.0.dist-info/top_level.txt +1 -0
vnma/__init__.py
ADDED
vnma/__main__.py
ADDED
vnma/cli.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""Command-line interface and subcommands for VNMA."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from vnma.core.config import ConfigHandler, VnmaConfig
|
|
10
|
+
from vnma.core.context import ProjectContext
|
|
11
|
+
from vnma.core.lockfile import LockfileHandler
|
|
12
|
+
from vnma.core.models import PackageSpec, normalize_package_name
|
|
13
|
+
from vnma.core.package_config import PackageConfig
|
|
14
|
+
from vnma.pypi.cache import WheelCache
|
|
15
|
+
from vnma.pypi.git import GitCloner
|
|
16
|
+
from vnma.pypi.local_index import LocalWheelIndex
|
|
17
|
+
from vnma.pypi.resolver import DependencyConflictError, WheelResolver
|
|
18
|
+
from vnma.runtime.bootstrap import BootstrapGenerator
|
|
19
|
+
from vnma.runtime.runner import ScriptRunner
|
|
20
|
+
from vnma.visitors.git_visitors import GitignoreVisitor
|
|
21
|
+
from vnma.visitors.ide_visitors import PyCharmConfigVisitor, VSCodeConfigVisitor
|
|
22
|
+
from vnma.wheel.builder import SourceBuilder
|
|
23
|
+
from vnma.wheel.installer import WheelInstaller
|
|
24
|
+
from vnma.wheel.pruner import OrphanPruner
|
|
25
|
+
from vnma.wheel.uninstaller import WheelUninstaller
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cmd_init(args: argparse.Namespace, context: ProjectContext) -> int:
|
|
29
|
+
"""Initialize VNMA configuration, directories, and developer environment."""
|
|
30
|
+
context.ensure_directories()
|
|
31
|
+
|
|
32
|
+
if not context.config_path.exists() and not context.pyproject_path.exists():
|
|
33
|
+
initial_cfg = VnmaConfig(
|
|
34
|
+
project_name=context.project_root.name,
|
|
35
|
+
version="0.1.0",
|
|
36
|
+
vendor_dir=context.vendor_name,
|
|
37
|
+
)
|
|
38
|
+
ConfigHandler.save(context.config_path, initial_cfg)
|
|
39
|
+
print(f"[vnma] Created {context.config_path.name}")
|
|
40
|
+
|
|
41
|
+
BootstrapGenerator(context.vendor_path).generate()
|
|
42
|
+
context.accept(GitignoreVisitor())
|
|
43
|
+
context.accept(VSCodeConfigVisitor())
|
|
44
|
+
context.accept(PyCharmConfigVisitor())
|
|
45
|
+
|
|
46
|
+
print(f"[vnma] Project initialized with vendor directory '{context.vendor_name}'")
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_install(args: argparse.Namespace, context: ProjectContext) -> int:
|
|
51
|
+
"""Install a package or restore all dependencies resolving transitive DAG constraints."""
|
|
52
|
+
context.ensure_directories()
|
|
53
|
+
cache = WheelCache()
|
|
54
|
+
installer = WheelInstaller(context)
|
|
55
|
+
builder = SourceBuilder(cache.cache_dir)
|
|
56
|
+
cloner = GitCloner()
|
|
57
|
+
|
|
58
|
+
offline = getattr(args, "offline", False)
|
|
59
|
+
find_links = getattr(args, "find_links", None)
|
|
60
|
+
search_dirs = [cache.cache_dir]
|
|
61
|
+
if find_links:
|
|
62
|
+
search_dirs.append(Path(find_links))
|
|
63
|
+
|
|
64
|
+
if offline:
|
|
65
|
+
local_index = LocalWheelIndex(search_directories=search_dirs)
|
|
66
|
+
resolver = WheelResolver(client=local_index)
|
|
67
|
+
print("[vnma] Offline mode enabled. Resolving strictly from local wheel caches.")
|
|
68
|
+
else:
|
|
69
|
+
resolver = WheelResolver()
|
|
70
|
+
|
|
71
|
+
target_pkg = args.package
|
|
72
|
+
|
|
73
|
+
if not target_pkg or target_pkg == ".":
|
|
74
|
+
config = ConfigHandler.load(context.config_path, context.pyproject_path)
|
|
75
|
+
specs = config.get_all_specs()
|
|
76
|
+
if not specs:
|
|
77
|
+
print("[vnma] No dependencies found to install.")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
pypi_specs: list[PackageSpec] = []
|
|
81
|
+
for spec in specs:
|
|
82
|
+
if spec.path:
|
|
83
|
+
_install_local_spec(spec, builder, installer)
|
|
84
|
+
elif spec.git_url:
|
|
85
|
+
if offline:
|
|
86
|
+
print(f"[vnma] WARNING: Git dependency '{spec.name}' may require network connectivity.")
|
|
87
|
+
_install_git_spec(spec, cloner, builder, installer)
|
|
88
|
+
else:
|
|
89
|
+
pypi_specs.append(spec)
|
|
90
|
+
|
|
91
|
+
if pypi_specs:
|
|
92
|
+
origin = "local cache" if offline else "PyPI"
|
|
93
|
+
print(f"[vnma] Resolving {len(pypi_specs)} package(s) from {origin} and transitive dependencies...")
|
|
94
|
+
try:
|
|
95
|
+
resolved_pairs = resolver.resolve_graph(pypi_specs)
|
|
96
|
+
except (DependencyConflictError, ValueError) as err:
|
|
97
|
+
print(f"[vnma] Resolution error: {err}")
|
|
98
|
+
if offline:
|
|
99
|
+
print("[vnma] Hint: Ensure required wheels exist in ~/.vnma/cache/wheels or pass --find-links <dir>")
|
|
100
|
+
return 1
|
|
101
|
+
|
|
102
|
+
for spec, wheel_rel in resolved_pairs:
|
|
103
|
+
_download_and_install(spec, wheel_rel, cache, installer)
|
|
104
|
+
|
|
105
|
+
LockfileHandler.update(context)
|
|
106
|
+
print(f"[vnma] Synchronized {context.lock_path.name}")
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
root_spec = PackageSpec.parse(target_pkg)
|
|
110
|
+
|
|
111
|
+
if root_spec.path:
|
|
112
|
+
record = _install_local_spec(root_spec, builder, installer)
|
|
113
|
+
ConfigHandler.add_dependency(
|
|
114
|
+
context.config_path,
|
|
115
|
+
record.name,
|
|
116
|
+
{"path": str(root_spec.path.as_posix())},
|
|
117
|
+
)
|
|
118
|
+
LockfileHandler.update(context)
|
|
119
|
+
print(f"[vnma] Synchronized {context.lock_path.name}")
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
if root_spec.git_url:
|
|
123
|
+
if offline:
|
|
124
|
+
print(f"[vnma] WARNING: Git dependency '{root_spec.name}' may require network connectivity.")
|
|
125
|
+
record = _install_git_spec(root_spec, cloner, builder, installer)
|
|
126
|
+
ConfigHandler.add_dependency(
|
|
127
|
+
context.config_path,
|
|
128
|
+
record.name,
|
|
129
|
+
{"git": root_spec.git_url, "branch": root_spec.git_ref or "main"},
|
|
130
|
+
)
|
|
131
|
+
LockfileHandler.update(context)
|
|
132
|
+
print(f"[vnma] Synchronized {context.lock_path.name}")
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
origin = "local cache" if offline else "PyPI"
|
|
136
|
+
print(f"[vnma] Resolving '{root_spec.name}' from {origin} and transitive dependencies...")
|
|
137
|
+
try:
|
|
138
|
+
resolved_pairs = resolver.resolve_graph([root_spec])
|
|
139
|
+
except (DependencyConflictError, ValueError) as err:
|
|
140
|
+
print(f"[vnma] Resolution error: {err}")
|
|
141
|
+
if offline:
|
|
142
|
+
print("[vnma] Hint: Ensure required wheels exist in ~/.vnma/cache/wheels or pass --find-links <dir>")
|
|
143
|
+
return 1
|
|
144
|
+
|
|
145
|
+
primary_record = None
|
|
146
|
+
for spec, wheel_rel in resolved_pairs:
|
|
147
|
+
record = _download_and_install(spec, wheel_rel, cache, installer)
|
|
148
|
+
if spec.name == root_spec.name:
|
|
149
|
+
primary_record = record
|
|
150
|
+
|
|
151
|
+
if primary_record:
|
|
152
|
+
ConfigHandler.add_dependency(
|
|
153
|
+
context.config_path,
|
|
154
|
+
primary_record.name,
|
|
155
|
+
f"=={primary_record.version}",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
LockfileHandler.update(context)
|
|
159
|
+
print(f"[vnma] Synchronized {context.lock_path.name}")
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _install_local_spec(
|
|
164
|
+
spec: PackageSpec,
|
|
165
|
+
builder: SourceBuilder,
|
|
166
|
+
installer: WheelInstaller,
|
|
167
|
+
):
|
|
168
|
+
"""Build and install package from local filesystem source path."""
|
|
169
|
+
src_path = spec.path
|
|
170
|
+
if not src_path or not src_path.is_dir():
|
|
171
|
+
raise FileNotFoundError(f"Source directory not found: {src_path}")
|
|
172
|
+
|
|
173
|
+
pkg_cfg = PackageConfig.load_from_dir(src_path)
|
|
174
|
+
if pkg_cfg:
|
|
175
|
+
print(f"[vnma] Loaded explicit configuration from {src_path / 'vnma_config.toml'}")
|
|
176
|
+
|
|
177
|
+
print(f"[vnma] Building wheel from local source '{src_path.name}'...")
|
|
178
|
+
wheel_path = builder.build_wheel(src_path)
|
|
179
|
+
|
|
180
|
+
ver = pkg_cfg.version if pkg_cfg and pkg_cfg.version else "0.0.0"
|
|
181
|
+
if ver == "0.0.0":
|
|
182
|
+
# Extract from wheel filename
|
|
183
|
+
parts = wheel_path.stem.split("-")
|
|
184
|
+
if len(parts) >= 2:
|
|
185
|
+
ver = parts[1]
|
|
186
|
+
|
|
187
|
+
print(f"[vnma] Installing built wheel {wheel_path.name} into vendor...")
|
|
188
|
+
record = installer.install(wheel_path, spec.name, ver, package_config=pkg_cfg)
|
|
189
|
+
print(
|
|
190
|
+
f"[vnma] Successfully installed {record.name}=={record.version} from {src_path} "
|
|
191
|
+
f"({len(record.files)} files, native_binaries={record.has_native_binaries})"
|
|
192
|
+
)
|
|
193
|
+
return record
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _install_git_spec(
|
|
197
|
+
spec: PackageSpec,
|
|
198
|
+
cloner: GitCloner,
|
|
199
|
+
builder: SourceBuilder,
|
|
200
|
+
installer: WheelInstaller,
|
|
201
|
+
):
|
|
202
|
+
"""Clone, build and install package from remote git repository."""
|
|
203
|
+
print(f"[vnma] Cloning git repository {spec.git_url} (ref: {spec.git_ref})...")
|
|
204
|
+
repo_path = cloner.fetch_repository(spec.git_url or "", spec.git_ref or "main")
|
|
205
|
+
|
|
206
|
+
pkg_cfg = PackageConfig.load_from_dir(repo_path)
|
|
207
|
+
if pkg_cfg:
|
|
208
|
+
print(f"[vnma] Loaded explicit configuration from {repo_path / 'vnma_config.toml'}")
|
|
209
|
+
|
|
210
|
+
print(f"[vnma] Building wheel from repository '{repo_path.name}'...")
|
|
211
|
+
wheel_path = builder.build_wheel(repo_path)
|
|
212
|
+
|
|
213
|
+
ver = pkg_cfg.version if pkg_cfg and pkg_cfg.version else "0.0.0"
|
|
214
|
+
if ver == "0.0.0":
|
|
215
|
+
parts = wheel_path.stem.split("-")
|
|
216
|
+
if len(parts) >= 2:
|
|
217
|
+
ver = parts[1]
|
|
218
|
+
|
|
219
|
+
print(f"[vnma] Installing built wheel {wheel_path.name} into vendor...")
|
|
220
|
+
record = installer.install(wheel_path, spec.name, ver, package_config=pkg_cfg)
|
|
221
|
+
print(
|
|
222
|
+
f"[vnma] Successfully installed {record.name}=={record.version} from git "
|
|
223
|
+
f"({len(record.files)} files, native_binaries={record.has_native_binaries})"
|
|
224
|
+
)
|
|
225
|
+
return record
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _download_and_install(
|
|
229
|
+
spec: PackageSpec,
|
|
230
|
+
wheel_release,
|
|
231
|
+
cache: WheelCache,
|
|
232
|
+
installer: WheelInstaller,
|
|
233
|
+
):
|
|
234
|
+
"""Download wheel from cache/PyPI and install into vendor directory."""
|
|
235
|
+
print(f"[vnma] Fetching {wheel_release.filename}...")
|
|
236
|
+
wheel_path = cache.download_wheel(
|
|
237
|
+
wheel_release.url,
|
|
238
|
+
wheel_release.filename,
|
|
239
|
+
wheel_release.sha256,
|
|
240
|
+
)
|
|
241
|
+
print(f"[vnma] Installing {wheel_release.filename} into vendor...")
|
|
242
|
+
record = installer.install(wheel_path, spec.name, wheel_release.version)
|
|
243
|
+
print(
|
|
244
|
+
f"[vnma] Successfully installed {record.name}=={record.version} "
|
|
245
|
+
f"({len(record.files)} files, native_binaries={record.has_native_binaries})"
|
|
246
|
+
)
|
|
247
|
+
return record
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def cmd_uninstall(args: argparse.Namespace, context: ProjectContext) -> int:
|
|
251
|
+
"""Uninstall a package and clean its files and configuration entries."""
|
|
252
|
+
raw_target = args.package
|
|
253
|
+
spec = PackageSpec.parse(raw_target)
|
|
254
|
+
uninstaller = WheelUninstaller(context)
|
|
255
|
+
|
|
256
|
+
if uninstaller.uninstall(spec.name):
|
|
257
|
+
ConfigHandler.remove_dependency(context.config_path, spec.name)
|
|
258
|
+
LockfileHandler.update(context)
|
|
259
|
+
print(f"[vnma] Successfully uninstalled '{spec.name}' and synchronized {context.lock_path.name}")
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
print(f"[vnma] Package '{spec.name}' is not currently installed.")
|
|
263
|
+
return 1
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def cmd_prune(args: argparse.Namespace, context: ProjectContext) -> int:
|
|
267
|
+
"""Identify and remove orphaned packages not reachable from project dependencies."""
|
|
268
|
+
pruner = OrphanPruner(context)
|
|
269
|
+
orphans = pruner.find_orphans()
|
|
270
|
+
|
|
271
|
+
if not orphans:
|
|
272
|
+
print("[vnma] No orphaned packages found. Vendor tree is clean.")
|
|
273
|
+
return 0
|
|
274
|
+
|
|
275
|
+
if getattr(args, "dry_run", False):
|
|
276
|
+
print(f"[vnma] Dry-run: The following {len(orphans)} orphaned package(s) would be removed:")
|
|
277
|
+
for pkg in orphans:
|
|
278
|
+
print(f" - {pkg}")
|
|
279
|
+
return 0
|
|
280
|
+
|
|
281
|
+
if not getattr(args, "yes", False):
|
|
282
|
+
try:
|
|
283
|
+
confirm = input(f"[vnma] Prune {len(orphans)} orphaned package(s)? [y/N]: ").strip().lower()
|
|
284
|
+
if confirm not in ("y", "yes"):
|
|
285
|
+
print("[vnma] Pruning aborted.")
|
|
286
|
+
return 0
|
|
287
|
+
except (KeyboardInterrupt, EOFError):
|
|
288
|
+
print("\n[vnma] Pruning aborted.")
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
pruned = pruner.prune(dry_run=False)
|
|
292
|
+
print(f"[vnma] Successfully pruned {len(pruned)} orphaned package(s): {', '.join(pruned)}")
|
|
293
|
+
print(f"[vnma] Synchronized {context.lock_path.name}")
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cmd_list(args: argparse.Namespace, context: ProjectContext) -> int:
|
|
298
|
+
"""Display all packages and versions installed in the vendor directory."""
|
|
299
|
+
installer = WheelInstaller(context)
|
|
300
|
+
manifest = installer._load_manifest()
|
|
301
|
+
|
|
302
|
+
if not manifest.packages:
|
|
303
|
+
print("[vnma] No packages installed in vendor directory.")
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
print(f"[vnma] Packages installed in '{context.vendor_name}':")
|
|
307
|
+
print(f"{'Package':<25} {'Version':<15} {'Native':<10}")
|
|
308
|
+
print("-" * 52)
|
|
309
|
+
for name, record in sorted(manifest.packages.items()):
|
|
310
|
+
native_flag = "Yes" if record.has_native_binaries else "No"
|
|
311
|
+
print(f"{name:<25} {record.version:<15} {native_flag:<10}")
|
|
312
|
+
|
|
313
|
+
return 0
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def cmd_sync_ide(args: argparse.Namespace, context: ProjectContext) -> int:
|
|
317
|
+
"""Regenerate IDE workspace configuration for VS Code and PyCharm."""
|
|
318
|
+
vs_updated = context.accept(VSCodeConfigVisitor())
|
|
319
|
+
pc_updated = context.accept(PyCharmConfigVisitor())
|
|
320
|
+
git_updated = context.accept(GitignoreVisitor())
|
|
321
|
+
|
|
322
|
+
print(
|
|
323
|
+
f"[vnma] IDE sync finished (vscode_changed={vs_updated}, "
|
|
324
|
+
f"pycharm_changed={pc_updated}, gitignore_changed={git_updated})"
|
|
325
|
+
)
|
|
326
|
+
return 0
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def cmd_run(args: argparse.Namespace, context: ProjectContext) -> int:
|
|
330
|
+
"""Execute a Python script with the vendor directory configured."""
|
|
331
|
+
script_path = Path(args.script)
|
|
332
|
+
if not script_path.exists():
|
|
333
|
+
print(f"[vnma] Error: Script '{args.script}' not found.")
|
|
334
|
+
return 1
|
|
335
|
+
|
|
336
|
+
runner = ScriptRunner(context)
|
|
337
|
+
return runner.run_script(script_path, args.args)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def cmd_exec(args: argparse.Namespace, context: ProjectContext) -> int:
|
|
341
|
+
"""Execute a CLI tool installed in the vendor binary directory."""
|
|
342
|
+
runner = ScriptRunner(context)
|
|
343
|
+
try:
|
|
344
|
+
return runner.exec_binary(args.binary, args.args)
|
|
345
|
+
except FileNotFoundError as err:
|
|
346
|
+
print(f"[vnma] Error: {err}")
|
|
347
|
+
return 1
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def cmd_check(args: argparse.Namespace, context: ProjectContext) -> int:
|
|
351
|
+
"""Verify runtime compatibility and python version consistency."""
|
|
352
|
+
ver_file = context.meta_path / "python_version"
|
|
353
|
+
current_ver = (
|
|
354
|
+
f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
if not ver_file.exists():
|
|
358
|
+
print("[vnma] No metadata found. Vendor has not been built yet.")
|
|
359
|
+
return 0
|
|
360
|
+
|
|
361
|
+
saved_ver = ver_file.read_text(encoding="utf-8").strip()
|
|
362
|
+
if saved_ver != current_ver:
|
|
363
|
+
print(
|
|
364
|
+
f"[vnma] WARNING: Vendor was built with Python {saved_ver}, "
|
|
365
|
+
f"but you are running Python {current_ver}."
|
|
366
|
+
)
|
|
367
|
+
print("[vnma] Native binary extensions (.pyd/.so) may fail to load.")
|
|
368
|
+
print("[vnma] Run 'vnma install .' to rebuild dependencies.")
|
|
369
|
+
return 2
|
|
370
|
+
|
|
371
|
+
print(f"[vnma] Health check passed. Runtime matches Python {current_ver}.")
|
|
372
|
+
return 0
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
376
|
+
"""Construct command-line argument parser with all subcommands."""
|
|
377
|
+
parser = argparse.ArgumentParser(
|
|
378
|
+
prog="vnma",
|
|
379
|
+
description="VNMA — Vendor Manager for Python",
|
|
380
|
+
)
|
|
381
|
+
subparsers = parser.add_subparsers(dest="subcommand", required=True)
|
|
382
|
+
|
|
383
|
+
init_parser = subparsers.add_parser("init", help="Initialize project vendor configuration")
|
|
384
|
+
init_parser.set_defaults(handler=cmd_init)
|
|
385
|
+
|
|
386
|
+
install_parser = subparsers.add_parser("install", help="Install a package or restore dependencies")
|
|
387
|
+
install_parser.add_argument(
|
|
388
|
+
"package",
|
|
389
|
+
nargs="?",
|
|
390
|
+
default=None,
|
|
391
|
+
help="Package name, version, local directory path, or git+ URL",
|
|
392
|
+
)
|
|
393
|
+
install_parser.add_argument(
|
|
394
|
+
"--offline",
|
|
395
|
+
action="store_true",
|
|
396
|
+
help="Install strictly from local wheel cache without network calls",
|
|
397
|
+
)
|
|
398
|
+
install_parser.add_argument(
|
|
399
|
+
"--find-links",
|
|
400
|
+
type=str,
|
|
401
|
+
default=None,
|
|
402
|
+
help="Directory to search for local wheel archives",
|
|
403
|
+
)
|
|
404
|
+
install_parser.set_defaults(handler=cmd_install)
|
|
405
|
+
|
|
406
|
+
uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall a vendored package")
|
|
407
|
+
uninstall_parser.add_argument("package", help="Package to remove (e.g. pygame-2.5.6)")
|
|
408
|
+
uninstall_parser.set_defaults(handler=cmd_uninstall)
|
|
409
|
+
|
|
410
|
+
prune_parser = subparsers.add_parser("prune", help="Remove orphaned packages not defined in vnma.toml")
|
|
411
|
+
prune_parser.add_argument(
|
|
412
|
+
"--dry-run",
|
|
413
|
+
action="store_true",
|
|
414
|
+
help="Preview orphaned packages without removing them",
|
|
415
|
+
)
|
|
416
|
+
prune_parser.add_argument(
|
|
417
|
+
"-y",
|
|
418
|
+
"--yes",
|
|
419
|
+
action="store_true",
|
|
420
|
+
help="Automatically confirm pruning of orphaned packages",
|
|
421
|
+
)
|
|
422
|
+
prune_parser.set_defaults(handler=cmd_prune)
|
|
423
|
+
|
|
424
|
+
list_parser = subparsers.add_parser("list", help="List vendored packages")
|
|
425
|
+
list_parser.set_defaults(handler=cmd_list)
|
|
426
|
+
|
|
427
|
+
sync_parser = subparsers.add_parser("sync-ide", help="Synchronize IDE settings for VS Code and PyCharm")
|
|
428
|
+
sync_parser.set_defaults(handler=cmd_sync_ide)
|
|
429
|
+
|
|
430
|
+
run_parser = subparsers.add_parser("run", help="Run a Python script with vendor environment active")
|
|
431
|
+
run_parser.add_argument("script", help="Path to Python script to execute")
|
|
432
|
+
run_parser.add_argument("args", nargs=argparse.REMAINDER, help="Arguments passed to the script")
|
|
433
|
+
run_parser.set_defaults(handler=cmd_run)
|
|
434
|
+
|
|
435
|
+
exec_parser = subparsers.add_parser("exec", help="Execute an installed binary from vendor/.bin")
|
|
436
|
+
exec_parser.add_argument("binary", help="Binary or CLI name (e.g. pytest)")
|
|
437
|
+
exec_parser.add_argument("args", nargs=argparse.REMAINDER, help="Arguments passed to the binary")
|
|
438
|
+
exec_parser.set_defaults(handler=cmd_exec)
|
|
439
|
+
|
|
440
|
+
check_parser = subparsers.add_parser("check", help="Check vendor runtime compatibility")
|
|
441
|
+
check_parser.set_defaults(handler=cmd_check)
|
|
442
|
+
|
|
443
|
+
return parser
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def main(argv: list[str] | None = None) -> int:
|
|
447
|
+
"""CLI application entrypoint."""
|
|
448
|
+
parser = build_parser()
|
|
449
|
+
args = parser.parse_args(argv)
|
|
450
|
+
context = ProjectContext()
|
|
451
|
+
return args.handler(args, context)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
if __name__ == "__main__":
|
|
455
|
+
sys.exit(main())
|
vnma/core/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Core modules and data representations for VNMA."""
|
|
2
|
+
|
|
3
|
+
from vnma.core.config import ConfigHandler, VnmaConfig
|
|
4
|
+
from vnma.core.context import ProjectContext
|
|
5
|
+
from vnma.core.models import (
|
|
6
|
+
InstalledFile,
|
|
7
|
+
InstalledPackageRecord,
|
|
8
|
+
PackageSpec,
|
|
9
|
+
VnmaManifest,
|
|
10
|
+
WheelRelease,
|
|
11
|
+
)
|
|
12
|
+
from vnma.core.platform_tags import PlatformTagMatcher
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ConfigHandler",
|
|
16
|
+
"InstalledFile",
|
|
17
|
+
"InstalledPackageRecord",
|
|
18
|
+
"PackageSpec",
|
|
19
|
+
"PlatformTagMatcher",
|
|
20
|
+
"ProjectContext",
|
|
21
|
+
"VnmaConfig",
|
|
22
|
+
"VnmaManifest",
|
|
23
|
+
"WheelRelease",
|
|
24
|
+
]
|
vnma/core/config.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Configuration loader and serializer for vnma.toml and pyproject.toml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from vnma.core.models import PackageSpec, normalize_package_name
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class VnmaConfig:
|
|
15
|
+
"""In-memory representation of project vendor configuration."""
|
|
16
|
+
|
|
17
|
+
project_name: str = "project"
|
|
18
|
+
version: str = "0.1.0"
|
|
19
|
+
dependencies: dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
dev_dependencies: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
vendor_dir: str = "vendor"
|
|
22
|
+
ide_integration: list[str] = field(
|
|
23
|
+
default_factory=lambda: ["vscode", "pycharm"]
|
|
24
|
+
)
|
|
25
|
+
package_overrides: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
def get_all_specs(self) -> list[PackageSpec]:
|
|
28
|
+
"""Convert declared dependency definitions into PackageSpec instances."""
|
|
29
|
+
specs: list[PackageSpec] = []
|
|
30
|
+
for name, value in self.dependencies.items():
|
|
31
|
+
norm_name = normalize_package_name(name)
|
|
32
|
+
if isinstance(value, dict):
|
|
33
|
+
if "path" in value:
|
|
34
|
+
specs.append(
|
|
35
|
+
PackageSpec(
|
|
36
|
+
name=norm_name,
|
|
37
|
+
path=Path(value["path"]).resolve(),
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
elif "git" in value:
|
|
41
|
+
ref = value.get("branch") or value.get("tag") or value.get("commit") or "main"
|
|
42
|
+
specs.append(
|
|
43
|
+
PackageSpec(
|
|
44
|
+
name=norm_name,
|
|
45
|
+
git_url=value["git"],
|
|
46
|
+
git_ref=ref,
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
elif isinstance(value, str):
|
|
50
|
+
raw = f"{norm_name}{value}" if value and value != "*" else norm_name
|
|
51
|
+
specs.append(PackageSpec.parse(raw))
|
|
52
|
+
return specs
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ConfigHandler:
|
|
56
|
+
"""Reads and writes TOML project configuration files."""
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def load(config_path: Path, pyproject_path: Path | None = None) -> VnmaConfig:
|
|
60
|
+
"""Parse configuration from vnma.toml or fallback to pyproject.toml."""
|
|
61
|
+
if config_path.exists():
|
|
62
|
+
with config_path.open("rb") as f:
|
|
63
|
+
data = tomllib.load(f)
|
|
64
|
+
return ConfigHandler._parse_vnma_data(data)
|
|
65
|
+
|
|
66
|
+
if pyproject_path and pyproject_path.exists():
|
|
67
|
+
with pyproject_path.open("rb") as f:
|
|
68
|
+
data = tomllib.load(f)
|
|
69
|
+
tool_data = data.get("tool", {}).get("vnma", {})
|
|
70
|
+
project_data = data.get("project", {})
|
|
71
|
+
return VnmaConfig(
|
|
72
|
+
project_name=project_data.get("name", "project"),
|
|
73
|
+
version=project_data.get("version", "0.1.0"),
|
|
74
|
+
dependencies=tool_data.get("dependencies", {}),
|
|
75
|
+
dev_dependencies=tool_data.get("dev-dependencies", {}),
|
|
76
|
+
vendor_dir=tool_data.get("vendor_dir", "vendor"),
|
|
77
|
+
ide_integration=tool_data.get("ide_integration", ["vscode", "pycharm"]),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return VnmaConfig()
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def save(config_path: Path, config: VnmaConfig) -> None:
|
|
84
|
+
"""Serialize configuration directly to TOML file format."""
|
|
85
|
+
lines: list[str] = [
|
|
86
|
+
"[project]",
|
|
87
|
+
f'name = "{config.project_name}"',
|
|
88
|
+
f'version = "{config.version}"',
|
|
89
|
+
"",
|
|
90
|
+
"[dependencies]",
|
|
91
|
+
]
|
|
92
|
+
for name, req in sorted(config.dependencies.items()):
|
|
93
|
+
if isinstance(req, dict):
|
|
94
|
+
formatted_inner = ", ".join(f'{k} = "{v}"' for k, v in req.items())
|
|
95
|
+
lines.append(f"{name} = {{ {formatted_inner} }}")
|
|
96
|
+
else:
|
|
97
|
+
lines.append(f'{name} = "{req}"')
|
|
98
|
+
|
|
99
|
+
lines.extend([
|
|
100
|
+
"",
|
|
101
|
+
"[dev-dependencies]",
|
|
102
|
+
])
|
|
103
|
+
for name, req in sorted(config.dev_dependencies.items()):
|
|
104
|
+
if isinstance(req, dict):
|
|
105
|
+
formatted_inner = ", ".join(f'{k} = "{v}"' for k, v in req.items())
|
|
106
|
+
lines.append(f"{name} = {{ {formatted_inner} }}")
|
|
107
|
+
else:
|
|
108
|
+
lines.append(f'{name} = "{req}"')
|
|
109
|
+
|
|
110
|
+
lines.extend([
|
|
111
|
+
"",
|
|
112
|
+
"[tool.vnma]",
|
|
113
|
+
f'vendor_dir = "{config.vendor_dir}"',
|
|
114
|
+
f'ide_integration = {config.ide_integration!r}'.replace("'", '"'),
|
|
115
|
+
])
|
|
116
|
+
|
|
117
|
+
if config.package_overrides:
|
|
118
|
+
lines.append("")
|
|
119
|
+
for pkg_name, overrides in sorted(config.package_overrides.items()):
|
|
120
|
+
lines.append(f"[package.{pkg_name}]")
|
|
121
|
+
for k, v in overrides.items():
|
|
122
|
+
if isinstance(v, list):
|
|
123
|
+
lines.append(f'{k} = {v!r}'.replace("'", '"'))
|
|
124
|
+
elif isinstance(v, dict):
|
|
125
|
+
lines.append(f'{k} = {v!r}'.replace("'", '"'))
|
|
126
|
+
else:
|
|
127
|
+
lines.append(f'{k} = "{v}"')
|
|
128
|
+
|
|
129
|
+
lines.append("")
|
|
130
|
+
config_path.write_text("\n".join(lines), encoding="utf-8")
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def add_dependency(config_path: Path, name: str, value: Any = "*") -> None:
|
|
134
|
+
"""Add or update a dependency entry inside vnma.toml."""
|
|
135
|
+
config = ConfigHandler.load(config_path)
|
|
136
|
+
norm_name = normalize_package_name(name)
|
|
137
|
+
config.dependencies[norm_name] = value
|
|
138
|
+
ConfigHandler.save(config_path, config)
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def remove_dependency(config_path: Path, name: str) -> None:
|
|
142
|
+
"""Remove a dependency entry from vnma.toml if present."""
|
|
143
|
+
config = ConfigHandler.load(config_path)
|
|
144
|
+
norm_name = normalize_package_name(name)
|
|
145
|
+
config.dependencies.pop(norm_name, None)
|
|
146
|
+
config.dev_dependencies.pop(norm_name, None)
|
|
147
|
+
ConfigHandler.save(config_path, config)
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _parse_vnma_data(data: dict[str, Any]) -> VnmaConfig:
|
|
151
|
+
"""Extract configuration properties from raw parsed dictionary."""
|
|
152
|
+
project_data = data.get("project", {})
|
|
153
|
+
tool_data = data.get("tool", {}).get("vnma", {})
|
|
154
|
+
package_overrides = data.get("package", {})
|
|
155
|
+
|
|
156
|
+
return VnmaConfig(
|
|
157
|
+
project_name=project_data.get("name", "project"),
|
|
158
|
+
version=project_data.get("version", "0.1.0"),
|
|
159
|
+
dependencies=data.get("dependencies", {}),
|
|
160
|
+
dev_dependencies=data.get("dev-dependencies", {}),
|
|
161
|
+
vendor_dir=tool_data.get("vendor_dir", "vendor"),
|
|
162
|
+
ide_integration=tool_data.get("ide_integration", ["vscode", "pycharm"]),
|
|
163
|
+
package_overrides=package_overrides,
|
|
164
|
+
)
|