From 7e81c0e9a318d2aee94eb15d19e3f96d20d2b574 Mon Sep 17 00:00:00 2001 From: Emin Date: Tue, 7 Apr 2026 11:19:40 +0800 Subject: [PATCH] feat(chipcompiler): add manifest-based tool resolution for plugin manager Adds _resolve_from_manifest() to read ~/.ecos/tools/manifest.json and resolve plugin-managed tool binaries, inserting it as the first check in _resolve_yosys_command() before CHIPCOMPILER_OSS_CAD_DIR and system PATH. Also adds pytest norecursedirs config and conftest to skip inaccessible bazel symlinks during test collection. Co-Authored-By: Claude Opus 4.6 (1M context) --- chipcompiler/tools/yosys/utility.py | 33 ++++++- conftest.py | 1 + pyproject.toml | 4 + test/test_manifest_resolution.py | 134 ++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 conftest.py create mode 100644 test/test_manifest_resolution.py diff --git a/chipcompiler/tools/yosys/utility.py b/chipcompiler/tools/yosys/utility.py index 94e8a7b9..5730a100 100644 --- a/chipcompiler/tools/yosys/utility.py +++ b/chipcompiler/tools/yosys/utility.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +import json import os import shutil import subprocess @@ -12,6 +13,27 @@ def _sanitize_loader_env(env: dict[str, str]) -> dict[str, str]: return env +_MANIFEST_PATH = Path.home() / ".ecos" / "tools" / "manifest.json" + + +def _resolve_from_manifest(tool_name: str) -> tuple[list[str], Path | None]: + """Check ~/.ecos/tools/manifest.json for a plugin-managed tool.""" + if not _MANIFEST_PATH.exists(): + return [], None + try: + manifest = json.loads(_MANIFEST_PATH.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return [], None + entry = manifest.get("installed", {}).get(tool_name) + if not entry: + return [], None + tool_dir = Path(entry["path"]) + binary = tool_dir / "bin" / ("yosys.exe" if os.name == "nt" else tool_name) + if binary.exists(): + return [str(binary)], tool_dir + return [], None + + def _build_oss_cad_env(oss_path: Path, base_env: dict[str, str] | None = None) -> dict[str, str]: """Build subprocess environment variables for OSS CAD Suite.""" # TODO: Useless in nix build, consider remove this? @@ -46,17 +68,24 @@ def _resolve_oss_yosys_paths() -> tuple[str, Path | None, Path | None]: def _resolve_yosys_command() -> tuple[list[str], Path | None]: """ - Resolve yosys executable from bundled runtime first, then system PATH. + Resolve yosys executable: manifest first, then bundled runtime, then system PATH. Returns: (command, oss_path): - command: list containing executable command or empty list if unavailable - - oss_path: OSS CAD root path if bundled yosys is selected, else None + - oss_path: tool root path if resolved, else None """ + # 1. Check manifest (plugin-managed tools) + cmd, path = _resolve_from_manifest("yosys") + if cmd: + return cmd, path + + # 2. Check CHIPCOMPILER_OSS_CAD_DIR (bundled/Nix path) _, oss_path, yosys_bin = _resolve_oss_yosys_paths() if oss_path is not None and yosys_bin is not None and yosys_bin.exists(): return [str(yosys_bin)], oss_path + # 3. System PATH if shutil.which("yosys"): return ["yosys"], None diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..77dcf537 --- /dev/null +++ b/conftest.py @@ -0,0 +1 @@ +collect_ignore_glob = ["bazel-*"] diff --git a/pyproject.toml b/pyproject.toml index 6f93e99e..628403d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,6 +93,10 @@ lint.select = [ ] extend-include = ["*.spec"] +[tool.pytest.ini_options] +testpaths = ["test"] +norecursedirs = ["bazel-bin", "bazel-out", "bazel-ecc", "bazel-testlogs", ".venv", "dist"] + [tool.ty] environment.python-version = "3.11" src.include = [ "chipcompiler/**/*.py", "tests/**/*.py" ] diff --git a/test/test_manifest_resolution.py b/test/test_manifest_resolution.py new file mode 100644 index 00000000..19a1c4fc --- /dev/null +++ b/test/test_manifest_resolution.py @@ -0,0 +1,134 @@ +import json +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + + +def test_resolve_from_manifest_found(tmp_path: Path) -> None: + """When manifest has yosys entry and binary exists, resolve it.""" + from chipcompiler.tools.yosys.utility import _resolve_from_manifest + + # Set up fake tool installation + tool_dir = tmp_path / "yosys" / "0.61" + bin_dir = tool_dir / "bin" + bin_dir.mkdir(parents=True) + yosys_bin = bin_dir / "yosys" + yosys_bin.write_text("#!/bin/sh\necho yosys") + yosys_bin.chmod(0o755) + + manifest = { + "schema_version": 1, + "installed": { + "yosys": { + "version": "0.61", + "path": str(tool_dir), + "sha256": "abc123", + } + }, + } + manifest_path = tmp_path / "manifest.json" + manifest_path.write_text(json.dumps(manifest)) + + with patch( + "chipcompiler.tools.yosys.utility._MANIFEST_PATH", + manifest_path, + ): + cmd, tool_path = _resolve_from_manifest("yosys") + assert cmd == [str(yosys_bin)] + assert tool_path == tool_dir + + +def test_resolve_from_manifest_no_file(tmp_path: Path) -> None: + """When manifest doesn't exist, return empty.""" + from chipcompiler.tools.yosys.utility import _resolve_from_manifest + + with patch( + "chipcompiler.tools.yosys.utility._MANIFEST_PATH", + tmp_path / "nonexistent.json", + ): + cmd, tool_path = _resolve_from_manifest("yosys") + assert cmd == [] + assert tool_path is None + + +def test_resolve_from_manifest_tool_not_installed(tmp_path: Path) -> None: + """When manifest exists but tool not in it, return empty.""" + from chipcompiler.tools.yosys.utility import _resolve_from_manifest + + manifest = {"schema_version": 1, "installed": {}} + manifest_path = tmp_path / "manifest.json" + manifest_path.write_text(json.dumps(manifest)) + + with patch( + "chipcompiler.tools.yosys.utility._MANIFEST_PATH", + manifest_path, + ): + cmd, tool_path = _resolve_from_manifest("yosys") + assert cmd == [] + assert tool_path is None + + +def test_resolve_from_manifest_binary_missing(tmp_path: Path) -> None: + """When manifest has entry but binary doesn't exist, return empty.""" + from chipcompiler.tools.yosys.utility import _resolve_from_manifest + + tool_dir = tmp_path / "yosys" / "0.61" + tool_dir.mkdir(parents=True) + # Don't create the binary + + manifest = { + "schema_version": 1, + "installed": { + "yosys": { + "version": "0.61", + "path": str(tool_dir), + "sha256": "abc123", + } + }, + } + manifest_path = tmp_path / "manifest.json" + manifest_path.write_text(json.dumps(manifest)) + + with patch( + "chipcompiler.tools.yosys.utility._MANIFEST_PATH", + manifest_path, + ): + cmd, tool_path = _resolve_from_manifest("yosys") + assert cmd == [] + assert tool_path is None + + +def test_resolve_yosys_command_checks_manifest_first(tmp_path: Path) -> None: + """_resolve_yosys_command should check manifest before env var and PATH.""" + from chipcompiler.tools.yosys.utility import _resolve_yosys_command + + # Set up fake manifest tool + tool_dir = tmp_path / "yosys" / "0.61" + bin_dir = tool_dir / "bin" + bin_dir.mkdir(parents=True) + yosys_bin = bin_dir / "yosys" + yosys_bin.write_text("#!/bin/sh\necho yosys") + yosys_bin.chmod(0o755) + + manifest = { + "schema_version": 1, + "installed": { + "yosys": { + "version": "0.61", + "path": str(tool_dir), + "sha256": "abc123", + } + }, + } + manifest_path = tmp_path / "manifest.json" + manifest_path.write_text(json.dumps(manifest)) + + with patch( + "chipcompiler.tools.yosys.utility._MANIFEST_PATH", + manifest_path, + ): + cmd, oss_path = _resolve_yosys_command() + assert cmd == [str(yosys_bin)] + assert oss_path == tool_dir