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 ADDED
@@ -0,0 +1,3 @@
1
+ """VNMA — Vendor Manager for Python."""
2
+
3
+ __version__ = "0.1.0"
vnma/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Direct execution entry point for python -m vnma."""
2
+
3
+ import sys
4
+ from vnma.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
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
+ )