diff --git a/.github/scripts/ci_export_signoff_csv.py b/.github/scripts/ci_export_signoff_csv.py new file mode 100644 index 000000000..3bd6b3563 --- /dev/null +++ b/.github/scripts/ci_export_signoff_csv.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""CI harness: CSV + FileCheck projection from a workspace built by packaged ecc. + +Not a second flow engine — only tabularizes QoR/checklist/flow already on disk. +""" + +import argparse +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--workspace", type=Path, required=True) + parser.add_argument("--out-dir", type=Path, default=Path("ci-artifacts/eda-signoff")) + parser.add_argument( + "--spec", + type=Path, + default=REPO_ROOT / "test" / "support" / "csv_profiles" / "signoff.yml", + ) + args = parser.parse_args(argv) + + for path in (REPO_ROOT, REPO_ROOT / "test"): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + + from support.csv_export import build_csv_bundle, write_csv_bundle + from support.csv_projection import write_projection + from support.csv_spec import load_csv_spec + + from chipcompiler.data import load_workspace + + workspace_dir = args.workspace.resolve() + if not workspace_dir.is_dir(): + print(f"workspace missing: {workspace_dir}", file=sys.stderr) + return 1 + + out_dir = args.out_dir.resolve() + csv_dir = out_dir / "csv" + reports = out_dir / "reports" + for path in (csv_dir, reports): + path.mkdir(parents=True, exist_ok=True) + + workspace = load_workspace(str(workspace_dir)) + if workspace is None: + print(f"invalid workspace: {workspace_dir}", file=sys.stderr) + return 1 + + spec = load_csv_spec(str(args.spec.resolve())) + bundle = build_csv_bundle(workspace, spec=spec) + files = write_csv_bundle(bundle, str(csv_dir)) + if not files: + print("csv export wrote no tables", file=sys.stderr) + return 1 + + check_path = write_projection(csv_dir, bundle.design, reports / "metrics.check.txt") + print(f"csv → {csv_dir} ({len(files)} files)") + print(f"projection → {check_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/ci_run_ics55_gcd.py b/.github/scripts/ci_run_ics55_gcd.py new file mode 100644 index 000000000..a2b950017 --- /dev/null +++ b/.github/scripts/ci_run_ics55_gcd.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""CI: run ics55 gcd rtl2gds through a packaged ``ecc`` binary. + +Requires ``ECC_BIN`` (absolute path to the PyInstaller ``ecc`` executable). +Creates ``--project-dir``, copies fixture RTL, sets the PDK root, then +``ecc run --workspace default``. +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _run(ecc: Path, args: list[str], *, cwd: Path | None = None) -> None: + cmd = [str(ecc), *args] + print("+", " ".join(cmd), flush=True) + completed = subprocess.run(cmd, cwd=cwd, check=False) + if completed.returncode != 0: + raise SystemExit(completed.returncode) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--ecc", + type=Path, + default=Path(os.environ["ECC_BIN"]) if os.environ.get("ECC_BIN") else None, + help="Packaged ecc binary (or set ECC_BIN)", + ) + parser.add_argument( + "--project-dir", + type=Path, + default=Path("ci-artifacts/gcd"), + help="Project directory created by ecc init", + ) + parser.add_argument( + "--pdk-root", + type=Path, + default=None, + help="icsprout55-pdk root (default: ../pdk/icsprout55-pdk relative to repo)", + ) + parser.add_argument( + "--workspace-name", + default="default", + help="Managed workspace name passed to ecc run", + ) + args = parser.parse_args(argv) + + if args.ecc is None: + print("missing --ecc / ECC_BIN (packaged ecc binary required)", file=sys.stderr) + return 2 + ecc = args.ecc.resolve() + if not ecc.is_file() or not os.access(ecc, os.X_OK): + print(f"ecc binary not executable: {ecc}", file=sys.stderr) + return 2 + + verilog = REPO_ROOT / "test" / "fixtures" / "gcd" / "gcd.v" + if not verilog.is_file(): + print(f"missing fixture RTL: {verilog}", file=sys.stderr) + return 1 + + pdk_root = ( + args.pdk_root.resolve() + if args.pdk_root is not None + else (REPO_ROOT.parent / "pdk" / "icsprout55-pdk").resolve() + ) + if not pdk_root.is_dir(): + # CI clones to ../pdk from the repo working directory. + alt = (Path.cwd().parent / "pdk" / "icsprout55-pdk").resolve() + pdk_root = alt if alt.is_dir() else pdk_root + if not pdk_root.is_dir(): + print(f"PDK root missing: {pdk_root}", file=sys.stderr) + return 1 + + project_dir = args.project_dir.resolve() + if project_dir.exists(): + shutil.rmtree(project_dir) + project_dir.parent.mkdir(parents=True, exist_ok=True) + + # ecc init creates / relative to cwd; pass path as the project name. + _run(ecc, ["init", str(project_dir), "--plain"]) + + rtl_dest = project_dir / "rtl" / "gcd.v" + rtl_dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(verilog, rtl_dest) + + _run(ecc, ["pdk", "set-root", str(pdk_root), "--project", str(project_dir), "--plain"]) + _run( + ecc, + [ + "run", + "--project", + str(project_dir), + "--workspace", + args.workspace_name, + "--plain", + ], + ) + + workspace = project_dir / args.workspace_name + marker = project_dir.parent / "eda-gcd.ok" + if not (workspace / "home" / "flow.json").is_file(): + if marker.exists(): + marker.unlink() + print(f"EDA rtl2gds failed → missing flow.json under {workspace}", file=sys.stderr) + return 1 + + marker.write_text(str(workspace) + "\n", encoding="utf-8") + print(f"EDA rtl2gds ok → {workspace}") + print(f"ECC_WORKSPACE={workspace}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e60205bf7..7944933e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,8 +46,61 @@ jobs: with: expected_ref: ${{ startsWith(github.ref, 'refs/heads/release/v') && github.ref || startsWith(github.base_ref, 'release/v') && github.base_ref || '' }} - test: - name: Test + lint: + needs: check-version + runs-on: ubuntu-latest + container: quay.io/pypa/manylinux_2_34_x86_64 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Python dependencies + uses: ./.github/actions/setup-python-deps + + - name: Ruff format check + run: uv run --no-sync ruff format --check chipcompiler test + + - name: Ruff lint + run: uv run --no-sync ruff check --output-format=github chipcompiler test + + unit-test: + name: Unit tests + needs: check-version + runs-on: ubuntu-latest + container: quay.io/pypa/manylinux_2_34_x86_64 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Python dependencies + uses: ./.github/actions/setup-python-deps + with: + build-all: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs['build-all'] == 'true' && 'true' || 'false' }} + + - name: Pytest (no integration / no packaged EDA) + run: > + uv run --no-sync pytest test/ -v + --ignore=test/integration + --basetemp=pytest-tmp + --cov=chipcompiler --cov-report= + + - name: Publish coverage summary + if: success() + run: | + set -euo pipefail + { + echo '## Coverage Report' + echo '```' + uv run --no-sync coverage report + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + packaged-ecc: + name: Build packaged ecc and run EDA needs: check-version runs-on: ubuntu-latest container: quay.io/pypa/manylinux_2_34_x86_64 @@ -62,6 +115,18 @@ jobs: with: build-all: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs['build-all'] == 'true' && 'true' || 'false' }} + - name: Smoke test native toolchain wheels + run: | + .venv/bin/python - <<'PY' + import ecc_tools_bin.ecc_py # noqa: F401 + from chipcompiler.tools.ecc.module import ECCToolsModule + from dreamplace.Params import Params # noqa: F401 + from dreamplace.Placer import PlacementEngine # noqa: F401 + + assert ECCToolsModule().get_ecc() is not None + print("ecc-tools wrapper and ecc-dreamplace imports passed") + PY + - name: Setup PDK run: | git clone --depth 1 https://github.com/openecos-projects/icsprout55-pdk.git ../pdk/icsprout55-pdk @@ -121,63 +186,49 @@ jobs: command -v Sizer test -f "${CHIPCOMPILER_ECC_SIZER_ROOT}/src/sizer_os.tcl" - - name: Pytest - run: uv run --no-sync pytest test/ -v --basetemp=pytest-tmp --cov=chipcompiler --cov-report= - - - name: Upload integration flow logs on failure - if: failure() - uses: actions/upload-artifact@v4 + - name: Build PyInstaller bundle + uses: ./.github/actions/build-pyinstaller-bundle with: - name: integration-flow-logs - if-no-files-found: ignore - path: | - pytest-tmp/**/home/flow.json - pytest-tmp/**/log/** - pytest-tmp/**/data/**/*.log + artifact-path: dist/ecc.tar + artifact-format: tar + smoke-dir: dist/ecc-packaged - - name: Publish coverage summary - if: always() + - name: Point ECC_BIN at packaged binary run: | - { - echo '## Coverage Report' - echo '```' - uv run --no-sync coverage report - echo '```' - } >> "$GITHUB_STEP_SUMMARY" + set -euo pipefail + test -x dist/ecc-packaged/ecc + echo "ECC_BIN=${GITHUB_WORKSPACE}/dist/ecc-packaged/ecc" >> "$GITHUB_ENV" - build-pyinstaller: - name: Build PyInstaller Bundle - needs: check-version - runs-on: ubuntu-latest - container: quay.io/pypa/manylinux_2_34_x86_64 - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - submodules: recursive + - name: Install Nix (lit / filecheck / CI script wrappers) + uses: DeterminateSystems/nix-installer-action@v16 - - name: Setup Python dependencies - uses: ./.github/actions/setup-python-deps - with: - build-all: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs['build-all'] == 'true' && 'true' || 'false' }} - - - name: Smoke test native toolchain wheels + - name: Packaged ecc rtl2gds (ics55 gcd) run: | - .venv/bin/python - <<'PY' - import ecc_tools_bin.ecc_py # noqa: F401 - from chipcompiler.tools.ecc.module import ECCToolsModule - from dreamplace.Params import Params # noqa: F401 - from dreamplace.Placer import PlacementEngine # noqa: F401 - - assert ECCToolsModule().get_ecc() is not None - print("ecc-tools wrapper and ecc-dreamplace imports passed") - PY + set -euo pipefail + export ECC_REPO_ROOT="${GITHUB_WORKSPACE}" + nix run .#ci-run-ics55-gcd -- \ + --ecc "${ECC_BIN}" \ + --project-dir ci-artifacts/gcd \ + --pdk-root ../pdk/icsprout55-pdk - - name: Build PyInstaller bundle - uses: ./.github/actions/build-pyinstaller-bundle - with: - artifact-path: dist/ecc.tar - artifact-format: tar + - name: Packaged ecc signoff export + CSV FileCheck + run: | + set -euo pipefail + export ECC_REPO_ROOT="${GITHUB_WORKSPACE}" + workspace=ci-artifacts/gcd/default + test -f "${workspace}/home/flow.json" + mkdir -p ci-artifacts/eda-signoff/packages + nix run .#ci-export-signoff-csv -- \ + --workspace "${workspace}" \ + --out-dir ci-artifacts/eda-signoff + "${ECC_BIN}" signoff export \ + --project ci-artifacts/gcd \ + --workspace default \ + --output ci-artifacts/eda-signoff/packages/signoff.tar.gz \ + --plain + nix run .#filecheck -- \ + --input-file=ci-artifacts/eda-signoff/reports/metrics.check.txt \ + test/support/csv_profiles/ics55_gcd.check - name: Upload PyInstaller artifact if: always() @@ -188,24 +239,27 @@ jobs: path: | dist/ecc.tar - lint: - needs: check-version - runs-on: ubuntu-latest - container: quay.io/pypa/manylinux_2_34_x86_64 - steps: - - name: Checkout - uses: actions/checkout@v4 + - name: Upload EDA signoff artifacts + if: always() + uses: actions/upload-artifact@v4 with: - submodules: recursive - - - name: Setup Python dependencies - uses: ./.github/actions/setup-python-deps - - - name: Ruff format check - run: uv run --no-sync ruff format --check chipcompiler test - - - name: Ruff lint - run: uv run --no-sync ruff check --output-format=github chipcompiler test + name: eda-signoff-artifacts + if-no-files-found: ignore + path: | + ci-artifacts/eda-signoff/** + ci-artifacts/gcd/default/home/flow.json + ci-artifacts/gcd/default/home/checklist.json + ci-artifacts/gcd/default/**/analysis/qor_metrics.json + ci-artifacts/gcd/default/**/analysis/qor_summary.json + ci-artifacts/gcd/default/**/log/** - # - name: Pyright - # run: uv run pyright chipcompiler + - name: Upload packaged EDA logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: packaged-eda-flow-logs + if-no-files-found: ignore + path: | + ci-artifacts/gcd/default/**/log/** + ci-artifacts/gcd/default/home/flow.json + ci-artifacts/gcd/default/home/checklist.json diff --git a/docs/development.md b/docs/development.md index bb23cba1d..57cf9dc08 100644 --- a/docs/development.md +++ b/docs/development.md @@ -18,6 +18,16 @@ nix develop If Nix is not available, run the same `uv sync` commands in your normal shell after installing the required system packages for native builds. +The Nix shell also provides pinned **lit** / **filecheck** and wrappers for the +signoff CI scripts (versions in [`nix/signoff-tools.nix`](../nix/signoff-tools.nix); +flake wiring in [`nix/signoff.nix`](../nix/signoff.nix)): + +```bash +nix run .#filecheck -- --help +nix run .#ci-run-ics55-gcd -- --help +nix run .#ci-export-signoff-csv -- --help +``` + ### ECC Workspace From the `ecc` repository root: diff --git a/flake.nix b/flake.nix index 81137fbbf..a9e7668b6 100644 --- a/flake.nix +++ b/flake.nix @@ -153,14 +153,22 @@ }; in flake-parts.lib.mkFlake { inherit inputs; } { systems = [ "x86_64-linux" ]; - perSystem = { self', pkgs, system, ... }: { - packages.default = pkgs.callPackage chipcompiler { - ecc-dreamplace = ecc-dreamplace.packages.${system}.default; - ecc-tools = ecc-tools.packages.${system}.default; - jsonrpcserver = pkgs.callPackage jsonrpcserver { oslash = pkgs.callPackage oslash {}; }; - rosettakit = pkgs.callPackage rosettakit {}; - yosysWithSlang = infra.packages.${system}.yosysWithSlang; - }; + perSystem = { self', pkgs, system, ... }: + let + signoff = import ./nix/signoff.nix { inherit pkgs; }; + in { + packages = { + default = pkgs.callPackage chipcompiler { + ecc-dreamplace = ecc-dreamplace.packages.${system}.default; + ecc-tools = ecc-tools.packages.${system}.default; + jsonrpcserver = pkgs.callPackage jsonrpcserver { oslash = pkgs.callPackage oslash {}; }; + rosettakit = pkgs.callPackage rosettakit {}; + yosysWithSlang = infra.packages.${system}.yosysWithSlang; + }; + } // signoff.packages; + + apps = signoff.apps; + devShells.default = pkgs.mkShell.override { stdenv = pkgs.ccacheStdenv; } { @@ -178,10 +186,10 @@ nativeBuildInputs = ecc-dreamplace.packages.${system}.default.rawNativeBuildInputs ++ ecc-tools.packages.${system}.default.rawNativeBuildInputs ++ (with pkgs; [ uv - ]); + ]) ++ signoff.nativeBuildInputs; shellHook = '' export CCACHE_DIR="$PWD/.ccache" - ''; + '' + signoff.shellHook; }; }; }; diff --git a/nix/signoff-tools.nix b/nix/signoff-tools.nix new file mode 100644 index 000000000..65db0f497 --- /dev/null +++ b/nix/signoff-tools.nix @@ -0,0 +1,106 @@ +# Signoff CI helpers: lit / filecheck version pins + script wrappers. +# +# Bump `filecheckVersion` / `litVersion` here when upgrading. Prefer this over +# pyproject.dev-dependencies so Nix, local shells, and CI share one pin. + +{ + lib, + python3Packages, + writeShellApplication, + symlinkJoin, +}: + +let + filecheckVersion = "1.0.6"; + filecheck = python3Packages.buildPythonPackage rec { + pname = "filecheck"; + version = filecheckVersion; + pyproject = true; + + src = python3Packages.fetchPypi { + inherit pname version; + hash = "sha256-xBxR9zOwv9rmcY3KS5T7C99y+rcPNfxo1iv4rGDWRC0="; + }; + + build-system = [ python3Packages.poetry-core ]; + + pythonImportsCheck = [ "filecheck" ]; + + meta = { + description = "Python-native clone of LLVM FileCheck"; + mainProgram = "filecheck"; + homepage = "https://github.com/AntonLydike/filecheck"; + license = lib.licenses.asl20; + }; + }; + + litVersion = "18.1.8"; + lit = python3Packages.buildPythonPackage rec { + pname = "lit"; + version = litVersion; + pyproject = true; + + src = python3Packages.fetchPypi { + inherit pname version; + hash = "sha256-R8F0oYaUGugw8E3tdqNERgC+Z9Xl+4KCw3g/umccTts="; + }; + + build-system = [ python3Packages.setuptools ]; + doCheck = false; + + meta = { + description = "LLVM Integrated Tester"; + mainProgram = "lit"; + homepage = "https://llvm.org/docs/CommandGuide/lit.html"; + license = lib.licenses.ncsa; + }; + }; + + ciRunScript = ../.github/scripts/ci_run_ics55_gcd.py; + ciExportScript = ../.github/scripts/ci_export_signoff_csv.py; + + ci-run-ics55-gcd = writeShellApplication { + name = "ci-run-ics55-gcd"; + runtimeInputs = [ python3Packages.python ]; + text = '' + exec ${python3Packages.python.interpreter} ${ciRunScript} "$@" + ''; + }; + + # Resolve chipcompiler from the caller's checkout (.venv after uv sync). + # Override with ECC_REPO_ROOT when not invoked from the repo root. + ci-export-signoff-csv = writeShellApplication { + name = "ci-export-signoff-csv"; + runtimeInputs = [ python3Packages.python ]; + text = '' + root="''${ECC_REPO_ROOT:-$PWD}" + if [ -x "$root/.venv/bin/python" ]; then + py="$root/.venv/bin/python" + else + py="${python3Packages.python.interpreter}" + fi + export PYTHONPATH="$root/test''${PYTHONPATH:+:$PYTHONPATH}" + exec "$py" ${ciExportScript} "$@" + ''; + }; + + signoff-tools = symlinkJoin { + name = "ecc-signoff-tools"; + paths = [ + filecheck + lit + ]; + meta.description = "Pinned lit ${litVersion} + filecheck ${filecheckVersion} for ECC signoff CI"; + }; +in +{ + inherit + filecheck + filecheckVersion + lit + litVersion + ci-run-ics55-gcd + ci-export-signoff-csv + signoff-tools + ; +} diff --git a/nix/signoff.nix b/nix/signoff.nix new file mode 100644 index 000000000..8fa04e243 --- /dev/null +++ b/nix/signoff.nix @@ -0,0 +1,45 @@ +# Flake-facing signoff CI surface. flake.nix imports this and merges outputs. +# +# Tool pins and script wrappers live in ./signoff-tools.nix; bump versions there. + +{ pkgs }: + +let + tools = pkgs.callPackage ./signoff-tools.nix { }; +in +{ + packages = { + filecheck = tools.filecheck; + lit = tools.lit; + signoff-tools = tools.signoff-tools; + ci-run-ics55-gcd = tools.ci-run-ics55-gcd; + ci-export-signoff-csv = tools.ci-export-signoff-csv; + }; + + apps = { + ci-run-ics55-gcd = { + type = "app"; + program = "${tools.ci-run-ics55-gcd}/bin/ci-run-ics55-gcd"; + }; + ci-export-signoff-csv = { + type = "app"; + program = "${tools.ci-export-signoff-csv}/bin/ci-export-signoff-csv"; + }; + filecheck = { + type = "app"; + program = "${tools.filecheck}/bin/filecheck"; + }; + }; + + # Append to devShells.default.nativeBuildInputs + nativeBuildInputs = [ + tools.signoff-tools + tools.ci-run-ics55-gcd + tools.ci-export-signoff-csv + ]; + + # Append to devShells.default.shellHook + shellHook = '' + export ECC_REPO_ROOT="$PWD" + ''; +} diff --git a/test/conftest.py b/test/conftest.py index e9d097b57..bd042b2ec 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -19,23 +19,19 @@ def _load_complete_ics55_pdk_available(): FILELIST_INTEGRATION_PREFIX = "test/data/test_workspace_filelist.py::TestCreateWorkspaceIntegration" -PDK_REQUIRED_TESTS = { - f"{FILELIST_INTEGRATION_PREFIX}::test_workspace_with_filelist": "", - f"{FILELIST_INTEGRATION_PREFIX}::test_workspace_with_nested_filelist": "", - "test/integration/test_rtl2gds_flow.py::test_ics55_gcd": "", -} +# Empty pdk_root means "resolve via env / default layout" in complete_ics55_pdk_available. +PDK_REQUIRED_PREFIXES = ( + FILELIST_INTEGRATION_PREFIX, + "test/integration/test_rtl2gds_flow.py::test_ics55_gcd", +) def pytest_collection_modifyitems(config, items): - repo_root = str(config.rootpath) + if complete_ics55_pdk_available(""): + return skip_missing_pdk = pytest.mark.skip(reason="complete ICS55 PDK is not available") for item in items: - pdk_root = PDK_REQUIRED_TESTS.get(item.nodeid) - if pdk_root is None: - continue - if pdk_root: - pdk_root = f"{repo_root}/{pdk_root}" - if not complete_ics55_pdk_available(pdk_root): + if any(item.nodeid.startswith(prefix) for prefix in PDK_REQUIRED_PREFIXES): item.add_marker(skip_missing_pdk) diff --git a/test/support/__init__.py b/test/support/__init__.py new file mode 100644 index 000000000..cc2b626e3 --- /dev/null +++ b/test/support/__init__.py @@ -0,0 +1 @@ +"""Non-product helpers for CI and tests (not part of the ``ecc`` CLI).""" diff --git a/test/support/csv_export.py b/test/support/csv_export.py new file mode 100644 index 000000000..eab4ee28d --- /dev/null +++ b/test/support/csv_export.py @@ -0,0 +1,308 @@ +"""CSV export for CI/test (not an ``ecc`` CLI surface). + +Builds tabular views from workspace QoR inputs, checklist, and flow.json. +""" + +import csv +import dataclasses +import io +import os +import shutil +from pathlib import Path + +from chipcompiler.utility.file import write_text_atomic +from chipcompiler.utility.json import json_read + +TABLE_FILES = ( + "qor_summary.csv", + "qor_metrics.csv", + "checklist.csv", + "flow_steps.csv", +) + + +@dataclasses.dataclass(frozen=True) +class CsvTable: + filename: str + fieldnames: tuple[str, ...] + rows: tuple[dict, ...] + + +@dataclasses.dataclass(frozen=True) +class CsvExportBundle: + design: str + tables: tuple[CsvTable, ...] + checklist_available: bool + overall_score: float | None + qor_status: str + checklist_status: str + spec_path: str | None = None + + +def render_csv(table: CsvTable) -> str: + buffer = io.StringIO(newline="") + writer = csv.DictWriter( + buffer, + fieldnames=table.fieldnames, + extrasaction="ignore", + lineterminator="\n", + ) + writer.writeheader() + for row in table.rows: + writer.writerow({key: _cell(row.get(key)) for key in table.fieldnames}) + return buffer.getvalue() + + +def build_csv_bundle(workspace, spec=None) -> CsvExportBundle: + from chipcompiler.analysis.qor.loader import load_workspace_qor_inputs + from chipcompiler.engine.qor_report import build_qor_report + from chipcompiler.engine.signoff.report_checklist import build_checklist_report + + qor = build_qor_report(workspace) + inputs = load_workspace_qor_inputs(workspace) + checklist = build_checklist_report(workspace) + design = qor.design or inputs.design or _design_name(workspace) + status = qor.scalar_summary.status if qor.scalar_summary is not None else "" + + builders = { + "qor_summary": lambda: _qor_summary_table(qor, inputs, design), + "qor_metrics": lambda: _qor_metrics_table(inputs, spec), + "checklist": lambda: _checklist_table(checklist, spec), + "flow_steps": lambda: _flow_steps_table(workspace, spec), + } + selected = list(builders) if spec is None or spec.tables is None else list(spec.tables) + unknown = [key for key in selected if key not in builders] + if unknown: + raise ValueError(f"unsupported csv table(s): {', '.join(unknown)}") + tables = tuple(builders[key]() for key in selected) + return CsvExportBundle( + design=design, + tables=tables, + checklist_available=checklist.available, + overall_score=qor.overall_score, + qor_status=status, + checklist_status=checklist.status if checklist.available else "unavailable", + spec_path=None if spec is None else spec.source_path, + ) + + +def write_csv_bundle(bundle: CsvExportBundle, destination_dir: str) -> list[dict]: + os.makedirs(destination_dir, exist_ok=True) + written = [] + for table in bundle.tables: + path = os.path.join(destination_dir, table.filename) + write_text_atomic(path, render_csv(table)) + written.append({"file": table.filename, "rows": len(table.rows), "path": path}) + + if bundle.spec_path and os.path.isfile(bundle.spec_path): + dest = os.path.join(destination_dir, "export_spec.yml") + shutil.copy2(bundle.spec_path, dest) + written.append({"file": "export_spec.yml", "rows": 0, "path": dest}) + return written + + +def _qor_summary_table(qor, inputs, design: str) -> CsvTable: + summary = qor.scalar_summary + return CsvTable( + filename="qor_summary.csv", + fieldnames=( + "design", + "overall_score", + "status", + "profile", + "analyzed_steps", + ), + rows=( + { + "design": design, + "overall_score": qor.overall_score, + "status": summary.status if summary is not None else "", + "profile": summary.profile if summary is not None else inputs.profile, + "analyzed_steps": ";".join(inputs.analyzed_steps), + }, + ), + ) + + +def _qor_metrics_table(inputs, spec=None) -> CsvTable: + base_fields = ( + "step", + "metric_name", + "value", + "unit", + "scope", + "corner", + "project_role", + ) + metrics_spec = None if spec is None else spec.metrics + if metrics_spec is None: + rows = tuple(_metric_row(record) for record in inputs.metrics.values()) + return CsvTable(filename="qor_metrics.csv", fieldnames=base_fields, rows=rows) + + rows = [] + for item in metrics_spec: + record = inputs.metrics.get(item.id) + if record is None: + rows.append( + { + "step": "", + "metric_name": item.id, + "value": "", + "unit": "", + "scope": "", + "corner": "", + "project_role": "", + "reference": item.reference, + "present": False, + } + ) + else: + row = _metric_row(record) + row["reference"] = item.reference + row["present"] = True + rows.append(row) + return CsvTable( + filename="qor_metrics.csv", + fieldnames=base_fields + ("reference", "present"), + rows=tuple(rows), + ) + + +def _metric_row(record) -> dict: + return { + "step": record.step, + "metric_name": record.metric_id, + "value": record.value, + "unit": record.unit, + "scope": record.scope or "", + "corner": record.corner or "", + "project_role": record.project_role, + } + + +def _checklist_table(checklist, spec=None) -> CsvTable: + base_fields = ( + "id", + "step", + "category", + "title", + "state", + "policy", + "blocked", + "summary", + "evidence", + ) + checklist_spec = None if spec is None else spec.checklist + if checklist_spec is None: + return CsvTable( + filename="checklist.csv", + fieldnames=base_fields, + rows=tuple(_checklist_row(item) for item in checklist.items), + ) + + by_id = {item.id: item for item in checklist.items} + rows = [] + for wanted in checklist_spec: + item = by_id.get(wanted.id) + if item is None: + rows.append( + { + "id": wanted.id, + "step": "", + "category": "", + "title": "", + "state": "", + "policy": "", + "blocked": "", + "summary": "", + "evidence": "", + "present": False, + } + ) + else: + row = _checklist_row(item) + row["present"] = True + rows.append(row) + return CsvTable( + filename="checklist.csv", + fieldnames=base_fields + ("present",), + rows=tuple(rows), + ) + + +def _checklist_row(item) -> dict: + return { + "id": item.id, + "step": item.step, + "category": item.category, + "title": item.title, + "state": item.state, + "policy": item.policy, + "blocked": item.blocked, + "summary": item.summary, + "evidence": ";".join(item.evidence), + } + + +def _flow_steps_table(workspace, spec=None) -> CsvTable: + workspace_root = Path(workspace.directory or "") + flow = json_read(workspace_root / "home" / "flow.json") + steps = flow.get("steps", []) if isinstance(flow, dict) else [] + raw_rows = [] + for step in steps: + if not isinstance(step, dict) or not step.get("name"): + continue + raw_rows.append( + { + "name": step.get("name"), + "tool": step.get("tool", ""), + "state": step.get("state", ""), + "runtime": step.get("runtime", ""), + "peak_memory_mb": step.get("peak memory (mb)", ""), + } + ) + + allow = None if spec is None else spec.flow_steps + base_fields = ("name", "tool", "state", "runtime", "peak_memory_mb") + if allow is None: + return CsvTable(filename="flow_steps.csv", fieldnames=base_fields, rows=tuple(raw_rows)) + + by_name = {row["name"]: row for row in raw_rows} + by_folded = {str(row["name"]).casefold(): row for row in raw_rows} + rows = [] + for name in allow: + row = by_name.get(name) or by_folded.get(name.casefold()) + if row is None: + rows.append( + { + "name": name, + "tool": "", + "state": "", + "runtime": "", + "peak_memory_mb": "", + "present": False, + } + ) + else: + rows.append({**row, "present": True}) + return CsvTable( + filename="flow_steps.csv", + fieldnames=base_fields + ("present",), + rows=tuple(rows), + ) + + +def _design_name(workspace) -> str: + design = getattr(workspace, "design", None) + name = getattr(design, "name", None) if design is not None else None + if isinstance(name, str) and name.strip(): + return name.strip() + return Path(workspace.directory or ".").name + + +def _cell(value) -> str: + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) diff --git a/test/support/csv_profiles/ics55_gcd.check b/test/support/csv_profiles/ics55_gcd.check new file mode 100644 index 000000000..ac4f1ac3d --- /dev/null +++ b/test/support/csv_profiles/ics55_gcd.check @@ -0,0 +1,17 @@ +# FileCheck contract for the real ics55 gcd rtl2gds CSV projection. +# Values may drift; require profile ids present=true (not fixture constants). +# +# RUN (CI): +# filecheck --input-file=/reports/metrics.check.txt test/support/csv_profiles/ics55_gcd.check + +CHECK: design: gcd +CHECK: section: metrics +CHECK-DAG: metric drc_count value={{.*}} present=true reference={{.*}} +CHECK-DAG: metric lvs_count value={{.*}} present=true reference={{.*}} +CHECK-DAG: metric sta_setup_wns value={{.*}} present=true reference={{.*}} +CHECK-DAG: metric sta_hold_wns value={{.*}} present=true reference={{.*}} +CHECK: section: checklist +CHECK-DAG: checklist quality.drc.clean {{.*}} present=true +CHECK-DAG: checklist quality.lvs.clean {{.*}} present=true +CHECK-DAG: checklist quality.sta.setup_closed {{.*}} present=true +CHECK-DAG: checklist quality.sta.hold_closed {{.*}} present=true diff --git a/test/support/csv_profiles/signoff.yml b/test/support/csv_profiles/signoff.yml new file mode 100644 index 000000000..ed7dcd294 --- /dev/null +++ b/test/support/csv_profiles/signoff.yml @@ -0,0 +1,32 @@ +# CI/test CSV profile: DRC + timing (+ light LVS) for FileCheck / artifact export. +# Override with ECC_SIGNOFF_CSV_SPEC. Not an ``ecc`` CLI surface. + +version: 1 + +tables: + - qor_summary + - qor_metrics + - checklist + - flow_steps + +metrics: + - id: drc_count + reference: 0 + - id: lvs_count + reference: 0 + - id: sta_setup_wns + reference: 0.0 + - id: sta_hold_wns + reference: 0.0 + +checklist: + - id: quality.drc.clean + - id: quality.lvs.clean + - id: quality.sta.setup_closed + - id: quality.sta.hold_closed + +flow_steps: + - DRC + - LVS + - sta + - Harden diff --git a/test/support/csv_projection.py b/test/support/csv_projection.py new file mode 100644 index 000000000..c04da9aab --- /dev/null +++ b/test/support/csv_projection.py @@ -0,0 +1,60 @@ +"""Stable text projection of CSV tables for FileCheck.""" + +from __future__ import annotations + +import csv +from pathlib import Path + + +def read_csv_rows(path: Path) -> list[dict]: + if not path.is_file(): + return [] + with path.open(newline="", encoding="utf-8") as handle: + return list(csv.DictReader(handle)) + + +def projection_lines(csv_dir: Path, design: str) -> list[str]: + """Emit one stable line per metric/checklist id for FileCheck.""" + lines = [f"design: {design}", "section: metrics"] + seen_metrics: set[str] = set() + for row in read_csv_rows(csv_dir / "qor_metrics.csv"): + name = row.get("metric_name") or "" + if not name or name in seen_metrics: + continue + seen_metrics.add(name) + lines.append( + "metric {name} value={value} present={present} reference={reference}".format( + name=name, + value=row.get("value", ""), + present=row.get("present", "true"), + reference=row.get("reference", ""), + ) + ) + if not seen_metrics: + lines.append("metrics: empty") + + lines.append("section: checklist") + seen_checklist: set[str] = set() + for row in read_csv_rows(csv_dir / "checklist.csv"): + item_id = row.get("id") or "" + if not item_id or item_id in seen_checklist: + continue + seen_checklist.add(item_id) + lines.append( + "checklist {item_id} state={state} present={present} blocked={blocked}".format( + item_id=item_id, + state=row.get("state", ""), + present=row.get("present", "true"), + blocked=row.get("blocked", ""), + ) + ) + if not seen_checklist: + lines.append("checklist: empty") + return lines + + +def write_projection(csv_dir: Path, design: str, destination: Path) -> Path: + destination.parent.mkdir(parents=True, exist_ok=True) + text = "\n".join(projection_lines(csv_dir, design)) + "\n" + destination.write_text(text, encoding="utf-8") + return destination diff --git a/test/support/csv_spec.py b/test/support/csv_spec.py new file mode 100644 index 000000000..bcbbcf3a5 --- /dev/null +++ b/test/support/csv_spec.py @@ -0,0 +1,159 @@ +"""YAML profile for CI/test CSV export (not an ``ecc`` CLI surface). + +Reference gates and include lists live in the profile so they can change +without code edits. When an include list is set, the CSV always emits one +contract row per requested id (``present=false`` if the workspace has no +matching data). +""" + +import dataclasses +import os +from pathlib import Path + +import yaml + +TABLE_KEYS = ( + "qor_summary", + "qor_metrics", + "checklist", + "flow_steps", +) + +TABLE_KEY_TO_FILE = {key: f"{key}.csv" for key in TABLE_KEYS} + + +@dataclasses.dataclass(frozen=True) +class MetricSpec: + id: str + reference: object | None = None + + +@dataclasses.dataclass(frozen=True) +class ChecklistItemSpec: + id: str + + +@dataclasses.dataclass(frozen=True) +class CsvExportSpec: + """Parsed ``version: 1`` CSV export profile.""" + + version: int + tables: tuple[str, ...] | None + metrics: tuple[MetricSpec, ...] | None + checklist: tuple[ChecklistItemSpec, ...] | None + flow_steps: tuple[str, ...] | None + source_path: str | None = None + + +class CsvSpecError(ValueError): + """Invalid or unreadable CSV export profile.""" + + +def load_csv_spec(path: str) -> CsvExportSpec: + """Load and validate a CSV export profile from ``path``.""" + resolved = os.path.abspath(os.path.expanduser(path)) + try: + with open(resolved, encoding="utf-8") as handle: + payload = yaml.safe_load(handle) + except OSError as exc: + raise CsvSpecError(f"cannot read csv spec: {exc}") from exc + except yaml.YAMLError as exc: + raise CsvSpecError(f"invalid yaml in csv spec: {exc}") from exc + return parse_csv_spec(payload, source_path=resolved) + + +def parse_csv_spec(payload, *, source_path: str | None = None) -> CsvExportSpec: + if not isinstance(payload, dict): + raise CsvSpecError("csv spec root must be a mapping") + version = payload.get("version", 1) + if version != 1: + raise CsvSpecError(f"unsupported csv spec version: {version!r}") + + tables = _optional_string_list(payload.get("tables"), field="tables") + if tables is not None: + unknown = [name for name in tables if name not in TABLE_KEY_TO_FILE] + if unknown: + raise CsvSpecError( + f"unknown table(s): {', '.join(unknown)}; expected one of {', '.join(TABLE_KEYS)}" + ) + + metrics = _parse_metrics(payload.get("metrics")) + checklist = _parse_checklist(payload.get("checklist")) + flow_steps = _optional_string_list(payload.get("flow_steps"), field="flow_steps") + + return CsvExportSpec( + version=1, + tables=tables, + metrics=metrics, + checklist=checklist, + flow_steps=flow_steps, + source_path=source_path, + ) + + +def default_profile_path() -> Path: + """Default CI/test profile with mutable reference gates.""" + return Path(__file__).resolve().parent / "csv_profiles" / "signoff.yml" + + +def _optional_string_list(value, *, field: str) -> tuple[str, ...] | None: + if value is None: + return None + if not isinstance(value, list) or not value: + raise CsvSpecError(f"{field} must be a non-empty list when set") + items = [] + for entry in value: + if not isinstance(entry, str) or not entry.strip(): + raise CsvSpecError(f"{field} entries must be non-empty strings") + items.append(entry.strip()) + return tuple(items) + + +def _parse_metrics(value) -> tuple[MetricSpec, ...] | None: + if value is None: + return None + if not isinstance(value, list) or not value: + raise CsvSpecError("metrics must be a non-empty list when set") + items = [] + seen = set() + for entry in value: + if isinstance(entry, str): + metric_id = entry.strip() + reference = None + elif isinstance(entry, dict): + raw_id = entry.get("id") or entry.get("name") + if not isinstance(raw_id, str) or not raw_id.strip(): + raise CsvSpecError("metrics[].id must be a non-empty string") + metric_id = raw_id.strip() + reference = entry.get("reference", entry.get("ref")) + else: + raise CsvSpecError("metrics entries must be strings or mappings") + if metric_id in seen: + raise CsvSpecError(f"duplicate metric id: {metric_id}") + seen.add(metric_id) + items.append(MetricSpec(id=metric_id, reference=reference)) + return tuple(items) + + +def _parse_checklist(value) -> tuple[ChecklistItemSpec, ...] | None: + if value is None: + return None + if not isinstance(value, list) or not value: + raise CsvSpecError("checklist must be a non-empty list when set") + items = [] + seen = set() + for entry in value: + if isinstance(entry, str): + item_id = entry.strip() + elif isinstance(entry, dict): + raw_id = entry.get("id") + if not isinstance(raw_id, str) or not raw_id.strip(): + raise CsvSpecError("checklist[].id must be a non-empty string") + item_id = raw_id.strip() + else: + raise CsvSpecError("checklist entries must be strings or mappings") + if item_id in seen: + raise CsvSpecError(f"duplicate checklist id: {item_id}") + seen.add(item_id) + items.append(ChecklistItemSpec(id=item_id)) + return tuple(items) diff --git a/test/test_csv_export.py b/test/test_csv_export.py new file mode 100644 index 000000000..a5257146f --- /dev/null +++ b/test/test_csv_export.py @@ -0,0 +1,146 @@ +import csv +import io + +import pytest +import yaml +from support.csv_export import ( + TABLE_FILES, + build_csv_bundle, + render_csv, + write_csv_bundle, +) +from support.csv_spec import ( + CsvSpecError, + default_profile_path, + load_csv_spec, + parse_csv_spec, +) +from test_qor_report import _make_workspace + + +class TestCsvExport: + def test_bundle_tables_match_contract(self, tmp_path): + bundle = build_csv_bundle(_make_workspace(tmp_path)) + assert bundle.design == "gcd" + assert bundle.checklist_available is True + assert {table.filename for table in bundle.tables} == set(TABLE_FILES) + + by_name = {table.filename: table for table in bundle.tables} + assert by_name["qor_summary.csv"].rows[0]["design"] == "gcd" + assert by_name["qor_summary.csv"].rows[0]["overall_score"] == bundle.overall_score + assert any(row["metric_name"] == "sta_setup_wns" for row in by_name["qor_metrics.csv"].rows) + assert "quality.drc.clean" in {row["id"] for row in by_name["checklist.csv"].rows} + flow_names = [row["name"] for row in by_name["flow_steps.csv"].rows] + assert "sta" in flow_names + assert "Harden" in flow_names + + def test_write_creates_all_files(self, tmp_path): + bundle = build_csv_bundle(_make_workspace(tmp_path / "ws")) + destination = tmp_path / "out" + written = write_csv_bundle(bundle, str(destination)) + + assert [entry["file"] for entry in written] == list(TABLE_FILES) + for name in TABLE_FILES: + path = destination / name + assert path.is_file() + with path.open(newline="") as handle: + rows = list(csv.DictReader(handle)) + table = next(t for t in bundle.tables if t.filename == name) + assert len(rows) == len(table.rows) + + def test_render_csv_bool_and_none(self): + from support.csv_export import CsvTable + + text = render_csv( + CsvTable( + filename="t.csv", + fieldnames=("a", "b", "c"), + rows=({"a": True, "b": False, "c": None},), + ) + ) + reader = csv.DictReader(io.StringIO(text)) + assert list(reader) == [{"a": "true", "b": "false", "c": ""}] + + def test_empty_checklist_still_writes_header(self, tmp_path): + bundle = build_csv_bundle(_make_workspace(tmp_path, with_checklist=False)) + assert bundle.checklist_available is False + checklist = next(t for t in bundle.tables if t.filename == "checklist.csv") + assert checklist.rows == () + assert render_csv(checklist).startswith("id,step,category,") + + +class TestCsvSpec: + def test_parse_rejects_unknown_table(self): + with pytest.raises(CsvSpecError, match="unknown table"): + parse_csv_spec({"version": 1, "tables": ["nope"]}) + + def test_spec_injects_reference_and_missing_rows(self, tmp_path): + spec_path = tmp_path / "profile.yml" + spec_path.write_text( + yaml.dump( + { + "version": 1, + "tables": ["qor_metrics", "checklist", "flow_steps"], + "metrics": [ + {"id": "sta_setup_wns", "reference": 0.0}, + {"id": "missing_metric", "reference": 1}, + ], + "checklist": [ + {"id": "quality.drc.clean"}, + {"id": "missing.item"}, + ], + "flow_steps": ["sta", "GhostStep"], + } + ), + encoding="utf-8", + ) + spec = load_csv_spec(str(spec_path)) + bundle = build_csv_bundle(_make_workspace(tmp_path / "ws"), spec=spec) + assert [t.filename for t in bundle.tables] == [ + "qor_metrics.csv", + "checklist.csv", + "flow_steps.csv", + ] + assert bundle.spec_path == str(spec_path.resolve()) + + metrics = {row["metric_name"]: row for row in bundle.tables[0].rows} + assert metrics["sta_setup_wns"]["present"] is True + assert metrics["sta_setup_wns"]["reference"] == 0.0 + assert metrics["missing_metric"]["present"] is False + assert metrics["missing_metric"]["reference"] == 1 + + checklist = {row["id"]: row for row in bundle.tables[1].rows} + assert checklist["quality.drc.clean"]["present"] is True + assert checklist["missing.item"]["present"] is False + + flow = {row["name"]: row for row in bundle.tables[2].rows} + assert flow["sta"]["present"] is True + assert flow["GhostStep"]["present"] is False + + out = tmp_path / "csv" + written = write_csv_bundle(bundle, str(out)) + assert (out / "export_spec.yml").is_file() + assert any(entry["file"] == "export_spec.yml" for entry in written) + + def test_default_signoff_profile_loads(self): + spec = load_csv_spec(str(default_profile_path())) + assert spec.version == 1 + assert spec.metrics is not None + assert any(item.id == "drc_count" for item in spec.metrics) + + +class TestCsvProjection: + def test_projection_lists_spec_ids(self, tmp_path): + from support.csv_projection import projection_lines, write_projection + + spec = load_csv_spec(str(default_profile_path())) + bundle = build_csv_bundle(_make_workspace(tmp_path / "ws"), spec=spec) + csv_dir = tmp_path / "csv" + write_csv_bundle(bundle, str(csv_dir)) + lines = projection_lines(csv_dir, bundle.design) + text = "\n".join(lines) + assert "design: gcd" in text + assert "metric drc_count " in text + assert "checklist quality.drc.clean " in text + path = write_projection(csv_dir, bundle.design, tmp_path / "metrics.check.txt") + assert path.read_text(encoding="utf-8") == text + "\n" diff --git a/uv.lock b/uv.lock index aedf194bc..3801eea54 100644 --- a/uv.lock +++ b/uv.lock @@ -3,10 +3,10 @@ revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", - "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", "python_full_version >= '3.14' and sys_platform != 'darwin'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'darwin'", "python_full_version >= '3.14' and platform_machine != 'x86_64' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'darwin'", "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'x86_64' and sys_platform == 'darwin'", "python_full_version < '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'", "python_full_version < '3.12' and sys_platform != 'darwin'", @@ -1003,7 +1003,7 @@ name = "macholib" version = "1.16.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "altgraph", marker = "sys_platform == 'darwin'" }, + { name = "altgraph" }, ] sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } wheels = [ @@ -2503,16 +2503,16 @@ resolution-markers = [ "python_full_version >= '3.14' and platform_machine != 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "cloudpickle", marker = "python_full_version >= '3.14'" }, - { name = "numba", marker = "python_full_version >= '3.14'" }, - { name = "numpy", marker = "python_full_version >= '3.14'" }, - { name = "packaging", marker = "python_full_version >= '3.14'" }, - { name = "pandas", marker = "python_full_version >= '3.14'" }, - { name = "scikit-learn", marker = "python_full_version >= '3.14'" }, - { name = "scipy", marker = "python_full_version >= '3.14'" }, - { name = "slicer", marker = "python_full_version >= '3.14'" }, - { name = "tqdm", marker = "python_full_version >= '3.14'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, + { name = "cloudpickle" }, + { name = "numba" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "slicer" }, + { name = "tqdm" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dc/c6/9823a7f483aa9f3179fc359c10d22da9e418b1a7a3fc99a42b705d05e82a/shap-0.49.1.tar.gz", hash = "sha256:1114ecd804fff29f50d522ce6031082fcf42fe4a32fb1b5da233b2415d784c8c", size = 4084725, upload-time = "2025-10-14T10:04:49.75Z" } wheels = [ @@ -2549,16 +2549,16 @@ resolution-markers = [ "python_full_version < '3.12' and platform_machine != 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "cloudpickle", marker = "python_full_version < '3.14'" }, - { name = "numba", marker = "python_full_version < '3.14'" }, - { name = "numpy", marker = "python_full_version < '3.14'" }, - { name = "packaging", marker = "python_full_version < '3.14'" }, - { name = "pandas", marker = "python_full_version < '3.14'" }, - { name = "scikit-learn", marker = "python_full_version < '3.14'" }, - { name = "scipy", marker = "python_full_version < '3.14'" }, - { name = "slicer", marker = "python_full_version < '3.14'" }, - { name = "tqdm", marker = "python_full_version < '3.14'" }, - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, + { name = "cloudpickle" }, + { name = "numba" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "slicer" }, + { name = "tqdm" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2b/2c/9ccbfbdf5ceeb914317f9691ef1fca3118d4a997eb5e79bcd8992f56c938/shap-0.50.0.tar.gz", hash = "sha256:bdc559acf7f647bc3bb22c6a1fea9f50716ed357ad595bc357b43082ae4dc6b9", size = 4087800, upload-time = "2025-11-11T18:36:53.363Z" } wheels = [ @@ -2822,13 +2822,13 @@ resolution-markers = [ "python_full_version < '3.12' and platform_machine != 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "filelock", marker = "platform_machine != 'x86_64' and sys_platform == 'darwin'" }, - { name = "fsspec", marker = "platform_machine != 'x86_64' and sys_platform == 'darwin'" }, - { name = "jinja2", marker = "platform_machine != 'x86_64' and sys_platform == 'darwin'" }, - { name = "networkx", marker = "platform_machine != 'x86_64' and sys_platform == 'darwin'" }, - { name = "setuptools", marker = "platform_machine != 'x86_64' and sys_platform == 'darwin'" }, - { name = "sympy", marker = "platform_machine != 'x86_64' and sys_platform == 'darwin'" }, - { name = "typing-extensions", marker = "platform_machine != 'x86_64' and sys_platform == 'darwin'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "typing-extensions" }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d75eadcd97fe0dc7cd0eedc4d72152484c19cb2cfe46ce55766c8e129116425f", upload-time = "2026-03-23T15:16:54Z" }, @@ -2845,20 +2845,20 @@ version = "2.11.0+cpu" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", - "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", "python_full_version >= '3.14' and sys_platform != 'darwin'", + "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'darwin'", "python_full_version < '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'", "python_full_version < '3.12' and sys_platform != 'darwin'", ] dependencies = [ - { name = "filelock", marker = "platform_machine == 'x86_64' or sys_platform != 'darwin'" }, - { name = "fsspec", marker = "platform_machine == 'x86_64' or sys_platform != 'darwin'" }, - { name = "jinja2", marker = "platform_machine == 'x86_64' or sys_platform != 'darwin'" }, - { name = "networkx", marker = "platform_machine == 'x86_64' or sys_platform != 'darwin'" }, - { name = "setuptools", marker = "platform_machine == 'x86_64' or sys_platform != 'darwin'" }, - { name = "sympy", marker = "platform_machine == 'x86_64' or sys_platform != 'darwin'" }, - { name = "typing-extensions", marker = "platform_machine == 'x86_64' or sys_platform != 'darwin'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "typing-extensions" }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:5214b203ee187f8746c66f1378b72611b7c1e15c5cb325037541899e705ea24e", upload-time = "2026-04-27T21:55:40Z" },